diff --git a/.github/agents/release-manager.agent.md b/.github/agents/release-manager.agent.md new file mode 100644 index 0000000..50f61fb --- /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 0000000..bcbdf35 --- /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 0000000..c5c258c --- /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 0000000..07f61ff --- /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 0000000..966bbe1 --- /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 1e48e16..a8bb2d9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,25 @@ This repository contains the official C# SDK for the Model Context Protocol (MCP), enabling .NET applications to implement and interact with MCP clients and servers. +## GitHub Interactions + +### Commits and Pushes +- Never push to an active pull request without being explicitly asked. Always wait for explicit instructions to push. +- Never chain commit and push in the same command. Commit first, report what was committed, then wait for explicit push instructions. This creates a mandatory decision point. + +### AI-Generated Content Disclosure +When posting any content to GitHub under a user's credentials — opening pull requests, creating issues, commenting on pull requests or issues, posting review comments, or taking any other public-facing action — include a concise, visible note (e.g. a `> [!NOTE]` alert) at the bottom indicating that the content was AI/Copilot-generated. + +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 5c973fc..d7418b0 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,19 @@ 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 -- 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) +Publishing the release triggers two workflows in parallel. Invoke the `verify-release` skill to +monitor both and confirm their published outputs: + +- **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. Its `docs_source` input defaults + to `release-tags` to mirror a release deployment; select `latest-branches` to refresh every + published major from its current `release/{MAJOR}.x` branch, using `main` for its + matching major. diff --git a/.github/skills/breaking-changes/references/classification.md b/.github/skills/breaking-changes/references/classification.md index e588972..9c34c37 100644 --- a/.github/skills/breaking-changes/references/classification.md +++ b/.github/skills/breaking-changes/references/classification.md @@ -122,7 +122,7 @@ Before dismissing a potential break, review the PR description and all PR commen Every dismissed potential break must be reported to the user with enough detail for them to verify the conclusion. The audit must: 1. **Identify what would normally be breaking and why** (e.g., "CP0005 — adding abstract member `Completion` to abstract class `McpClient`") -2. **Explain the specific reason for dismissal** (e.g., "Bug fix correcting incorrect behavior per the MCP spec" or "`McpClient`'s only accessible constructor is `protected` and marked `[Experimental(MCPEXP002)]` with message 'Subclassing McpClient and McpServer is experimental and subject to change.'") +2. **Explain the specific reason for dismissal** (e.g., "Bug fix correcting incorrect behavior per the MCP spec" or "`McpClient`'s only accessible constructor is `protected` and marked `[Experimental(MCPEXP002)]` with message 'This C# SDK extensibility API is experimental and subject to change.'") 3. **Cite any supporting discussion** from the PR description or comments (e.g., "Reviewers discussed the addition and did not flag it as a breaking concern; compatibility suppressions were added for CP0005") 4. **Conclude with the dismissal and its category** (e.g., "Dismissed — bug fix correcting spec-non-compliant behavior" or "Dismissed — exclusively gated by `[Experimental]` API. Do not apply the `breaking-change` label.") diff --git a/.github/skills/bump-version/SKILL.md b/.github/skills/bump-version/SKILL.md index e789156..1127aa2 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 f1f5f6d..3d14978 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -62,11 +62,11 @@ 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 -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 29bc583..5e66a16 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 945df1a..78d1d13 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 8445667..55d2570 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 173bb20..be4d19b 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 467dbb3..888f3c5 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 4775677..298776b 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 0000000..ad54776 --- /dev/null +++ b/.github/skills/verify-release/SKILL.md @@ -0,0 +1,197 @@ +--- +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.** A release-triggered run builds each major from its latest release tag + into its own path (`/v1/`, `/v2/`). A manual dispatch defaults to the same release-tag content, + or can build every major from `release/{MAJOR}.x`, using `main` for its matching major, + by selecting `docs_source=latest-branches`. A new MAJOR adds a new path; the site root redirects + to the newest release, prereleases included. +- **Orchestration comes from `main`.** The scripts and picker assets are always checked out from + `main`. Release-triggered and default manual runs use release-tag content; a manual + `docs_source=latest-branches` run uses source-branch content. A docs fix that lives only in a + release branch will not affect orchestration. + +If it failed, summarize the failing step. Common causes are a docs build failure in one version's +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 +``` + +**Rebuild the released docs** — use the default `release-tags` source to retry a failed Pages +deployment after a release, without minting a product release: + +``` +gh workflow run docs.yml --field docs_source=release-tags +``` + +**Refresh docs without a new release** — select `latest-branches` to rebuild every published major +from its current source branch (`release/{MAJOR}.x`, using `main` for its matching major): + +``` +gh workflow run docs.yml --field docs_source=latest-branches +``` + +The workflow fails instead of silently using a release tag when any major has no matching branch. + +## Edge Cases + +- **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. diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index faab32f..86e48a2 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -38,19 +38,19 @@ jobs: steps: - name: 📥 Clone the repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: 🔧 Set up .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x 9.0.x - name: 🔧 Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index 4852801..e328f2b 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -10,9 +10,9 @@ jobs: publish-coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x @@ -24,7 +24,7 @@ jobs: pattern: testresults-* - name: Combine coverage reports - uses: danielpalme/ReportGenerator-GitHub-Action@7ae927204961589fcb0b0be245c51fbbc87cbca2 # 5.5.5 + uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # 5.5.11 with: reports: "**/*.cobertura.xml" targetdir: "${{ github.workspace }}/report" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f072844..bed505d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 6d0ed95..580ac1c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -11,10 +11,10 @@ jobs: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install .NET SDK - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index eba0c0b..458e3fc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,9 +1,28 @@ name: Publish Docs +# Publishes the versioned docs site to GitHub Pages. +# +# Discovers the versions to publish from the repository's GitHub releases (the +# latest release, by date, for each major >= 1), builds each release tag into +# its own sub-path (e.g. /v1/ and /v2/) with make generate-docs, injects a +# version-picker widget into each page, adds a root redirect to the default +# version, and deploys the combined site. A manual dispatch can build every +# major-version path from release tags or current source branches. +# Triggers on release publish and manual dispatch. + on: release: types: [published] workflow_dispatch: + inputs: + docs_source: + description: Source for every published major-version path + required: true + default: release-tags + type: choice + options: + - release-tags + - latest-branches # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -11,11 +30,12 @@ permissions: pages: write id-token: write # Required for actions/deploy-pages -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +# Allow only one concurrent deployment. Cancel any in-progress run when a newer one +# starts: every run rediscovers the current releases and rebuilds the whole site from +# scratch, so a newer run fully supersedes the work of the one it cancels. concurrency: group: "pages" - cancel-in-progress: false + cancel-in-progress: true jobs: publish-docs: @@ -26,23 +46,122 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout docs orchestration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # A release event otherwise checks out the released tag. Keep the + # orchestration scripts and picker assets current with main. + ref: main + # Full history + tags so we can add a worktree for each version's release tag. + fetch-depth: 0 - name: .NET Setup - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x 9.0.x - - name: Generate documentation - run: make generate-docs + - name: Discover versions from GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + # For every major version >= 1, take the most recently published (by date) + # non-draft release tagged v{major}.*. The vN prefix becomes the URL slug; + # the site root redirects to the newest release, including prereleases. + run: | + set -euo pipefail + gh release list --repo "${{ github.repository }}" --limit 200 \ + --json tagName,isPrerelease,isDraft,publishedAt \ + --jq ' + [ .[] + | select((.isDraft | not) and (.tagName | test("^v[1-9][0-9]*\\."))) + | { slug: (.tagName | match("^v[0-9]+").string), + ref: .tagName, label: .tagName, + prerelease: .isPrerelease, published: .publishedAt } + ] + | group_by(.slug) + | map(max_by(.published)) + | sort_by(.published) | reverse + | { default: .[0].slug, + versions: map({ slug, ref, label, prerelease }) } + ' | tee "$RUNNER_TEMP/docs-versions.json" + + - name: Build all versions + shell: bash + env: + USE_SOURCE_BRANCHES: ${{ inputs.docs_source == 'latest-branches' }} + run: | + set -euo pipefail + ROOT="$PWD" + COMBINED="$ROOT/combined" + rm -rf "$COMBINED" + mkdir -p "$COMBINED" + + # Orchestration (discovered docs-versions.json, scripts, picker assets) + # always comes from THIS branch. Normally each version's HTML is produced + # by its release tag. A manual dispatch can instead build every major from + # its current source branch. + git fetch --tags --force --prune origin "+refs/heads/*:refs/remotes/origin/*" + USE_SOURCE_BRANCHES="${USE_SOURCE_BRANCHES:-false}" + + MAIN_SLUG="" + if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then + MAIN_PROPS="$(mktemp)" + git show refs/remotes/origin/main:src/Directory.Build.props > "$MAIN_PROPS" + MAIN_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$MAIN_PROPS")" + rm -f "$MAIN_PROPS" + echo "Refreshing all published docs from source branches (main serves $MAIN_SLUG)" + else + echo "Refreshing all published docs from release tags" + fi + + while IFS=$'\t' read -r slug ref; do + if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then + source_branch="release/${slug#v}.x" + if ! git show-ref --verify --quiet "refs/remotes/origin/$source_branch"; then + if [[ "$slug" == "$MAIN_SLUG" ]]; then + source_branch="main" + else + echo "::error::No source branch found for $slug. Expected release/${slug#v}.x, or main with VersionPrefix for $slug." + exit 1 + fi + fi + + echo "::group::Build $slug (from branch $source_branch)" + wt="$ROOT/../work-$slug" + git worktree add --force --detach "$wt" "refs/remotes/origin/$source_branch" + source_slug="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$wt/src/Directory.Build.props")" + if [[ "$source_slug" != "$slug" ]]; then + git worktree remove --force "$wt" + echo "::error::Source branch '$source_branch' has major '$source_slug', not '$slug'." + exit 1 + fi + else + echo "::group::Build $slug (from tag $ref)" + wt="$ROOT/../work-$slug" + git worktree add --force --detach "$wt" "refs/tags/$ref" + fi + + make -C "$wt" generate-docs + + mkdir -p "$COMBINED/$slug" + cp -a "$wt/artifacts/_site/." "$COMBINED/$slug/" + node "$ROOT/scripts/inject-version-picker.mjs" "$COMBINED/$slug" "$slug" --base / + + git worktree remove --force "$wt" + echo "::endgroup::" + done < <(node "$ROOT/scripts/list-versions.mjs") + + node "$ROOT/scripts/finalize-docs-site.mjs" "$COMBINED" + + echo "Combined site contents:" + ls -la "$COMBINED" - name: Upload Pages artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: - path: 'artifacts/_site' + path: 'combined' - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index dd907de..1dbf83b 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Markup Link Checker (mlc) uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eca7a31..4c96a86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,12 +56,12 @@ jobs: steps: - name: Clone the repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x @@ -84,10 +84,10 @@ jobs: version_suffix_args: ${{ github.event_name != 'release' && format('--version-suffix "{0}"', inputs.version_suffix_override || format('ci.{0}', github.run_number)) || '' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x @@ -114,10 +114,10 @@ jobs: packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x @@ -149,7 +149,7 @@ jobs: packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -169,10 +169,10 @@ jobs: permissions: { } steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x diff --git a/Directory.Build.props b/Directory.Build.props index f5cdd3a..8bf8354 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,30 +11,19 @@ - - - Debug - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)')) $([MSBuild]::NormalizeDirectory('$(RepoRoot)', 'artifacts')) $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'obj')) $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'bin')) - $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestResults', '$(Configuration)')) - $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'packages', '$(Configuration)')) $(MSBuildProjectName) $([System.IO.Path]::GetFullPath('$(ArtifactsObjDir)$(OutDirName)\')) - $(BaseIntermediateOutputPath)$(Configuration)\ $([System.IO.Path]::GetFullPath('$(ArtifactsBinDir)$(OutDirName)\')) - $(BaseOutputPath)$(Configuration)\ - - $(ArtifactsPackagesDir) trx%3bLogFileName=$(MSBuildProjectName).$(TargetFramework).$(OS).trx - $(ArtifactsTestResultsDir) diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..d545f8b --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestResults', '$(Configuration)')) + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'packages', '$(Configuration)')) + $(ArtifactsPackagesDir) + $(ArtifactsTestResultsDir) + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 498e2a7..565d016 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,10 @@ true - 8.0.22 - 9.0.11 - 10.0.7 - 10.5.2 + 8.0.29 + 9.0.18 + 10.0.10 + 10.8.3 @@ -58,13 +58,14 @@ - + - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -75,24 +76,24 @@ - + - - - - - - + + + + + + - + - + diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index abfb430..9020d2f 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -69,6 +69,7 @@ + diff --git a/README.md b/README.md index fd2ed2a..71902e4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. + ## Getting Started To get started, see the [Getting Started](https://csharp.sdk.modelcontextprotocol.io/concepts/getting-started.html) guide in the conceptual documentation for installation instructions, package-selection guidance, and complete examples for both clients and servers. diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index 285f48d..f224799 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -111,11 +111,11 @@ public static string GetWeather(McpServer server, string location) The `Visibility` property controls which principals can invoke the tool: -| Value | Meaning | -| - | - | -| `McpUiToolVisibility.Model` | Only the LLM can call this tool | -| `McpUiToolVisibility.App` | Only the app UI can call this tool | -| Both (or null/empty) | Both the model and app can call the tool (default) | +| Value | Meaning | +|-----------------------------|----------------------------------------------------| +| `McpUiToolVisibility.Model` | Only the LLM can call this tool | +| `McpUiToolVisibility.App` | Only the app UI can call this tool | +| Both (or null/empty) | Both the model and app can call the tool (default) | ## UI resources @@ -164,7 +164,7 @@ The MCP Apps spec defines display modes (`inline`, `fullscreen`, `pip`) that con ## Host theming -Hosts pass standardized CSS custom properties (e.g., `--color-background-primary`, `--color-text-primary`) to app iframes. Your HTML can reference these variables to automatically match the host's theme without any server-side configuration. +Hosts pass standardized CSS custom properties (for example, `--color-background-primary`, `--color-text-primary`) to app iframes. Your HTML can reference these variables to automatically match the host's theme without any server-side configuration. See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) for the full list of CSS variables. @@ -176,10 +176,10 @@ The default Content Security Policy restricts external script and style loads. F The class provides constants for protocol values: -| Constant | Value | Usage | -| - | - | - | -| `McpApps.HtmlMimeType` | `text/html;profile=mcp-app` | MIME type for UI resources | -| `McpApps.ExtensionId` | `io.modelcontextprotocol/ui` | Key in `extensions` capability dictionary | +| Constant | Value | Usage | +|------------------------|------------------------------|-------------------------------------------| +| `McpApps.HtmlMimeType` | `text/html;profile=mcp-app` | MIME type for UI resources | +| `McpApps.ExtensionId` | `io.modelcontextprotocol/ui` | Key in `extensions` capability dictionary | ## Serialization diff --git a/docs/concepts/cancellation/cancellation.md b/docs/concepts/cancellation/cancellation.md index 618b2f0..9dbab09 100644 --- a/docs/concepts/cancellation/cancellation.md +++ b/docs/concepts/cancellation/cancellation.md @@ -47,7 +47,7 @@ The cancellation notification includes: - **RequestId**: The ID of the request to cancel, allowing the receiver to correlate the cancellation with the correct in-flight request. - **Reason**: An optional human-readable reason for the cancellation. -Cancellation notifications can be observed by registering a handler. For broader interception of notifications and other messages, delegates can be added to the collection in . +Cancellation notifications can be observed by registering a handler. For broader interception of notifications and other messages, you can add delegates to the collection in . ```csharp mcpClient.RegisterNotificationHandler( diff --git a/docs/concepts/capabilities/capabilities.md b/docs/concepts/capabilities/capabilities.md index b466736..d4c7642 100644 --- a/docs/concepts/capabilities/capabilities.md +++ b/docs/concepts/capabilities/capabilities.md @@ -42,7 +42,7 @@ var options = new McpClientOptions await using var client = await McpClient.CreateAsync(transport, options); ``` -Handlers for each capability (roots, sampling, elicitation) are covered in their respective documentation pages. +Handlers for each capability (roots, sampling, and elicitation) are covered in their respective documentation pages. ### Server capabilities @@ -63,7 +63,7 @@ Server capabilities are automatically inferred from the configured features. For Before using an optional feature, check whether the other side declared the corresponding capability. -#### Checking server capabilities from the client +#### Check server capabilities from the client ```csharp await using var client = await McpClient.CreateAsync(transport); @@ -83,11 +83,11 @@ if (client.ServerCapabilities.Resources is { Subscribe: true }) // Check if the server supports prompts with list-changed notifications if (client.ServerCapabilities.Prompts is { ListChanged: true }) { - mcpClient.RegisterNotificationHandler( + client.RegisterNotificationHandler( NotificationMethods.PromptListChangedNotification, async (notification, ct) => { - var prompts = await mcpClient.ListPromptsAsync(cancellationToken: ct); + var prompts = await client.ListPromptsAsync(cancellationToken: ct); }); } diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 7570d27..0ac808a 100644 --- a/docs/concepts/completions/completions.md +++ b/docs/concepts/completions/completions.md @@ -15,8 +15,8 @@ MCP [completions] allow servers to provide argument auto-completion suggestions Completions work with two types of references: -- **Prompt argument completions**: Suggest values for prompt parameters (e.g., language names, style options) -- **Resource template argument completions**: Suggest values for URI template parameters (e.g., file paths, resource IDs) +- **Prompt argument completions**: Suggest values for prompt parameters (for example, language names, style options) +- **Resource template argument completions**: Suggest values for URI template parameters (for example, file paths, resource IDs) The server returns a object containing a list of suggested values, an optional total count, and a flag indicating if more values are available. @@ -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) => @@ -83,7 +83,7 @@ builder.Services.AddMcpServer() ### Automatic completions with AllowedValuesAttribute -For parameters with a known set of valid values, you can use `System.ComponentModel.DataAnnotations.AllowedValuesAttribute` on `string` parameters of prompts or resource templates. The server will automatically surface those values as completions without needing a custom completion handler. +For parameters with a known set of valid values, you can use `System.ComponentModel.DataAnnotations.AllowedValuesAttribute` on `string` parameters of prompts or resource templates. The server automatically surfaces those values as completions without needing a custom completion handler. #### Prompt parameters @@ -115,11 +115,11 @@ public class MyResources } ``` -With these attributes in place, when a client sends a `completion/complete` request for the `language` or `section` argument, the server will automatically filter and return matching values based on what the user has typed so far. This approach can be combined with a custom completion handler registered via `WithCompleteHandler`; the handler's results are returned first, followed by any matching `AllowedValues`. +With these attributes in place, when a client sends a `completion/complete` request for the `language` or `section` argument, the server automatically filters and returns matching values based on what the user has typed so far. This approach can be combined with a custom completion handler registered via `WithCompleteHandler`; the handler's results are returned first, followed by any matching `AllowedValues`. ### Requesting completions on the client -Clients request completions using . Provide a reference to the prompt or resource template, the argument name, and the current partial value: +Clients request completions using . Provide a reference to the prompt or resource template, the argument name, and the current partial value. #### Prompt argument completions @@ -142,7 +142,7 @@ if (result.Completion.HasMore == true) } ``` -#### Resource template argument completions +#### Resource-template argument completions ```csharp // Get completions for a resource template argument diff --git a/docs/concepts/deployment/docker.md b/docs/concepts/deployment/docker.md new file mode 100644 index 0000000..8a6fa27 --- /dev/null +++ b/docs/concepts/deployment/docker.md @@ -0,0 +1,112 @@ +--- +title: Docker Deployment +author: john-mckillip +description: How to run ASP.NET Core MCP servers in Docker containers using Streamable HTTP transport. +uid: docker-deployment +--- + +## Docker deployment for MCP servers + +Docker is a practical way to package and run MCP servers consistently across development, CI, and production environments. For HTTP-based MCP servers, use ASP.NET Core hosting with Streamable HTTP. + +This guide assumes you already have an ASP.NET Core MCP server configured with `ModelContextProtocol.AspNetCore`, `WithHttpTransport()`, and `MapMcp()`. + + +> [!TIP] +> For local, process-based integrations where the client launches the server directly, stdio is often simpler. For remote and containerized deployments, Streamable HTTP is the recommended transport. + +### Baseline server + +A minimal HTTP-based MCP server looks like this: + +```csharp +using ModelContextProtocol.Server; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithToolsFromAssembly(); + +var app = builder.Build(); + +app.MapMcp("/mcp"); +app.Run(); +``` + +### Dockerfile + +Use a multi-stage Docker build so SDK tooling stays in the build stage and only runtime dependencies are shipped in the final image. + +```dockerfile +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish . + +# MCP HTTP endpoint listens on 8080 inside container +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "MyMcpServer.dll"] +``` + +Replace `MyMcpServer.dll` with your server assembly output name. + +### Build and run + +Build the image: + +```bash +docker build -t my-mcp-server:latest . +``` + +Run the container and map host port `3001` to container port `8080`: + +```bash +docker run --rm -p 3001:8080 my-mcp-server:latest +``` + +With the baseline route above (`app.MapMcp("/mcp")`), clients connect to the Streamable HTTP endpoint at `http://localhost:3001/mcp`. + +Streamable HTTP is the recommended transport for containerized MCP servers. The legacy SSE endpoints (`/mcp/sse` and `/mcp/message`) are not mapped by default, and the option to enable them is obsolete. See [Transports](../transports/transports.md) for details. + +### Configuration and secrets + +Pass configuration through environment variables rather than baking secrets into the image: + +```bash +docker run --rm -p 3001:8080 \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -e MYAPP__APIKEY=example \ + my-mcp-server:latest +``` + +ASP.NET Core binds `MYAPP__APIKEY` to `MYAPP:APIKEY` in configuration. + + +> [!IMPORTANT] +> Do not commit real tokens or credentials into Dockerfiles, compose files, or source code. Use runtime environment variables or an external secret store. + +### Health and readiness + +For container orchestrators, add an HTTP health endpoint and use it for readiness/liveness checks. Keep MCP traffic on your mapped MCP route and health probes on a separate route. + +### Reverse proxies and forwarded headers + +If your container is behind a reverse proxy (for example, ingress or load balancers), ensure forwarded headers are handled correctly so auth and origin metadata flow to the MCP server as expected. + +See also: + +- [Transports](../transports/transports.md) +- [Getting Started](../getting-started.md) +- [HTTP Context](../httpcontext/httpcontext.md) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 60530d8..b899df2 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -7,22 +7,22 @@ uid: elicitation ## Elicitation -The **elicitation** feature allows servers to request additional information from users during interactions. This enables more dynamic and interactive AI experiences, making it easier to gather necessary context before executing tasks. +The **elicitation** feature allows servers to request additional information from users during interactions. This feature enables more dynamic and interactive AI experiences, making it easier to gather necessary context before executing tasks. The protocol supports two modes of elicitation: - **Form (In-Band)**: The server requests structured data (strings, numbers, Booleans, enums) which the client collects via a form interface and returns to the server. - **URL Mode**: The server provides a URL for the user to visit (for example, for OAuth, payments, or sensitive data entry). The interaction happens outside the MCP client. -### Server Support for Elicitation +### Server support for elicitation Servers request information from users with the extension method on . The C# SDK registers an instance of with the dependency injection container, so tools can simply add a parameter of type to their method signature to access it. -#### Form Mode Elicitation (In-Band) +#### Form mode elicitation (in-band) -For form-based elicitation, the MCP Server must specify the schema of each input value it is requesting from the user. +For form-based elicitation, the MCP Server must specify the schema of each input value it's requesting from the user. Primitive types (string, number, Boolean) and enum types are supported for elicitation requests. The schema might include a description to help the user understand what's being requested. @@ -115,7 +115,7 @@ The following example demonstrates how a server could request a Boolean response [!code-csharp[](samples/server/Tools/InteractiveTools.cs?name=snippet_GuessTheNumber)] -#### URL Mode Elicitation (Out-of-Band) +#### URL mode elicitation (out-of-band) For URL mode elicitation, the server provides a URL that the user must visit to complete an action. This is useful for scenarios like OAuth flows, payment processing, or collecting sensitive credentials that should not be exposed to the MCP client. @@ -134,7 +134,7 @@ var result = await server.ElicitAsync( cancellationToken); ``` -### Client Support for Elicitation +### Client support for elicitation Clients declare their support for elicitation in their capabilities as part of the `initialize` request. Clients can support `Form` (in-band), `Url` (out-of-band), or both. @@ -158,7 +158,7 @@ var options = new McpClientOptions }; ``` -The `ElicitationHandler` is an asynchronous method that will be called when the server requests additional information. The handler should check the `Mode` of the request: +The `ElicitationHandler` is an asynchronous method that's called when the server requests additional information. The handler should check the `Mode` of the request: - **Form Mode**: Present the form defined by `RequestedSchema` to the user. Return the user's input in the `Content` of the result. - **URL Mode**: Present the `Message` and `Url` to the user. Ask for consent to open the URL. If the user consents, open the URL and return `Action="accept"`. If the user declines, return `Action="decline"`. @@ -170,12 +170,12 @@ Here's an example implementation of how a console application might handle elici [!code-csharp[](samples/client/Program.cs?name=snippet_ElicitationHandler)] -### Multi Round-Trip Requests (MRTR) +### Multi round-trip requests (MRTR) [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 — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — 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: @@ -196,7 +196,7 @@ public static string ElicitWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful session using protocol revision 2025-11-25)."; } // First call — request user input @@ -223,13 +223,13 @@ public static string ElicitWithMrtr( ``` > [!TIP] -> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including multiple round trips, concurrent input requests, and the compatibility matrix. +> For the full protocol details, including multiple round trips, concurrent input requests, and the compatibility matrix, see [Multi Round-Trip Requests (MRTR)](xref:mrtr). -### URL Elicitation Required Error +### URL elicitation required error -When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:stateless) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. +When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example, in [stateless](xref:stateless) mode where server-to-client requests are disabled), the server might throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. -#### Throwing UrlElicitationRequiredException on the Server +#### Throwing `UrlElicitationRequiredException` on the server A server tool can throw `UrlElicitationRequiredException` when it detects that authorization or other out-of-band interaction is required: @@ -267,14 +267,14 @@ public async Task AccessThirdPartyResource(McpServer server, Cancellatio The exception can include multiple elicitations if the operation requires authorization from multiple services. -#### Catching UrlElicitationRequiredException on the Client +#### Catching `UrlElicitationRequiredException` on the client When the client calls a tool and receives a `UrlElicitationRequiredException`, it should: -1. Present each URL elicitation to the user (showing the URL and message) -2. Get user consent before opening each URL -3. Optionally wait for completion notifications from the server -4. Retry the original request after the user completes the out-of-band interactions +1. Present each URL elicitation to the user (showing the URL and message). +2. Get user consent before opening each URL. +3. Optionally wait for completion notifications from the server. +4. Retry the original request after the user completes the out-of-band interactions. ```csharp try @@ -317,7 +317,7 @@ catch (UrlElicitationRequiredException ex) } ``` -#### Listening for Elicitation Completion Notifications +#### Listening for elicitation completion notifications Servers can optionally send a `notifications/elicitation/complete` notification when the out-of-band interaction is complete. Clients can register a handler to receive these notifications: @@ -339,6 +339,6 @@ await using var completionHandler = client.RegisterNotificationHandler( This pattern is particularly useful for: -- **Third-party OAuth flows**: When the MCP server needs to obtain tokens from external services on behalf of the user -- **Payment processing**: When user confirmation is required through a secure payment interface -- **Sensitive credential collection**: When API keys or other secrets must be entered directly on a trusted server page rather than through the MCP client +- **Third-party OAuth flows**: When the MCP server needs to obtain tokens from external services on behalf of the user. +- **Payment processing**: When user confirmation is required through a secure payment interface. +- **Sensitive credential collection**: When API keys or other secrets must be entered directly on a trusted server page rather than through the MCP client. diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs index b10dd7e..e7b9e37 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 @@ builder.Services.AddMcpServer() .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 896cea6..80d6d46 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -10,27 +10,35 @@ uid: filters The MCP Server provides two levels of filters for intercepting and modifying request processing: 1. **Message Filters** - Low-level filters (`AddIncomingFilter`, `AddOutgoingFilter`) configured via `WithMessageFilters(...)` that intercept all JSON-RPC messages before routing. -2. **Request-Specific Filters** - Handler-level filters (e.g., `AddListToolsFilter`, `AddCallToolFilter`) configured via `WithRequestFilters(...)` that target specific MCP operations. +2. **Request-Specific Filters** - Handler-level filters (for example, `AddListToolsFilter`, `AddCallToolFilter`) configured via `WithRequestFilters(...)` that target specific MCP operations. The filters are stored in `McpServerOptions.Filters`. -## Available Request-Specific Filter Methods +## Available request-specific filter methods The following request filter methods are available on `IMcpRequestFilterBuilder` inside `WithRequestFilters(...)`: -- `AddListResourceTemplatesFilter` - Filter for list resource templates handlers -- `AddListToolsFilter` - Filter for list tools handlers -- `AddCallToolFilter` - Filter for call tool handlers -- `AddListPromptsFilter` - Filter for list prompts handlers -- `AddGetPromptFilter` - Filter for get prompt handlers -- `AddListResourcesFilter` - Filter for list resources handlers -- `AddReadResourceFilter` - Filter for read resource handlers -- `AddCompleteFilter` - Filter for completion handlers -- `AddSubscribeToResourcesFilter` - Filter for resource subscription handlers -- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers -- `AddSetLoggingLevelFilter` - Filter for logging level handlers +| Method | Filters for... | +|-------------------------------------|----------------------------------| +| `AddListResourceTemplatesFilter` | List resource templates handlers | +| `AddListToolsFilter` | List tools handlers | +| `AddCallToolFilter` | Call tool handlers | +| `AddListPromptsFilter` | List prompts handlers | +| `AddGetPromptFilter` | Get prompt handlers | +| `AddListResourcesFilter` | List resources handlers | +| `AddReadResourceFilter` | Read resource handlers | +| `AddCompleteFilter` | Completion handlers | +| `AddSubscribeToResourcesFilter` | Resource subscription handlers | +| `AddUnsubscribeFromResourcesFilter` | Resource unsubscription handlers | +| `AddSetLoggingLevelFilter` | Logging level handlers | -## Message Filters +The Tasks extension and ASP.NET Core tool authorization use alternate-result `tools/call` filters. Alternate-result filters run in registration order outside the ordinary `AddCallToolFilter` pipeline. The Tasks filter creates a task at its position in that order and runs its remaining pipeline in the background. Consequently, alternate-result filters registered before Tasks run before task creation, while those registered after Tasks run in the background before the ordinary filters and tool. ASP.NET Core authorization is registered before Tasks, so an unauthorized call is rejected without creating a task. + +Ordinary call-tool filters run exactly once after all alternate-result filters and before the tool. For task-backed calls, ordinary filters execute in the background after task creation. An explicit `CallToolWithAlternateHandler` remains a full replacement and cannot be combined with ordinary call-tool filters. + +Configure `WithTasks` before adding ordinary call-tool filters. Tasks validates this ordering when its filter is installed because it must execute outside the ordinary call-tool pipeline. + +## Message filters In addition to the request-specific filters above, there are low-level message filters that intercept all JSON-RPC messages before they are routed to specific handlers. Configure these on `IMcpMessageFilterBuilder` inside `WithMessageFilters(...)`: @@ -38,7 +46,7 @@ Configure these on `IMcpMessageFilterBuilder` inside `WithMessageFilters(...)`: - `AddIncomingFilter` - Filter for all incoming JSON-RPC messages (requests and notifications) - `AddOutgoingFilter` - Filter for all outgoing JSON-RPC messages (responses and notifications) -### When to Use Message Filters +### When to use message filters Message filters operate at a lower level than request-specific filters and are useful when you need to: @@ -48,7 +56,7 @@ Message filters operate at a lower level than request-specific filters and are u - Modify or skip messages before they reach handlers - Send additional messages in response to specific events -### Incoming Message Filter +### Incoming message filter `AddIncomingFilter` intercepts all incoming JSON-RPC messages before they are dispatched to request-specific handlers: @@ -82,7 +90,7 @@ Inside an incoming message filter, you have access to: - `context.Services` - The request's service provider - `context.Items` - A dictionary for passing data between filters -#### Skipping Default Handlers +#### Skipping default handlers You can skip the default handler by not calling `next`. This is useful for implementing custom protocol methods: @@ -108,7 +116,7 @@ You can skip the default handler by not calling `next`. This is useful for imple }) ``` -### Outgoing Message Filter +### Outgoing message filter `AddOutgoingFilter` intercepts all outgoing JSON-RPC messages before they are sent to the client: @@ -137,7 +145,7 @@ services.AddMcpServer() .WithTools(); ``` -#### Skipping Outgoing Messages +#### Skipping outgoing messages You can suppress outgoing messages by not calling `next`: @@ -158,7 +166,7 @@ You can suppress outgoing messages by not calling `next`: }) ``` -#### Sending Additional Messages +#### Sending additional messages Outgoing message filters can send additional messages by calling `next` with a new `MessageContext`: @@ -189,7 +197,7 @@ Outgoing message filters can send additional messages by calling `next` with a n }) ``` -### Message Filter Execution Order +### Message filter execution order Message filters execute in registration order, with the first registered filter being the outermost: @@ -235,7 +243,7 @@ OutgoingFilter2 (after next) OutgoingFilter1 (after next) ``` -### Passing Data Between Filters +### Passing data between filters The `Items` dictionary allows you to pass data between filters processing the same message: @@ -291,7 +299,7 @@ services.AddMcpServer() }); ``` -## Filter Execution Order +## Filter execution order ```csharp services.AddMcpServer() @@ -306,7 +314,9 @@ services.AddMcpServer() Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filter2 -> filter1` -## Common Use Cases +## Common use cases + +Filters are commonly used for [logging](#logging), [error handling](#error-handling), [performance monitoring](#performance-monitoring), and [caching](#caching). ### Logging @@ -325,7 +335,7 @@ Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filt }); ``` -### Error Handling +### Error handling ```csharp .WithRequestFilters(requestFilters => @@ -351,7 +361,7 @@ Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filt }); ``` -### Performance Monitoring +### Performance monitoring ```csharp .WithRequestFilters(requestFilters => @@ -391,24 +401,24 @@ Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filt }); ``` -## Built-in Authorization Request Filters +## Built-in authorization request filters When using the ASP.NET Core integration (`ModelContextProtocol.AspNetCore`), you can add authorization filters to support `[Authorize]` and `[AllowAnonymous]` attributes on MCP server tools, prompts, and resources by calling `AddAuthorizationFilters()` on your MCP server builder. -### Enabling Authorization Request Filters +### Enabling authorization request filters To enable authorization support, call `AddAuthorizationFilters()` when configuring your MCP server: ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .AddAuthorizationFilters() // Enable authorization filter support .WithTools(); ``` -**Important**: You should always call `AddAuthorizationFilters()` when using ASP.NET Core integration if you want to use authorization attributes like `[Authorize]` on your MCP server tools, prompts, or resources. +**Important**: If you want to use authorization attributes like `[Authorize]` on your MCP server tools, prompts, or resources, you should always call `AddAuthorizationFilters()` when using ASP.NET Core integration. -### Authorization Attributes Support +### Authorization attributes support The MCP server automatically respects the following authorization attributes: @@ -417,7 +427,7 @@ The MCP server automatically respects the following authorization attributes: - **`[Authorize(Policy = "PolicyName")]`** - Requires specific authorization policies - **`[AllowAnonymous]`** - Explicitly allows anonymous access (overrides `[Authorize]`) -### Tool Authorization +### Tool authorization Tools can be decorated with authorization attributes to control access: @@ -447,7 +457,7 @@ public class WeatherTools } ``` -### Class-Level Authorization +### Class-level authorization You can apply authorization at the class level, which affects all tools in the class: @@ -471,19 +481,19 @@ public class RestrictedTools } ``` -### How Authorization Filters Work +### How authorization filters work The authorization filters work differently for list operations versus individual operations: -#### List Operations (ListTools, ListPrompts, ListResources) +#### List operations (`ListTools`, `ListPrompts`, `ListResources`) For list operations, the filters automatically remove unauthorized items from the results. Users only see tools, prompts, or resources they have permission to access. -#### Individual Operations (CallTool, GetPrompt, ReadResource) +#### Individual operations (`CallTool`, `GetPrompt`, `ReadResource`) For individual operations, the filters throw an `McpException` with "Access forbidden" message. These get turned into JSON-RPC errors if uncaught by middleware. -### Filter Execution Order and Authorization +### Filter execution order and authorization Authorization filters are applied automatically when you call `AddAuthorizationFilters()`. These filters run at a specific point in the filter pipeline, which means: @@ -501,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) => @@ -531,11 +541,13 @@ services.AddMcpServer() .WithTools(); ``` -### Setup Requirements +### Setup requirements 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") @@ -546,7 +558,7 @@ builder.Services.AddAuthorization(); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .AddAuthorizationFilters() // Required for authorization support .WithTools() @@ -565,7 +577,7 @@ app.MapMcp(); app.Run(); ``` -### Custom Authorization Filters +### Custom authorization filters You can also create custom authorization filters using the filter methods: diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index c6096aa..f3e2f58 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -5,7 +5,7 @@ description: Install the MCP C# SDK and build your first MCP client and server. uid: getting-started --- -## Getting Started +## Getting started This guide walks you through installing the MCP C# SDK and building a minimal MCP client and server. @@ -14,7 +14,7 @@ This guide walks you through installing the MCP C# SDK and building a minimal MC The SDK ships as three NuGet packages. Pick the one that matches your scenario: | Package | Use when... | -| - | - | +|---------|-------------| | **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core/absoluteLatest)** | You only need the client or low-level server APIs and want the **minimum set of dependencies**. | | **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol/absoluteLatest)** | You're building a client or a **stdio-based** server and want hosting, dependency injection, and attribute-based tool/prompt/resource discovery. References `ModelContextProtocol.Core`. **This is the right starting point for most projects.** | | **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore/absoluteLatest)** | You're building an **HTTP-based** MCP server hosted in ASP.NET Core. References `ModelContextProtocol`, so you get everything above plus the HTTP transport. | @@ -29,7 +29,7 @@ The SDK ships as three NuGet packages. Pick the one that matches your scenario: > [!TIP] > You can also use the [MCP Server project template](https://learn.microsoft.com/dotnet/ai/quickstarts/build-mcp-server) to quickly scaffold a new MCP server project. -Create a new console app, add the required packages, and replace `Program.cs` with the code below to get a working MCP server that exposes a single tool over stdio: +Create a new console app and add the required packages: ``` dotnet new console @@ -37,6 +37,8 @@ dotnet add package ModelContextProtocol dotnet add package Microsoft.Extensions.Hosting ``` +Replace `Program.cs` with the following code to get a working MCP server that exposes a single tool over stdio: + ```csharp using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -66,14 +68,17 @@ public static class EchoTool The call to `WithToolsFromAssembly` discovers every class marked with `[McpServerToolType]` in the assembly and registers every `[McpServerTool]` method as a tool. Prompts and resources work the same way with `[McpServerPromptType]` / `[McpServerPrompt]` and `[McpServerResourceType]` / `[McpServerResource]`. -For HTTP-based servers using ASP.NET Core: +For HTTP-based servers using ASP.NET Core, add the following packages: ``` dotnet new web dotnet add package ModelContextProtocol.AspNetCore ``` +And add the following code: + ```csharp +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.ComponentModel; @@ -81,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 Sessions 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(); @@ -109,21 +114,23 @@ For production servers, configure the exact public host names for the deployment #### Browser cross-origin access -**Only** enable CORS if you intentionally want browser-based cross-origin access to this server. +**Only** enable cross-origin requests (CORS) if you intentionally want browser-based cross-origin access to this server. -CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). +CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). For the full HTTP security examples, including `AllowedHosts` and restrictive CORS on `MapMcp`, see [Streamable HTTP transport](transports/transports.md#browser-cross-origin-access). ### Building an MCP client -Create a new console app, add the package, and replace `Program.cs` with the code below. This client connects to the MCP "everything" reference server, lists its tools, and calls one: +Create a new console app and add the following packages: ``` dotnet new console dotnet add package ModelContextProtocol ``` +Replace `Program.cs` with the following code. This client connects to the MCP "everything" reference server, lists its tools, and calls one: + ```csharp using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -157,7 +164,7 @@ Clients can connect to any MCP server, not just ones created with this library. #### Using tools with an LLM -`McpClientTool` inherits from `AIFunction`, so the tools returned by `ListToolsAsync` can be handed directly to any `IChatClient`: +`McpClientTool` inherits from [`AIFunction`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.aifunction), so the tools returned by `ListToolsAsync` can be given directly to any `IChatClient`: ```csharp // Get available tools. @@ -172,4 +179,4 @@ var response = await chatClient.GetResponseAsync( ### Next steps -Explore the rest of the conceptual documentation to learn about [tools](tools/tools.md), [prompts](prompts/prompts.md), [resources](resources/resources.md), [transports](transports/transports.md), and more. You can also browse the [samples](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples) directory for complete end-to-end examples. +Explore the rest of the conceptual documentation to learn about [tools](tools/tools.md), [prompts](prompts/prompts.md), [resources](resources/resources.md), [transports](transports/transports.md), and [Docker deployment](deployment/docker.md) for HTTP-based servers. You can also browse the [samples](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples) directory for complete end-to-end examples. diff --git a/docs/concepts/httpcontext/httpcontext.md b/docs/concepts/httpcontext/httpcontext.md index 34ee4db..25a4228 100644 --- a/docs/concepts/httpcontext/httpcontext.md +++ b/docs/concepts/httpcontext/httpcontext.md @@ -34,11 +34,11 @@ and the `GetHttpHeaders` method accessing the current [HttpContext] to retrieve When using the legacy SSE transport, be aware that the `HttpContext` returned by `IHttpContextAccessor` references the long-lived SSE connection request — not the individual `POST` request that triggered the tool call. This means: -- The `HttpContext.User` may contain stale claims if the client's token was refreshed after the SSE connection was established. +- The `HttpContext.User` might contain stale claims if the client's token was refreshed after the SSE connection was established. - Request headers, query strings, and other per-request metadata will reflect the initial SSE connection, not the current operation. The Streamable HTTP transport does not have this issue because each tool call is its own HTTP request, so `IHttpContextAccessor.HttpContext` always reflects the current request. In [stateless](xref:stateless) mode, this is guaranteed since every request creates a fresh server context. > [!NOTE] -> The server validates that the user identity has not changed between the session-initiating request and subsequent requests (using the `sub`, `NameIdentifier`, or `UPN` claim). If the user identity changes, the request is rejected with `403 Forbidden`. However, other claims (roles, permissions, custom claims) are not re-validated and may become stale over the lifetime of a session. +> The server validates that the user identity has not changed between the session-initiating request and subsequent requests (using the `sub`, `NameIdentifier`, or `UPN` claim). If the user identity changes, the request is rejected with `403 Forbidden`. However, other claims (roles, permissions, custom claims) are not re-validated and might become stale over the lifetime of a session. diff --git a/docs/concepts/httpcontext/samples/Program.cs b/docs/concepts/httpcontext/samples/Program.cs index a01602d..c9d9dc6 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 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/identity/identity.md b/docs/concepts/identity/identity.md index 3e9756b..313523b 100644 --- a/docs/concepts/identity/identity.md +++ b/docs/concepts/identity/identity.md @@ -9,7 +9,7 @@ uid: identity When building production MCP servers, you often need to know _who_ is calling a tool so you can enforce permissions, filter data, or audit access. The MCP C# SDK provides built-in support for propagating the caller's identity from the transport layer into your tool, prompt, and resource handlers — no custom headers or workarounds required. -## How Identity Flows Through the SDK +## How identity flows through the SDK When a client sends a request over an authenticated HTTP transport (Streamable HTTP or SSE), the ASP.NET Core authentication middleware populates `HttpContext.User` with a `ClaimsPrincipal`. The SDK's transport layer automatically copies this `ClaimsPrincipal` into `JsonRpcMessage.Context.User`, which then flows through message filters, request filters, and finally into the handler or tool method. @@ -24,7 +24,7 @@ HTTP Request (with auth token) This means you can access the authenticated user's identity at every stage of request processing. -## Direct `ClaimsPrincipal` Parameter Injection (Recommended) +## Direct `ClaimsPrincipal` parameter injection (recommended) The simplest and recommended approach is to declare a `ClaimsPrincipal` parameter on your tool method. The SDK automatically injects the authenticated user without including it in the tool's input schema: @@ -56,7 +56,7 @@ public class UserAwarePrompts } ``` -### Why This Works +### Why this works The SDK registers `ClaimsPrincipal` as one of the built-in services available during request processing. When a tool, prompt, or resource method declares a `ClaimsPrincipal` parameter, the SDK: @@ -64,9 +64,9 @@ The SDK registers `ClaimsPrincipal` as one of the built-in services available du 2. Automatically resolves it from the current request's `User` property at invocation time. 3. Passes `null` if no authenticated user is present (when the parameter is nullable). -This behavior is transport-agnostic. For HTTP transports, the `ClaimsPrincipal` comes from ASP.NET Core authentication. For other transports (like stdio), it will be `null` unless you set it explicitly via a message filter. +This behavior is transport-agnostic. For HTTP transports, the `ClaimsPrincipal` comes from ASP.NET Core authentication. For other transports (like stdio), it's `null` unless you set it explicitly via a message filter. -## Accessing Identity in Filters +## Accessing identity in filters Both message filters and request-specific filters expose the user via `context.User`: @@ -87,7 +87,7 @@ services.AddMcpServer() .WithTools(); ``` -## Role-Based Access with `[Authorize]` Attributes +## Role-based access with `[Authorize]` attributes For declarative authorization, you can use standard ASP.NET Core `[Authorize]` attributes on your tools, prompts, and resources. This requires calling `AddAuthorizationFilters()` during server configuration: @@ -132,9 +132,9 @@ When authorization fails, the SDK automatically: - **For list operations**: Removes unauthorized items from the results so users only see what they can access. - **For individual operations**: Returns a JSON-RPC error indicating access is forbidden. -See [Filters](xref:filters) for more details on authorization filters and their execution order. +For more details on authorization filters and their execution order, see [Filters](xref:filters). -## Using `IHttpContextAccessor` (HTTP-Only Alternative) +## Using `IHttpContextAccessor` (HTTP-only alternative) If you need access to the full `HttpContext` (not just the user), you can inject `IHttpContextAccessor` into your tool class. This gives you access to HTTP headers, query strings, and other request metadata: @@ -156,9 +156,9 @@ public class HttpContextTools(IHttpContextAccessor contextAccessor) > [!IMPORTANT] > `IHttpContextAccessor` only works with HTTP transports. For transport-agnostic identity access, use `ClaimsPrincipal` parameter injection instead. -See [HTTP Context](xref:httpcontext) for more details, including important caveats about stale `HttpContext` with the legacy SSE transport. +For more details, including important caveats about stale `HttpContext` with the legacy SSE transport, see [HTTP Context](xref:httpcontext). -## Transport Considerations +## Transport considerations | Transport | Identity Source | Notes | | --- | --- | --- | @@ -166,7 +166,7 @@ See [HTTP Context](xref:httpcontext) for more details, including important cavea | SSE | Same as Streamable HTTP, but the `HttpContext` is tied to the long-lived SSE connection. | The `ClaimsPrincipal` parameter injection still works correctly, but `IHttpContextAccessor` may return stale claims if the client's token was refreshed after the SSE connection was established. | | Stdio | No built-in authentication. `ClaimsPrincipal` is `null` unless set via a message filter. | For process-level identity, you can set the user in a message filter based on environment variables or other process-level context. | -### Setting Identity for Stdio Transport +### Setting identity for stdio transport For stdio-based servers where the caller's identity comes from the process environment rather than HTTP authentication, you can set the user in a message filter: @@ -188,7 +188,7 @@ services.AddMcpServer() .WithTools(); ``` -## Full Example: Protected HTTP Server +## Full example: protected HTTP server For a complete example of an MCP server with JWT authentication, OAuth resource metadata, and protected tools, see the [ProtectedMcpServer sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/ProtectedMcpServer). diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 3e763be..b318505 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -4,23 +4,29 @@ Welcome to the conceptual documentation for the Model Context Protocol SDK. Here ## Contents -### [Getting Started](getting-started.md) +### Getting started -Install the SDK and build your first MCP client and server. +To install the SDK and build your first MCP client and server, see [Getting started](getting-started.md). -### Base Protocol +### Deployment + +| Title | Description | +| - | - | +| [Docker deployment](deployment/docker.md) | Learn how to package and run ASP.NET Core MCP servers in Docker containers using Streamable HTTP transport. | + +### Base protocol | Title | Description | | - | - | | [Capabilities](capabilities/capabilities.md) | Learn how client and server capabilities are negotiated during initialization, including protocol version negotiation. | | [Transports](transports/transports.md) | Learn how to configure stdio, Streamable HTTP, and SSE transports for client-server communication. | +| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | | [Ping](ping/ping.md) | Learn how to verify connection health using the ping mechanism. | | [Progress tracking](progress/progress.md) | Learn how to track progress for long-running operations through notification messages. | | [Cancellation](cancellation/cancellation.md) | Learn how to cancel in-flight MCP requests using cancellation tokens and notifications. | -| [Tasks](tasks/tasks.md) | Learn how to use task-based execution for long-running operations that can be polled for status and results. | | [Multi Round-Trip Requests (MRTR)](mrtr/mrtr.md) | Learn how servers request client input during tool execution using input-required results and retries. | -### Client Features +### Client features | Title | Description | | - | - | @@ -28,7 +34,7 @@ Install the SDK and build your first MCP client and server. | [Roots](roots/roots.md) | Learn how clients provide filesystem roots to servers for context-aware operations. | | [Elicitation](elicitation/elicitation.md) | Learn how to request additional information from users during interactions. | -### Server Features +### Server features | Title | Description | | - | - | @@ -38,7 +44,6 @@ Install the SDK and build your first MCP client and server. | [Completions](completions/completions.md) | Learn how to implement argument auto-completion for prompts and resource templates. | | [Logging](logging/logging.md) | Learn how to implement logging in MCP servers and how clients can consume log messages. | | [Pagination](pagination/pagination.md) | Learn how to use cursor-based pagination when listing tools, prompts, and resources. | -| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | | [HTTP Context](httpcontext/httpcontext.md) | Learn how to access the underlying `HttpContext` for a request. | | [MCP Server Handler Filters](filters.md) | Learn how to add filters to the handler pipeline. Filters let you wrap the original handler with additional functionality. | @@ -47,4 +52,5 @@ Install the SDK and build your first MCP client and server. | Title | Description | | - | - | | [MCP Apps](apps/apps.md) | Learn how to use the MCP Apps extension to deliver interactive UIs from MCP servers. | +| [Tasks](tasks/tasks.md) | Learn how to use task-based execution for long-running operations that can be polled for status and results. | | [Identity and Roles](identity/identity.md) | Learn how to access caller identity and roles in MCP tool, prompt, and resource handlers. | diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index aa78eda..2b381aa 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -11,29 +11,32 @@ MCP servers can expose log messages to clients through the [Logging utility]. [Logging utility]: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging +> [!IMPORTANT] +> Logging is **deprecated** as of MCP specification revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), [MCP9005](xref:list-of-diagnostics#obsolete-apis)) and may be removed in a future version. + This document describes how to implement logging in MCP servers and how clients can consume log messages. -### Logging Levels +### Logging levels MCP uses the logging levels defined in [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424). The MCP C# SDK uses the standard .NET [ILogger] and [ILoggerProvider] abstractions, which support a slightly different set of logging levels. The following table shows the levels and how they map to standard .NET logging levels. -| Level | .NET | Description | Example Use Case | -|-----------|------|-----------------------------------|------------------------------| -| debug | ✓ | Detailed debugging information | Function entry/exit points | -| info | ✓ | General informational messages | Operation progress updates | -| notice | | Normal but significant events | Configuration changes | -| warning | ✓ | Warning conditions | Deprecated feature usage | -| error | ✓ | Error conditions | Operation failures | -| critical | ✓ | Critical conditions | System component failures | -| alert | | Action must be taken immediately | Data corruption detected | -| emergency | | System is unusable | | +| Level | .NET | Description | Example use case | +|-------------|------|----------------------------------|----------------------------| +| `debug` | ✓ | Detailed debugging information | Function entry/exit points | +| `info` | ✓ | General informational messages | Operation progress updates | +| `notice` | | Normal but significant events | Configuration changes | +| `warning` | ✓ | Warning conditions | Deprecated feature usage | +| `error` | ✓ | Error conditions | Operation failures | +| `critical` | ✓ | Critical conditions | System component failures | +| `alert` | | Action must be taken immediately | Data corruption detected | +| `emergency` | | System is unusable | | **Note:** .NET's [ILogger] also supports a `Trace` level (more verbose than Debug) log level. -As there is no equivalent level in the MCP logging levels, Trace level logs messages are silently -dropped when sending messages to the client. +As there is no more verbose level in the MCP logging levels, Trace level log messages are mapped to +the MCP `debug` level when sent to the client. [ILogger]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.ilogger [ILoggerProvider]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.iloggerprovider @@ -51,7 +54,7 @@ messages as there might not be an open connection to the client on which the log The C# SDK provides an extension method on to allow the server to perform any special logic it wants to perform when a client sets the logging level. However, the -SDK already takes care of setting the in the , so most servers will not need to +SDK already takes care of setting the in the , so most servers don't need to implement this. MCP Servers using the MCP C# SDK can obtain an [ILoggerProvider](https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.iloggerprovider) from the method on , @@ -74,7 +77,7 @@ to send all log messages or none—this is not specified in the protocol. So sets a logging level to ensure it receives the desired log messages and only those messages. The `loggingLevel` set by the client is an MCP logging level. -See the [Logging Levels](#logging-levels) section above for the mapping between MCP and .NET logging levels. +For the mapping between MCP and .NET logging levels, see the [Logging Levels](#logging-levels) section. [!code-csharp[](samples/client/Program.cs?name=snippet_LoggingLevel)] diff --git a/docs/concepts/logging/samples/server/Program.cs b/docs/concepts/logging/samples/server/Program.cs index 48e2905..f7bc043 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 @@ builder.Services.AddMcpServer() .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/logging/samples/server/Tools/LoggingTools.cs b/docs/concepts/logging/samples/server/Tools/LoggingTools.cs index 8ddb9e4..edb3916 100644 --- a/docs/concepts/logging/samples/server/Tools/LoggingTools.cs +++ b/docs/concepts/logging/samples/server/Tools/LoggingTools.cs @@ -16,7 +16,7 @@ public class LoggingTools var progressToken = context.Params.ProgressToken; var stepDuration = duration / steps; - // + // ILoggerProvider loggerProvider = context.Server.AsClientLoggerProvider(); ILogger logger = loggerProvider.CreateLogger("LoggingTools"); // diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 5044e6d..0d0fafa 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -7,10 +7,6 @@ uid: mrtr # Multi Round-Trip Requests (MRTR) - -> [!WARNING] -> MRTR is part of the **`2026-07-28`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. - Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. ## Overview @@ -27,13 +23,13 @@ MRTR is useful when: 1. The client calls a tool on the server via `tools/call`. 2. The server tool determines it needs client input and returns an `InputRequiredResult` containing `inputRequests` and/or `requestState`. -3. The client resolves each input request (for example by prompting the user for elicitation, calling an LLM for sampling, or listing its roots). +3. The client resolves each input request (for example, by prompting the user for elicitation, calling an LLM for sampling, or listing its roots). 4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. 5. The server processes the responses and either returns a final result or another `InputRequiredResult` for additional rounds. ## 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 a legacy `initialize` handshake only when the server doesn't support it. Servers accept `2026-07-28` automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy 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. @@ -47,9 +43,9 @@ var clientOptions = new McpClientOptions }; ``` -Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (where Streamable HTTP no longer supports sessions) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working on `2026-07-28` today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or `2026-07-28`. +Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (where Streamable HTTP no longer supports sessions) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — including `2026-07-28` stdio sessions — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on every stateless session. -Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. +Under `2025-11-25` and earlier, `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see the [Compatibility](#compatibility) section. ## Authoring an MRTR tool @@ -57,10 +53,10 @@ A tool participates in MRTR by throwing before throwing `InputRequiredException`. It returns `true` when either: +Tools should check before throwing `InputRequiredException`. The property returns `true` when either: - The negotiated protocol revision is `2026-07-28` (MRTR is native), or -- The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). +- The session is stateful under protocol revision `2025-11-25` (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). ```csharp [McpServerTool, Description("A tool that uses MRTR")] @@ -71,7 +67,7 @@ public static string MyTool( if (!server.IsMrtrSupported) { return "This tool requires a client that negotiates 2026-07-28, " - + "or a stateful current-protocol session."; + + "or a stateful session using protocol revision 2025-11-25."; } // ... MRTR logic @@ -136,9 +132,11 @@ When the client retries a tool call, the retry data is available on the request Use with the `JsonTypeInfo` matching the response type. The expected type follows from the matching in the original `inputRequests` map — there is no on-the-wire discriminator. -- Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` -- Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` -- Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` +| Input | Deserialize call | +|-------------|-----------------------------------------------------------------------| +| Elicitation | `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` | +| Sampling | `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` | +| Roots list | `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` | ### Load shedding with requestState-only responses @@ -249,30 +247,140 @@ public static string WizardTool( } ``` -### Providing custom error messages +### Supporting down-level clients -When MRTR is not supported, you can provide domain-specific guidance: +When is `false`, the tool +can't obtain client input through an input request — but that doesn't have to be a dead +end. Handle down-level and stateless callers along a spectrum, from a helpful message to a +fully functional non-interactive path. + +#### Return actionable guidance + +At minimum, tell the caller how to proceed instead of failing opaquely. Explain what kind +of session would enable the interactive path, and — when the tool exposes one — how to +invoke the non-interactive path described below: ```csharp if (!server.IsMrtrSupported) { - return "This tool requires interactive input. To use it:\n" - + "1. Connect with a client that negotiates MCP protocol revision 2026-07-28, or\n" - + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" - + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; + return "This tool needs interactive input. Connect with a client that negotiates MCP " + + "protocol revision 2026-07-28, or use a stateful session using revision 2025-11-25 " + + "so the server can resolve the input requests for you."; } ``` +#### Offer a non-interactive fallback + +When a tool *can* complete without a prompt, expose an explicit argument that lets a +down-level or stateless caller opt in directly. The tool stays usable everywhere: it +elicits input when MRTR is available, and otherwise returns guidance the caller +(or its model) can act on by resending with the argument set. + +The tool below is just an example that has a sensible non-interactive fallback. It also +shows how to branch on the elicitation **action** rather than assuming acceptance: the +deserialized carries an + of `"accept"`, `"decline"`, or +`"cancel"`, surfaced through the + shorthand. + +```csharp +[McpServerTool, Description("Closes a support ticket, recording why it was closed.")] +public static string CloseSupportTicket( + McpServer server, + RequestContext context, + [Description("The ID of the ticket to close")] long ticketId, + [Description("Why the ticket is being closed")] string? closeReason = null) +{ + // Handles four client scenarios: + // 1. Provided up-front: client sends `closeReason` in the initial call + // 2. MRTR round-trip request: client confirms via `InputResponses["closeReason"]` + // 3. MRTR initial request: server proposes a default reason and asks for confirmation + // (with automatic SDK down-level bridge) + // 4. Session-less down-level: server returns a guidance message requesting the reason up-front + + // The default reason proposed to the caller and used if none is provided. + string defaultCloseReason = "completed"; + + // (1) Provided up-front. Works on any client, including down-level session-less. + // These requests are typically sent after (4) returns a guidance message. + var confirmedReason = closeReason; + + // (2) MRTR round-trip request. Works with native MRTR support or the automatic down-level + // SDK bridge after (3) throws an `InputRequiredException` to request a `closeReason`. + if (string.IsNullOrWhiteSpace(confirmedReason) && + context.Params?.InputResponses?.TryGetValue("closeReason", out var reasonResponse) is true) + { + var reasonResult = reasonResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + + // Branch on the elicitation action: `decline` or `cancel` leaves the ticket open. + if (reasonResult?.IsAccepted is not true) return "Ticket close cancelled"; + + // Accepted: use the reason the caller confirmed, falling back to the proposed default. + confirmedReason = reasonResult.Content?.TryGetValue("closeReason", out var reasonValue) is true + ? reasonValue.GetString() + : null; + confirmedReason = string.IsNullOrWhiteSpace(confirmedReason) ? defaultCloseReason : confirmedReason; + } + + // (1) or (2) A reason is in hand; proceed with closing the ticket. + if (!string.IsNullOrWhiteSpace(confirmedReason)) + return $"Closed ticket {ticketId}: {confirmedReason}"; + + // (3) MRTR initial request: propose "completed" as the default reason and ask the caller + // to confirm (or adjust) it. This uses the 2026-07-28 MRTR input request, but the SDK + // provides an automatic bridge to a legacy elicitation on a down-level, stateful + // session. When the bridge can be provided, `server.IsMrtrSupported` is `true` and the + // exception leads to a legacy elicitation response automatically. + if (server.IsMrtrSupported) + { + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["closeReason"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Close ticket '{ticketId}'? Accept the default reason or provide your own.", + RequestedSchema = new() + { + Properties = + { + ["closeReason"] = new ElicitRequestParams.StringSchema + { + Title = "Close reason", + Description = "The reason for closing the ticket", + Default = defaultCloseReason, + }, + }, + }, + }) + }, + requestState: ticketId.ToString()); // opaque; echoed back to us on the retry + } + + // (4) Down-level and stateless: we can't prompt an elicitation through an MRTR + // round-trip request or an elicitation. Return a natural language response + // with guidance for providing the reason up-front. + return "Closing a ticket requires a reason. Resend with `closeReason`."; +} +``` + +> [!IMPORTANT] +> Form-mode elicitations must set `RequestedSchema`. This tool proposes a default, so its +> schema declares an optional `closeReason` string with a `Default` the caller can accept or +> override. A confirmation prompt that collects no fields still needs a schema — set +> `RequestedSchema = new()` (an empty object schema): it's accepted by native `2026-07-28` MRTR +> and **required** by the down-level stateful elicitation bridge under `2025-11-25`, which +> otherwise throws `ArgumentException: "Form mode elicitation requests require a requested schema."`. + ## 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 | -|---|---|---| -| `2026-07-28` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | -| `2026-07-28` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | -| Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | -| Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | +| Negotiated protocol | Session mode | Behavior | +|----------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `2026-07-28` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | +| `2026-07-28` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | +| `2025-11-25` and earlier | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | +| `2025-11-25` and earlier | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | > [!NOTE] > The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `2026-07-28` (check `IsMrtrSupported`). @@ -281,6 +389,6 @@ The SDK supports `InputRequiredException` across two protocol revisions and two `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). -Under the current protocol revision (`2025-06-18` 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. +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 runs statelessly whenever a client speaks `2026-07-28`. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves `2026-07-28` requests without a session and creates a legacy 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 6440b72..0f60192 100644 --- a/docs/concepts/pagination/pagination.md +++ b/docs/concepts/pagination/pagination.md @@ -7,18 +7,18 @@ uid: pagination ## Pagination -MCP uses [cursor-based pagination] for all list operations that may return large result sets. +MCP uses [cursor-based pagination] for all list operations that can return large result sets. [cursor-based pagination]: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination ### Overview -Instead of offset-based pagination (page 1, page 2, etc.), MCP uses opaque cursor tokens. Each paginated response may include a `NextCursor` value. If present, pass it in the next request to retrieve the next page of results. +Instead of offset-based pagination (page 1, page 2, etc.), MCP uses opaque cursor tokens. Each paginated response might include a `NextCursor` value. If present, pass it in the next request to retrieve the next page of results. Two levels of API are provided for paginated operations: -1. **Convenience methods** (e.g., `ListToolsAsync()` returning `IList`) that automatically handle pagination and return all results. -2. **Raw methods** (e.g., `ListToolsAsync(ListToolsRequestParams)` returning the result type directly) that provide direct control over pagination. +1. **Convenience methods** (for example, `ListToolsAsync()` returning `IList`) that automatically handle pagination and return all results. +2. **Raw methods** (for example, `ListToolsAsync(ListToolsRequestParams)` returning the result type directly) that provide direct control over pagination. ### Automatic pagination @@ -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/progress.md b/docs/concepts/progress/progress.md index fe89629..b46bbdb 100644 --- a/docs/concepts/progress/progress.md +++ b/docs/concepts/progress/progress.md @@ -11,14 +11,14 @@ The Model Context Protocol (MCP) supports [progress tracking] for long-running o [progress tracking]: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress -Typically progress tracking is supported by server tools that perform operations that take a significant amount of time to complete, such as image generation or complex calculations. +Typically, progress tracking is supported by server tools that perform operations that take a significant amount of time to complete, such as image generation or complex calculations. However, progress tracking is defined in the MCP specification as a general feature that can be implemented for any request that's handled by either a server or a client. This project illustrates the common case of a server tool that performs a long-running operation and sends progress updates to the client. > [!NOTE] > Progress notifications are sent inline as part of the response to a request — they are not unsolicited. Progress tracking works in both [stateless and stateful](xref:stateless) modes as well as stdio. -### Server Implementation +### Server implementation When processing a request, the server can use the extension method of to send progress updates, specifying `"notifications/progress"` as the notification method name. @@ -30,7 +30,7 @@ The server must verify that the caller provided a `progressToken` in the request [!code-csharp[](samples/server/Tools/LongRunningTools.cs?name=snippet_SendProgress)] -### Client Implementation +### Client implementation Clients request progress updates by including a `progressToken` in the parameters of a request. Note that servers aren't required to support progress tracking, so clients should not depend on receiving progress updates. @@ -55,7 +55,7 @@ await using var handler = mcpClient.RegisterNotificationHandler(NotificationMeth The second way is to pass a [`Progress`](https://learn.microsoft.com/dotnet/api/system.progress-1) instance to the tool method. `Progress` is a standard .NET type that provides a way to receive progress updates. For the purposes of MCP progress notifications, `T` should be . -The MCP C# SDK will automatically handle progress notifications and report them through the `Progress` instance. +The MCP C# SDK automatically handles progress notifications and reports them through the `Progress` instance. This notification handler will only receive progress updates for the specific request that was made, rather than all progress notifications from the server. diff --git a/docs/concepts/progress/samples/server/Program.cs b/docs/concepts/progress/samples/server/Program.cs index cfff458..ef9c67c 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 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs index e02889a..e3a21db 100644 --- a/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs +++ b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs @@ -22,7 +22,7 @@ public class LongRunningTools { await Task.Delay(stepDuration * 1000); - // + // if (progressToken is not null) { await server.SendNotificationAsync("notifications/progress", new ProgressNotificationParams @@ -36,7 +36,7 @@ public class LongRunningTools }, }); } - // + // } return $"Long running tool completed. Duration: {duration} seconds. Steps: {steps}."; diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 8b11e91..5dcc236 100644 --- a/docs/concepts/prompts/prompts.md +++ b/docs/concepts/prompts/prompts.md @@ -11,7 +11,7 @@ MCP [prompts] allow servers to expose reusable prompt templates to clients. Prom [prompts]: https://modelcontextprotocol.io/specification/2025-11-25/server/prompts -This document covers implementing prompts on the server, consuming them from the client, rich content types, and change notifications. +This document covers implementing prompts on the server, consuming prompts from the client, rich content types, and change notifications. ### Defining prompts on the server @@ -63,14 +63,14 @@ Register prompt types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithPrompts(); ``` ### Rich content in prompts -Prompt messages can contain more than just text. For text and image content, use `ChatMessage` from Microsoft.Extensions.AI. `DataContent` is automatically mapped to the appropriate MCP content block: image MIME types become , audio MIME types become , and all other MIME types become with binary resource contents. For text embedded resources specifically, use directly. +Prompt messages can contain more than just text. For text and image content, use [`ChatMessage`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatmessage) from Microsoft.Extensions.AI. `DataContent` is automatically mapped to the appropriate MCP content block: image MIME types become , audio MIME types become , and all other MIME types become with binary resource contents. For text-embedded resources specifically, use directly. #### Image content diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index 959ad73..6080b2c 100644 --- a/docs/concepts/resources/resources.md +++ b/docs/concepts/resources/resources.md @@ -11,7 +11,7 @@ MCP [resources] allow servers to expose data and content to clients. Resources r [resources]: https://modelcontextprotocol.io/specification/2025-11-25/server/resources -This document covers implementing resources on the server, consuming them from the client, resource templates, subscriptions, and change notifications. +This document covers implementing resources on the server, consuming resources from the client, resource templates, subscriptions, and change notifications. ### Defining resources on the server @@ -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,14 +209,14 @@ 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) => { if (ctx.Params.Uri is { } uri) { - // Track the subscription (e.g., in a concurrent dictionary) + // Track the subscription (for example, in a concurrent dictionary) subscriptions[ctx.Server.SessionId].TryAdd(uri, 0); } return new EmptyResult(); @@ -231,6 +231,16 @@ builder.Services.AddMcpServer() }); ``` +#### Customizing a subscriptions/listen stream + +For the `2026-07-28` protocol version and later, a server can fully own a long-lived +`subscriptions/listen` stream by registering `WithSubscriptionsListenHandler`. This replaces the +SDK's built-in listen handling. The handler must send one +`notifications/subscriptions/acknowledged` notification before other events, tag every streamed +notification with the listen request ID in `_meta.subscriptionId`, and remain active until its +cancellation token is cancelled. It must also configure only the server capabilities that the +handler actually supports; this extension does not advertise capabilities automatically. + #### Sending resource update notifications When a resource's content changes, the server notifies subscribed clients: @@ -244,7 +254,7 @@ await server.SendNotificationAsync( ### Resource list change notifications -When the set of available resources changes (resources added or removed), the server notifies clients: +When the set of available resources changes (resources added or removed), the server notifies clients. #### Sending notifications from the server diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index bb0218f..fa9e1cd 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -11,6 +11,9 @@ MCP [roots] allow clients to inform servers about the relevant locations in the [roots]: https://modelcontextprotocol.io/specification/2025-11-25/client/roots +> [!IMPORTANT] +> Roots are **deprecated** as of MCP specification revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), [MCP9005](xref:list-of-diagnostics#obsolete-apis)) and may be removed in a future version. + ### Overview Roots provide a mechanism for the client to tell the server which directories, projects, or repositories are relevant to the current session. A server might use roots to: @@ -57,7 +60,7 @@ await using var client = await McpClient.CreateAsync(transport, options); ### Requesting roots from the server -Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:stateless) — it is not available in [stateless mode](xref:stateless#stateless-mode-recommended). +Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:stateless) — it is not available in [stateless mode](xref:stateless#stateless-mode-recommended). For a stateless-compatible alternative, throw `InputRequiredException` from your handler through MRTR — see [Multi round-trip requests (MRTR)](#multi-round-trip-requests-mrtr). ```csharp [McpServerTool, Description("Lists the user's project roots")] @@ -104,12 +107,12 @@ server.RegisterNotificationHandler( }); ``` -### Multi Round-Trip Requests (MRTR) +### Multi round-trip requests (MRTR) [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 — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — 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: @@ -128,7 +131,7 @@ public static string ListRootsWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful session using protocol revision 2025-11-25)."; } // First call — request the client's root list @@ -142,4 +145,4 @@ public static string ListRootsWithMrtr( ``` > [!TIP] -> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. +> For the full protocol details, including load shedding, multiple round trips, and the compatibility matrix, see [Multi Round-Trip Requests (MRTR)](xref:mrtr). diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 825acfb..7a1f907 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -11,8 +11,11 @@ MCP [sampling] allows servers to request LLM completions from the client. This e [sampling]: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling +> [!IMPORTANT] +> Sampling is **deprecated** as of MCP specification revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), [MCP9005](xref:list-of-diagnostics#obsolete-apis)) and may be removed in a future version. + > [!NOTE] -> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:stateless). Sampling is not available in [stateless mode](xref:stateless#stateless-mode-recommended) because stateless servers cannot send requests to clients. +> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:stateless). Sampling is not available in [stateless mode](xref:stateless#stateless-mode-recommended) because stateless servers cannot send requests to clients. For a stateless-compatible alternative, throw `InputRequiredException` from your handler through MRTR — see [Multi round-trip requests (MRTR)](#multi-round-trip-requests-mrtr). ### How sampling works @@ -23,7 +26,7 @@ MCP [sampling] allows servers to request LLM completions from the client. This e ### Server: requesting a completion -Inject into a tool method and use the extension method to get an that sends requests through the connected client: +To get an that sends requests through the connected client, inject into a tool method and use the extension method: ```csharp [McpServerTool(Name = "SummarizeContent"), Description("Summarizes the given text")] @@ -121,12 +124,12 @@ McpClientOptions options = new() Sampling requires the client to advertise the `sampling` capability. This is handled automatically — when a is set, the client includes the sampling capability during initialization. The server can check whether the client supports sampling before calling ; if sampling is not supported, the method throws . -### Multi Round-Trip Requests (MRTR) +### Multi round-trip requests (MRTR) [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 — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — 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: @@ -146,7 +149,7 @@ public static string SampleWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful session using protocol revision 2025-11-25)."; } // First call — request LLM completion from the client @@ -171,4 +174,4 @@ public static string SampleWithMrtr( ``` > [!TIP] -> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. +> For the full protocol details, including load shedding, multiple round trips, and the compatibility matrix, see [Multi Round-Trip Requests (MRTR)](xref:mrtr). diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443..c393d3d 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,40 +20,47 @@ 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 legacy 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), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. 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 honored. 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 legacy 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)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that even with `Stateless = false`, a `2026-07-28` request is still served without a session because the protocol has no session header; the stateful path activates only when a client falls back to a legacy 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 are not mapped. Legacy 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 falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request (which carries no session) on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A `2026-07-28` request that carries an `Mcp-Session-Id` is always rejected, since the revision has no session concept. +**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 legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. +**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. **Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it: -- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. -- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. +- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. The era is cached per instance, so the probe cost is paid only on the first connect. -**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to a legacy revision. This is useful for strict-modern production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. +**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. ```csharp var clientOptions = new McpClientOptions @@ -64,7 +71,7 @@ var clientOptions = new McpClientOptions ### Migrating from legacy SSE -If your clients connect to a `/sse` endpoint (e.g., `https://my-server.example.com/sse`), they are using the [legacy SSE transport](#legacy-sse-transport) — regardless of any `Stateless` or session settings on the server. The `/sse` and `/message` endpoints are now **disabled by default** ( is `false` and marked `[Obsolete]` with diagnostic `MCP9004`). Upgrading the server SDK without updating clients will break SSE connections. +If your clients connect to a `/sse` endpoint (for example, `https://my-server.example.com/sse`), they are using the [legacy SSE transport](#legacy-sse-transport) — regardless of any `Stateless` or session settings on the server. The `/sse` and `/message` endpoints are now **disabled by default** ( is `false` and marked `[Obsolete]` with diagnostic `MCP9004`). Upgrading the server SDK without updating clients will break SSE connections. **Client-side migration.** Change the client `Endpoint` from the `/sse` path to the root MCP endpoint — the same URL your server passes to `MapMcp()`. For example: @@ -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,21 +114,21 @@ 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 - still works, but is called **per HTTP request** rather than once per session (see [Per-request configuration in stateless mode](#per-request-configuration-in-stateless-mode)) -- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#legacy-sse-transport) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport cannot connect +- The `GET` and `DELETE` MCP endpoints are not mapped, and [legacy SSE endpoints](#legacy-sse-transport) (`/sse` and `/message`) are always disabled in stateless mode — clients that only support the legacy SSE transport can't connect - **Server-to-client requests are disabled**, including: - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) - [Roots](xref:roots) (`RequestRootsAsync`) - - Ping — the server cannot ping the client to verify connectivity + - Ping — the server can't ping the client to verify connectivity [MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak `2026-07-28` — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). -- **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. -- **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. +- **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (for example, resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client `POST` request — to understand why, see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages). +- **No concurrent client isolation.** Every request is independent — the server can't distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. - **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means. - [Tasks](xref:tasks) **are supported** — the task store is shared across ephemeral server instances. However, task-augmented sampling and elicitation are disabled because they require server-to-client requests. @@ -135,22 +144,22 @@ Use stateless mode when your server: - Needs to scale horizontally behind a load balancer without session affinity - Is deployed to serverless environments (Azure Functions, AWS Lambda, etc.) -Most MCP servers fall into this category. Tools that call APIs, query databases, process data, or return computed results are all natural fits for stateless mode. See [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing between stateless and stateful mode. +Most MCP servers fall into this category. Tools that call APIs, query databases, process data, or return computed results are all natural fits for stateless mode. For guidance on choosing between stateless and stateful mode, see [Forward and backward compatibility](#forward-and-backward-compatibility). ### Stateless alternatives for server-to-client interactions The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a stateless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. -This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` revision, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak it. +This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Keep a session if you must interact with clients that do not support `2026-07-28`. ## 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 +- [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the `GET` stream - Resource subscriptions -- Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) +- Session-scoped state (for example, `RunSessionHandler`, state that persists across multiple requests within a session) ### When to use stateful mode @@ -164,12 +173,48 @@ Use stateful mode when your server needs one or more of: - **Concurrent client isolation**: Multiple agents or editor instances connecting simultaneously, where per-client state must not leak between users — separate working environments, independent scratch state, or parallel simulations where each participant needs its own context. The server — not the model — controls when sessions are created, so the harness decides the boundaries of isolation. - **Local development and debugging**: Testing a typically-stdio server over HTTP where you want to attach a debugger, see log output on stdout, and have editors like Claude Code, GitHub Copilot in VS Code, and Cursor reset the server's state by starting a new session — without requiring a process restart. This closely mirrors the stdio experience where restarting the server process gives the client a clean slate. -The [deployment considerations](#deployment-considerations) below are 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. +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 | -|---|---|---| +|---------------|-----------|----------| | **Deployment** | Any topology — load balancer, serverless, multi-instance | Requires session affinity (sticky sessions) | | **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing | | **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize | @@ -177,12 +222,15 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Server-to-client requests** | Available via [MRTR](xref:mrtr) (`2026-07-28`-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) | | **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | -| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | +| **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [might not send `Mcp-Session-Id` correctly](#deployment-considerations) | | **Local development** | Works, but no way to reset server state from the editor | Editors can reset state by starting a new session without restarting the process | | **Concurrent client isolation** | No distinction between clients — all requests are independent | Each client gets its own session with isolated state | | **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 @@ -191,28 +239,28 @@ The [deployment considerations](#deployment-considerations) below are real conce Understanding how messages flow between client and server over HTTP is key to understanding why sessions exist and when you can avoid them. -**POST response streams (solicited messages).** Every JSON-RPC request from the client arrives as an HTTP POST. The server holds the POST response body open as a [Server-Sent Events (SSE)](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream and writes messages back to it: the JSON-RPC response, any intermediate messages the handler produces (progress notifications, log messages), and — critically — any **server-to-client requests** the handler makes during execution, such as sampling, elicitation, or roots requests. This is a **solicited** interaction: the client's POST request solicited the server's response, and the server writes everything related to that request into the same HTTP response body. The POST response completes when the final JSON-RPC response is sent. +**POST response streams (solicited messages).** Every JSON-RPC request from the client arrives as an HTTP POST. The server holds the `POST` response body open as a [Server-Sent Events (SSE)](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream and writes messages back to it: the JSON-RPC response, any intermediate messages the handler produces (progress notifications, log messages), and — critically — any **server-to-client requests** the handler makes during execution, such as sampling, elicitation, or roots requests. This is a **solicited** interaction: the client's `POST` request solicited the server's response, and the server writes everything related to that request into the same HTTP response body. The `POST` response completes when the final JSON-RPC response is sent. -**The GET stream (unsolicited messages).** The client can optionally open a long-lived HTTP GET request to the same MCP endpoint. This stream is the **only** channel for **unsolicited** messages — notifications or server-to-client requests that the server initiates _outside the context of any active request handler_. For example: +**The `GET` stream (unsolicited messages).** The client can optionally open a long-lived HTTP `GET` request to the same MCP endpoint. This stream is the **only** channel for **unsolicited** messages — notifications or server-to-client requests that the server initiates _outside the context of any active request handler_. For example: - A resource-changed notification fired by a background file watcher - A log message emitted asynchronously after all request handlers have returned - A server-to-client request that isn't triggered by a tool call -These messages are "unsolicited" because no client POST solicited them. There is no POST response body to write them to — because outside of POST requests that solicit the server 1:1 with a JSON-RPC request, there is simply no HTTP response body stream available. The GET stream fills this gap. +These messages are "unsolicited" because no client `POST` solicited them. There is no `POST` response body to write them to — because outside of `POST` requests that solicit the server 1:1 with a JSON-RPC request, there is simply no HTTP response body stream available. The `GET` stream fills this gap. -**No GET stream = messages silently dropped.** Clients are not required to open a GET stream. If the client hasn't opened one, the server has no delivery path for unsolicited messages and silently drops them. This is by design in the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) — unsolicited messages are best-effort. +**No `GET` stream = messages silently dropped.** Clients are not required to open a `GET` stream. If the client hasn't opened one, the server has no delivery path for unsolicited messages and silently drops them. This is by design in the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) — unsolicited messages are best-effort. -**Why stateless mode can't support unsolicited messages.** In stateless mode, the GET endpoint is not mapped at all. Every message the server sends must be part of a POST response — there is no other HTTP response body to write to. This is also why server-to-client requests (sampling, elicitation, roots) are disabled: the server could initiate a request down the POST response stream during a handler, but the client's response to that request would arrive as a _new_ POST — which in stateless mode creates a completely independent server context with no connection to the original handler. The server has no way to correlate the client's reply with the handler that asked the question. Sessions solve this by keeping the handler alive across multiple HTTP round-trips within the same in-memory session. +**Why stateless mode can't support unsolicited messages.** In stateless mode, the `GET` endpoint is not mapped at all. Every message the server sends must be part of a `POST` response — there is no other HTTP response body to write to. This is also why server-to-client requests (sampling, elicitation, roots) are disabled: the server could initiate a request down the `POST` response stream during a handler, but the client's response to that request would arrive as a _new_ `POST` — which in stateless mode creates a completely independent server context with no connection to the original handler. The server has no way to correlate the client's reply with the handler that asked the question. Sessions solve this by keeping the handler alive across multiple HTTP round-trips within the same in-memory session. #### Session lifecycle A session begins when a client sends an `initialize` JSON-RPC request without an `Mcp-Session-Id` header. The server: -1. Creates a new session with a unique session ID -2. Calls (if configured) to customize the session's `McpServerOptions` -3. Starts the MCP server for the session -4. Returns the session ID in the `Mcp-Session-Id` response header along with the `InitializeResult` +1. Creates a new session with a unique session ID. +2. Calls (if configured) to customize the session's `McpServerOptions`. +3. Starts the MCP server for the session. +4. Returns the session ID in the `Mcp-Session-Id` response header along with the `InitializeResult`. All subsequent requests from the client must include this session ID. @@ -220,20 +268,20 @@ All subsequent requests from the client must include this session ID. The server tracks the last activity time for each Streamable HTTP session. Activity is recorded when: -- A request arrives for the session (POST or GET) +- A request arrives for the session (`POST` or `GET`) - A response is sent for the session #### Idle timeout Streamable HTTP sessions that have no activity for the duration of (default: **2 hours**) are automatically closed. The idle timeout is checked in the background every 5 seconds. -A client can keep its session alive by maintaining any open HTTP request (e.g., a long-running POST with a streamed response or an open `GET` for unsolicited messages). Sessions with active requests are never considered idle. +A client can keep its session alive by maintaining any open HTTP request (for example, a long-running `POST` with a streamed response or an open `GET` for unsolicited messages). Sessions with active requests are never considered idle. When a session times out: - The session's `McpServer` is disposed - Any pending requests receive cancellation -- A client trying to use the expired session ID receives a `404 Session not found` error and should start a new session +- A client that tries to use the expired session ID receives a `404 Session not found` error and should start a new session You can disable idle timeout by setting it to `Timeout.InfiniteTimeSpan`, though this is not recommended for production deployments. @@ -251,10 +299,10 @@ Sessions with any active HTTP request don't count toward this limit. Streamable HTTP sessions can be terminated by: -- **Client DELETE request**: The client sends an HTTP `DELETE` to the session endpoint with its `Mcp-Session-Id` -- **Idle timeout**: The session exceeds the idle timeout without activity -- **Max idle count**: The server exceeds its maximum idle session count and prunes the oldest sessions -- **Server shutdown**: All sessions are disposed when the server shuts down +- **Client DELETE request**: The client sends an HTTP `DELETE` to the session endpoint with its `Mcp-Session-Id`. +- **Idle timeout**: The session exceeds the idle timeout without activity. +- **Max idle count**: The server exceeds its maximum idle session count and prunes the oldest sessions. +- **Server shutdown**: All sessions are disposed when the server shuts down. #### Deployment considerations @@ -264,11 +312,11 @@ Stateful sessions introduce several challenges for production, internet-facing s **Memory consumption.** Each session consumes memory on the server for the lifetime of the session. The default idle timeout is **2 hours**, and the default maximum idle session count is **10,000**. A server with many concurrent clients can accumulate significant memory usage. Monitor your idle session count and tune and to match your workload. -**Server restarts lose all sessions.** Sessions are stored in memory by default. When the server restarts (for deployments, crashes, or scaling events), all sessions are lost. Clients must reinitialize their sessions, which some clients may not handle gracefully. You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. +**Server restarts lose all sessions.** Sessions are stored in memory by default. When the server restarts (for deployments, crashes, or scaling events), all sessions are lost. Clients must reinitialize their sessions, which some clients might not handle gracefully. You can mitigate this with , but this adds complexity. See [Session migration](#session-migration) for details. -**Clients that don't send Mcp-Session-Id.** Some MCP clients may not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. +**Clients that don't send Mcp-Session-Id.** Some MCP clients might not send the `Mcp-Session-Id` header on every request. When this happens, the server responds with an error: `"Bad Request: A new session can only be created by an initialize request."` This can happen after a server restart, when a client loses its session ID, or when a client simply doesn't support sessions. If you see this error, consider whether your server actually needs sessions — and if not, switch to stateless mode. -**No built-in backpressure on advanced features.** By default, each JSON-RPC request holds its HTTP POST open until the handler responds — providing natural HTTP/2 backpressure. However, advanced features like and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations. +**No built-in backpressure on advanced features.** By default, each JSON-RPC request holds its HTTP `POST` open until the handler responds — providing natural HTTP/2 backpressure. However, advanced features like and [Tasks](xref:tasks) can decouple handler execution from the HTTP request, removing this protection. See [Request backpressure](#request-backpressure) for details and mitigations. ### stdio transport @@ -288,10 +336,10 @@ The SDK's MCP client () participates When you call , the client: -1. Connects to the server via the configured transport -2. Sends an `initialize` JSON-RPC request (without an `Mcp-Session-Id` header) -3. Receives the server's `InitializeResult` — if the response includes an `Mcp-Session-Id` header, the client stores it -4. Automatically includes the session ID in all subsequent requests (POST, GET, DELETE) +1. Connects to the server via the configured transport. +2. Sends an `initialize` JSON-RPC request (without an `Mcp-Session-Id` header). +3. Receives the server's `InitializeResult` — if the response includes an `Mcp-Session-Id` header, the client stores it. +4. Automatically includes the session ID in all subsequent requests (POST, GET, DELETE). This is entirely automatic — you don't need to manage the session ID yourself. The property exposes the current session ID (or `null` for transports that don't support sessions, like stdio). @@ -299,9 +347,9 @@ This is entirely automatic — you don't need to manage the session ID yourself. The server can terminate a session at any time — due to idle timeout, max session count exceeded, explicit shutdown, or any server-side policy. When this happens, subsequent requests with that session ID receive HTTP `404`. The client detects this and: -1. Wraps the failure in a with containing the HTTP status code -2. Cancels all in-flight operations -3. Completes the task +1. Wraps the failure in a with containing the HTTP status code. +2. Cancels all in-flight operations. +3. Completes the task. **There is no automatic reconnection after session expiry.** Your application must handle this. You can either create a fresh session with , or attempt to resume the existing session with if the server supports it. @@ -338,7 +386,7 @@ async Task ConnectWithRetryAsync( #### Stream reconnection -The Streamable HTTP client automatically reconnects its SSE event stream when the connection drops. This only applies to **stateful sessions** — the GET event stream is how the server sends unsolicited messages to the client, and it requires an active session. Stream reconnection is separate from session expiry: reconnection recovers the event stream within an existing session, while the example above handles creating a new session after the server has terminated the old one. +The Streamable HTTP client automatically reconnects its SSE event stream when the connection drops. This only applies to **stateful sessions** — the `GET` event stream is how the server sends unsolicited messages to the client, and it requires an active session. Stream reconnection is separate from session expiry: reconnection recovers the event stream within an existing session, while the previous example handles creating a new session after the server has terminated the old one. If the server has an [event store](#session-resumability) configured, the client sends `Last-Event-ID` on reconnection so the server can replay missed events. See [Transports](xref:transports) for details on reconnection intervals and retry limits (, ). If all reconnection attempts are exhausted, the transport closes and `McpClient.Completion` resolves. @@ -352,19 +400,19 @@ If the server is still tracking the session (or supports [session migration](#se - (optional) - (optional) -See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. +For a code example, see the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide. Session resumption is useful when: -- The client process restarts but the server session is still alive -- A transient network failure disconnects the client but the server hasn't timed out the session -- You want to hand off a session between different parts of your application +- The client process restarts but the server session is still alive. +- A transient network failure disconnects the client but the server hasn't timed out the session. +- You want to hand off a session between different parts of your application. #### Terminating a session When you dispose an `McpClient` (via `await using` or explicit `DisposeAsync`), the client sends an HTTP `DELETE` request to the session endpoint with the `Mcp-Session-Id` header. This tells the server to clean up the session immediately rather than waiting for the idle timeout. -The property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (e.g., reading capabilities) without intending to own the session's lifetime. +The property (default: `true`) controls this behavior. Set it to `false` when you're creating a transport purely to bootstrap session information (for example, reading capabilities) without intending to own the session's lifetime. ### Client transport options @@ -373,8 +421,9 @@ The following prop | Property | Default | Description | |----------|---------|-------------| | | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | -| | `true` | Whether to send a DELETE request when the client is disposed. Set to `false` when you don't want disposal to terminate the server session. | -| | `null` | Custom headers included in all requests (e.g., for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. | +| | `true` | Opens the standalone `GET` stream for unsolicited messages. Set to `false` to skip it; `POST` request/response streaming still works. | +| | `true` | Whether to send a `DELETE` request when the client is disposed. Set to `false` when you don't want disposal to terminate the server session. | +| | `null` | Custom headers included in all requests (for example, for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. | For transport-level options like reconnection intervals and transport mode, see [Transports](xref:transports). @@ -389,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 --- @@ -416,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. | @@ -425,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 @@ -446,7 +496,7 @@ options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancella }; ``` -See the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample for a complete example that filters tools based on route parameters. +For a complete example that filters tools based on route parameters, see the [AspNetCoreMcpPerSessionTools](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools) sample. #### Per-request configuration in stateless mode @@ -456,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 @@ -468,7 +518,7 @@ builder.Services.AddMcpServer() }) .WithTools(); ``` - + ### Security and user binding #### User binding @@ -477,13 +527,13 @@ When authentication is configured, the server automatically binds sessions to th ##### How it works -1. When a session is created, the server captures the authenticated user's identity from `HttpContext.User` +1. When a session is created, the server captures the authenticated user's identity from `HttpContext.User`. 2. The server extracts a user ID claim in priority order: - `ClaimTypes.NameIdentifier` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`) - `"sub"` (OpenID Connect subject claim) - `ClaimTypes.Upn` (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn`) -3. On each subsequent request, the server validates that the current user matches the session's original user -4. If there's a mismatch, the server responds with `403 Forbidden` +3. On each subsequent request, the server validates that the current user matches the session's original user. +4. If there's a mismatch, the server responds with `403 Forbidden`. This binding is automatic — no configuration is needed. If no authentication middleware is configured, user binding is skipped (the session is not bound to any user). @@ -497,9 +547,9 @@ In stateful mode, the server's Ctrl+C). | Stateless mode has the simplest cancellation story: the handler's `CancellationToken` is `HttpContext.RequestAborted` — the same token any ASP.NET Core endpoint receives. No additional tokens, linked sources, or session-level lifecycle to reason about. ### Client-initiated cancellation -In stateful modes (Streamable HTTP, SSE, stdio), a client can cancel a specific in-flight request by sending a [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) notification with the request ID. The SDK looks up the running handler and cancels its `CancellationToken`. This may result in an `OperationCanceledException` if the handler is awaiting a cancellation-aware operation when the token is cancelled. +In stateful modes (Streamable HTTP, SSE, stdio), a client can cancel a specific in-flight request by sending a [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation) notification with the request ID. The SDK looks up the running handler and cancels its `CancellationToken`. This can result in an `OperationCanceledException` if the handler is awaiting a cancellation-aware operation when the token is cancelled. -- Invalid or unknown request IDs are silently ignored -- In stateless mode, there is no persistent session to receive the notification on, so client-initiated cancellation does not apply +- Invalid or unknown request IDs are silently ignored. +- In stateless mode, there is no persistent session to receive the notification on, so client-initiated cancellation does not apply. - For [task-augmented requests](xref:tasks), the MCP specification requires using [`tasks/cancel`](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#cancelling-tasks) instead of `notifications/cancelled`. The SDK uses a separate cancellation token per task (independent of the original HTTP request), so `tasks/cancel` can cancel a task even after the initial request has completed. See [Tasks and session modes](#tasks-and-session-modes) for details. ### Server and session disposal When an `McpServer` is disposed — whether due to session termination, transport closure, or application shutdown — the SDK **awaits all in-flight handlers** before `DisposeAsync()` returns. This means: -- Handlers have an opportunity to complete cleanup (e.g., flushing writes, releasing locks) -- Scoped services created for the handler are disposed after the handler completes -- The SDK logs each handler's completion at `Information` level, including elapsed time +- Handlers have an opportunity to complete cleanup (for example, flushing writes, releasing locks). +- Scoped services created for the handler are disposed after the handler completes. +- The SDK logs each handler's completion at `Information` level, including elapsed time. #### Graceful shutdown in ASP.NET Core -When `ApplicationStopping` fires (e.g., `SIGTERM`, `Ctrl+C`, `app.StopAsync()`), the SDK immediately cancels active SSE and GET streams so that connected clients don't block shutdown. In-flight POST request handlers continue running and are awaited before the server finishes disposing. The total shutdown time is bounded by ASP.NET Core's `HostOptions.ShutdownTimeout` (default: **30 seconds**). In practice, the SDK completes shutdown well within this limit. +When `ApplicationStopping` fires (for example, `SIGTERM`, Ctrl+C, `app.StopAsync()`), the SDK immediately cancels active SSE and `GET` streams so that connected clients don't block shutdown. In-flight `POST` request handlers continue running and are awaited before the server finishes disposing. The total shutdown time is bounded by ASP.NET Core's `HostOptions.ShutdownTimeout` (default: **30 seconds**). In practice, the SDK completes shutdown well within this limit. For stateless servers, shutdown is even simpler: each request is independent, so there are no long-lived sessions to drain — just standard ASP.NET Core request completion. #### stdio process lifecycle -- **Graceful shutdown** (stdin EOF, `SIGTERM`, `Ctrl+C`): The transport closes, in-flight handlers are awaited, and `McpServer.DisposeAsync()` runs normally. +- **Graceful shutdown** (stdin EOF, `SIGTERM`, Ctrl+C): The transport closes, in-flight handlers are awaited, and `McpServer.DisposeAsync()` runs normally. - **Process kill** (`SIGKILL`): No cleanup occurs. Handlers are interrupted mid-execution, and no disposal code runs. This is inherent to process-level termination and not specific to the SDK. ### Stateless per-request logging -In stateless mode, each HTTP request creates and disposes a short-lived `McpServer` instance. This produces session lifecycle log entries at `Trace` level (`session created` / `session disposed`) for every request. These are typically invisible at default log levels but may appear when troubleshooting with verbose logging enabled. There is no user-facing `initialize` handshake in stateless mode — the SDK handles the per-request server lifecycle internally. +In stateless mode, each HTTP request creates and disposes a short-lived `McpServer` instance. This produces session lifecycle log entries at `Trace` level (`session created` / `session disposed`) for every request. These entries are typically invisible at default log levels but might appear when troubleshooting with verbose logging enabled. There is no user-facing `initialize` handshake in stateless mode — the SDK handles the per-request server lifecycle internally. ## Tasks and session modes -[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. +[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (enabled via `WithTasks`), and behavior differs between session modes. ### Stateless mode -Tasks are a natural fit for stateless servers. The client sends a task-augmented `tools/call` request, receives a task ID immediately, and polls for completion with `tasks/get` or `tasks/result` on subsequent independent HTTP requests. Because each request creates an ephemeral `McpServer` that shares the same `IMcpTaskStore`, all task operations work without any persistent session. +Tasks are a natural fit for stateless servers. The client sends a task-augmented `tools/call` request, receives a task ID immediately, and polls for completion with `tasks/get` on subsequent independent HTTP requests. Because each request creates an ephemeral `McpServer` that shares the same `IMcpTaskStore`, all task operations work without any persistent session. In stateless mode, there is no `SessionId`, so the task store does not apply session-based isolation. All tasks are accessible from any request to the same server. This is typically fine for single-purpose servers or when authentication middleware already identifies the caller. ### Stateful mode -In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. +In stateful mode, the `IMcpTaskStore` tracks tasks created for that server instance. The built-in keeps tasks in memory; implement a custom store when your scenario requires durable storage or isolation across server instances. -Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. +Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. For guidance, see [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store). -### Task cancellation vs request cancellation +### Task cancellation vs. request cancellation The MCP specification defines two distinct cancellation mechanisms: @@ -615,17 +665,17 @@ For task-augmented requests, the specification [requires](https://modelcontextpr ## Request backpressure -How well the server is protected against a flood of concurrent requests depends on the session mode and which advanced features are enabled. **In the default configuration, stateful and stateless modes provide identical HTTP-level backpressure** — both hold the POST response open while the handler runs, so HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits concurrent handlers per connection. The unbounded cases (legacy SSE, `EventStreamStore`, Tasks) are all **opt-in** advanced features. +How well the server is protected against a flood of concurrent requests depends on the session mode and which advanced features are enabled. **In the default configuration, stateful and stateless modes provide identical HTTP-level backpressure** — both hold the `POST` response open while the handler runs, so HTTP/2's `MaxStreamsPerConnection` (default: **100**) naturally limits concurrent handlers per connection. The unbounded cases (legacy SSE, `EventStreamStore`, Tasks) are all **opt-in** advanced features. ### Default stateful mode (no EventStreamStore, no tasks) -In the default configuration, each JSON-RPC request holds its POST response open until the handler produces a result. The POST response body is an SSE stream that carries the JSON-RPC response, and the server awaits the handler's completion before closing it. This means: +In the default configuration, each JSON-RPC request holds its `POST` response open until the handler produces a result. The `POST` response body is an SSE stream that carries the JSON-RPC response, and the server awaits the handler's completion before closing it. This means: - Each in-flight handler occupies one HTTP/2 stream - The HTTP server's `MaxStreamsPerConnection` (default: **100** in Kestrel) limits concurrent handlers per connection - This is the same backpressure model as **gRPC unary calls** — one request occupies one stream until the response is sent -One difference from gRPC: handler cancellation tokens are linked to the **session** lifetime, not `HttpContext.RequestAborted`. If a client disconnects from a POST mid-flight, the handler continues running until it completes or the session is terminated. But the client has freed a stream slot, so it can submit a new request — meaning the server could accumulate up to `MaxStreamsPerConnection` handlers that outlive their original connections. In practice this is bounded and comparable to how gRPC handlers behave when the client cancels an RPC. +One difference from gRPC: handler cancellation tokens are linked to the **session** lifetime, not `HttpContext.RequestAborted`. If a client disconnects from a `POST` mid-flight, the handler continues running until it completes or the session is terminated. But the client has freed a stream slot, so it can submit a new request — meaning the server could accumulate up to `MaxStreamsPerConnection` handlers that outlive their original connections. In practice this is bounded and comparable to how gRPC handlers behave when the client cancels an RPC. For comparison, ASP.NET Core SignalR limits concurrent hub invocations per client to **1** by default (`MaximumParallelInvocationsPerClient`). Default stateful MCP is less restrictive but still bounded by HTTP/2 stream limits. @@ -633,32 +683,32 @@ For comparison, ASP.NET Core SignalR limits concurrent hub invocations per clien Legacy SSE endpoints are [disabled by default](#legacy-sse-transport) and must be explicitly enabled via . This is the primary reason they are disabled — the SSE transport has no built-in HTTP-level backpressure. -The legacy SSE transport separates the request and response channels: clients POST JSON-RPC messages to `/message` and receive responses through a long-lived GET SSE stream on `/sse`. The POST endpoint returns **202 Accepted immediately** after queuing the message — it does not wait for the handler to complete. This means there is **no HTTP-level backpressure** on handler concurrency, because each POST frees its connection immediately regardless of how long the handler runs. +The legacy SSE transport separates the request and response channels: clients `POST` JSON-RPC messages to `/message` and receive responses through a long-lived `GET` SSE stream on `/sse`. The `POST` endpoint returns **202 Accepted immediately** after queuing the message — it does not wait for the handler to complete. This means there is **no HTTP-level backpressure** on handler concurrency, because each `POST` frees its connection immediately regardless of how long the handler runs. -Internally, handlers are dispatched with the same fire-and-forget pattern as Streamable HTTP (`_ = ProcessMessageAsync()`). A client can send unlimited POST requests to `/message` while keeping the GET stream open, and each one spawns a concurrent handler with no built-in limit. +Internally, handlers are dispatched with the same fire-and-forget pattern as Streamable HTTP (`_ = ProcessMessageAsync()`). A client can send unlimited `POST` requests to `/message` while keeping the `GET` stream open, and each one spawns a concurrent handler with no built-in limit. -The GET stream does provide **session lifetime bounds**: handler cancellation tokens are linked to the GET request's `HttpContext.RequestAborted`, so when the client disconnects the SSE stream, all in-flight handlers are cancelled. This is similar to SignalR's connection-bound lifetime model — but unlike SignalR, there is no per-client concurrency limit like `MaximumParallelInvocationsPerClient`. The GET stream provides cleanup on disconnect, not rate-limiting during the connection. +The `GET` stream does provide **session lifetime bounds**: handler cancellation tokens are linked to the `GET` request's `HttpContext.RequestAborted`, so when the client disconnects the SSE stream, all in-flight handlers are cancelled. This is similar to SignalR's connection-bound lifetime model — but unlike SignalR, there is no per-client concurrency limit like `MaximumParallelInvocationsPerClient`. The `GET` stream provides cleanup on disconnect, not rate-limiting during the connection. ### With EventStreamStore - is an advanced API that enables session resumability — storing SSE events so clients can reconnect and replay missed messages using the `Last-Event-ID` header. When configured, handlers gain the ability to call `EnablePollingAsync()`, which closes the POST response early and switches the client to polling mode. + is an advanced API that enables session resumability — storing SSE events so clients can reconnect and replay missed messages using the `Last-Event-ID` header. When configured, handlers gain the ability to call `EnablePollingAsync()`, which closes the `POST` response early and switches the client to polling mode. When a handler calls `EnablePollingAsync()`: -- The POST response completes **before the handler finishes** +- The `POST` response completes **before the handler finishes** - The handler continues running in the background, decoupled from any HTTP request - The client's HTTP/2 stream slot is freed, allowing it to submit more requests - **HTTP-level backpressure no longer applies** — there is no built-in limit on how many concurrent handlers can accumulate The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expiration, 30-minute sliding window) that govern event retention, but these do not limit handler concurrency. If you enable `EventStreamStore` on a public-facing server, apply **HTTP rate-limiting middleware** and **reverse proxy limits** to compensate for the loss of stream-level backpressure. -### With tasks (experimental) +### With tasks -[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. +[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately, so the `POST` response completes **before the handler starts its real work**. This means: -- **No HTTP-level backpressure on task handlers** — each POST returns almost immediately, freeing the stream slot +- **No HTTP-level backpressure on task handlers** — each `POST` returns almost immediately, freeing the stream slot - A client can rapidly submit many task-augmented requests, each spawning a background handler with no concurrency limit - Task cleanup is governed by TTL (time-to-live), not by handler completion or session termination @@ -668,15 +718,15 @@ For servers using the built-in automatic task handlers without external work dis ### Stateless mode -Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each POST is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see [Handler cancellation tokens](#handler-cancellation-tokens) above). +Stateless mode provides the same HTTP-level backpressure as default stateful mode. In both modes, each `POST` is held open until the handler responds. The one difference is cancellation: in stateless mode, the handler's `CancellationToken` is `HttpContext.RequestAborted`, so if a client disconnects mid-flight, the handler is cancelled immediately — identical to a standard ASP.NET Core minimal API or controller action. In default stateful mode, the handler's token is session-scoped, so a disconnected client's handler continues running until it completes or the session is terminated (see the [Handler cancellation tokens](#handler-cancellation-tokens) section). ### Summary -| Configuration | POST held open? | Backpressure mechanism | Concurrent handler limit per connection | +| Configuration | `POST` held open? | Backpressure mechanism | Concurrent handler limit per connection | |---|---|---|---| | **Stateless** | Yes (handler = request) | HTTP/2 streams, server timeouts | `MaxStreamsPerConnection` (default: 100) | | **Stateful (default)** | Yes (until handler responds) | HTTP/2 streams, server timeouts | `MaxStreamsPerConnection` (default: 100) | -| **SSE (legacy — opt-in)** | No (returns 202 Accepted) | None built-in; GET stream provides cleanup | Unbounded — apply rate limiting | +| **SSE (legacy — opt-in)** | No (returns 202 Accepted) | None built-in; `GET` stream provides cleanup | Unbounded — apply rate limiting | | **Stateful + EventStreamStore** | No (if `EnablePollingAsync()` called) | None built-in | Unbounded — apply rate limiting | | **Stateful + Tasks** | No (returns task ID immediately) | None built-in | Unbounded — apply rate limiting | @@ -721,7 +771,7 @@ app.MapMcp().AddEndpointFilter(async (context, next) => > [!NOTE] -> The `AllowNewSessionForNonInitializeRequests` AppContext switch (`ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests`) is a back-compat escape hatch that allows creating new sessions from non-initialize POST requests that arrive without an `Mcp-Session-Id` header. When enabled, the server creates a **brand-new session** for each such request rather than rejecting it — the response still carries the `Mcp-Session-Id` header with the new session's ID. This is **non-compliant with the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)**, which requires that only `initialize` requests create sessions. Use it only as a temporary workaround for clients that don't implement the session protocol correctly. +> The `AllowNewSessionForNonInitializeRequests` AppContext switch (`ModelContextProtocol.AspNetCore.AllowNewSessionForNonInitializeRequests`) is a back-compat escape hatch that allows creating new sessions from non-initialize `POST` requests that arrive without an `Mcp-Session-Id` header. When enabled, the server creates a **brand-new session** for each such request rather than rejecting it — the response still carries the `Mcp-Session-Id` header with the new session's ID. This is **non-compliant with the [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http)**, which requires that only `initialize` requests create sessions. Use it only as a temporary workaround for clients that don't implement the session protocol correctly. ### Other activity tags @@ -731,10 +781,10 @@ Other tags include `mcp.method.name`, `mcp.protocol.version`, `jsonrpc.request.i The SDK records histograms under the `Experimental.ModelContextProtocol` meter: -| Metric | Description | -|--------|-------------| -| `mcp.server.session.duration` | Duration of the MCP session on the server | -| `mcp.client.session.duration` | Duration of the MCP session on the client | +| Metric | Description | +|---------------------------------|-----------------------------------------------------| +| `mcp.server.session.duration` | Duration of the MCP session on the server | +| `mcp.client.session.duration` | Duration of the MCP session on the client | | `mcp.server.operation.duration` | Duration of each request/notification on the server | | `mcp.client.operation.duration` | Duration of each request/notification on the client | @@ -745,26 +795,26 @@ 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 -1. The client connects to the `/sse` endpoint with a GET request -2. The server generates a session ID and sends a `/message?sessionId={id}` URL as the first SSE event -3. The client sends JSON-RPC messages as POST requests to that `/message?sessionId={id}` URL -4. The server streams responses and unsolicited messages back over the open SSE GET stream +1. The client connects to the `/sse` endpoint with a `GET` request. +2. The server generates a session ID and sends a `/message?sessionId={id}` URL as the first SSE event. +3. The client sends JSON-RPC messages as `POST` requests to that `/message?sessionId={id}` URL. +4. The server streams responses and unsolicited messages back over the open SSE `GET` stream. -Unlike Streamable HTTP which uses the `Mcp-Session-Id` header, legacy SSE passes the session ID as a query string parameter on the `/message` endpoint. +Unlike Streamable HTTP, which uses the `Mcp-Session-Id` header, legacy SSE passes the session ID as a query string parameter on the `/message` endpoint. ### Session lifetime -SSE session lifetime is tied directly to the GET SSE stream. When the client disconnects (detected via `HttpContext.RequestAborted`), or the server shuts down (via `IHostApplicationLifetime.ApplicationStopping`), the session is immediately removed. There is no idle timeout or maximum idle session count for SSE sessions — the session exists exactly as long as the SSE connection is open. +SSE session lifetime is tied directly to the `GET` SSE stream. When the client disconnects (detected via `HttpContext.RequestAborted`), or the server shuts down (via `IHostApplicationLifetime.ApplicationStopping`), the session is immediately removed. There is no idle timeout or maximum idle session count for SSE sessions — the session exists exactly as long as the SSE connection is open. This makes SSE sessions behave similarly to [stdio](#stdio-transport): the session is implicit in the connection lifetime, and disconnection is the only termination mechanism. ### Configuration - and both work with SSE sessions. They are called during the `/sse` GET request handler, and services resolve from the GET request's `HttpContext.RequestServices`. [User binding](#user-binding) also works — the authenticated user is captured from the GET request and verified on each POST to `/message`. + and both work with SSE sessions. They are called during the `/sse` `GET` request handler, and services resolve from the `GET` request's `HttpContext.RequestServices`. [User binding](#user-binding) also works — the authenticated user is captured from the `GET` request and verified on each `POST` to `/message`. ## Advanced features @@ -777,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(); }); ``` @@ -790,9 +840,9 @@ builder.Services.AddSingleton { // Session resumability is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = new MyEventStreamStore(); }); ``` @@ -816,4 +866,4 @@ When configured: - Events are stored for later replay - When a client reconnects with `Last-Event-ID`, missed events are replayed before new events are sent -This is useful for clients that may experience transient network issues. Without an event store, clients that disconnect and reconnect may miss events that were sent while they were disconnected. +This is useful for clients that might experience transient network issues. Without an event store, clients that disconnect and reconnect might miss events that were sent while they were disconnected. diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 16e3a6d..c1ab0c2 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -10,16 +10,16 @@ Tasks let an MCP server run a request asynchronously and report its result to th primary use case today is long-running tool invocations: the tool is offloaded to a background task, and the client polls for status, optionally exchanging additional input along the way. -> **Status**: Experimental — diagnostic ID `MCPEXP001`. The implementation tracks -> [SEP-2663 (Tasks Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md). -> See [Experimental APIs](xref:experimental) for how to opt in. +Tasks are provided by the `ModelContextProtocol.Extensions.Tasks` package and require MCP protocol +version `2026-07-28` or later. The implementation follows +[SEP-2663 (Tasks Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md). ### Overview A client opts into tasks on a per-request basis by including the `io.modelcontextprotocol/tasks` -extension key in the request's `_meta`. When that opt-in is present, the server **may** respond -with a instead of the standard result -(e.g., ). The client then polls `tasks/get` +extension key in the request's `_meta`. When that opt-in is present, the server **might** respond +with a instead of the standard result +(for example, ). The client then polls `tasks/get` until the task reaches a terminal state. Per the SEP, the server **must not** return `CreateTaskResult` for a request that did not include @@ -39,36 +39,33 @@ the extension opt-in. The SDK enforces this on the server side. └──→ Failed (terminal — JSON-RPC errors only) ``` - wire values are serialized in snake_case: + wire values are serialized in snake_case: `working`, `input_required`, `completed`, `cancelled`, `failed`. The discriminator field -on the response payload is `"task"` for +on the response payload is `"task"` for and `"complete"` for ordinary results. ### Server configuration #### Using the task store -The easiest way to enable tasks is to set an -on . -The SDK ships for development and tests: +The easiest way to enable the package's task store integration is to call + on the server builder, +passing an . +The SDK ships for development and tests: ```csharp -#pragma warning disable MCPEXP001 +using ModelContextProtocol.Extensions.Tasks; -builder.Services.AddMcpServer(options => -{ - options.TaskStore = new InMemoryMcpTaskStore(); -}) -.WithTools(); +builder.Services.AddMcpServer() + .WithTools() + .WithTasks(new InMemoryMcpTaskStore()); ``` -When a `TaskStore` is configured the SDK automatically: +When tasks are enabled with `WithTasks` the SDK automatically: -- Wires `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. Explicit - handlers in still take precedence - for any slot they fill. +- Wires the `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. - Advertises the `io.modelcontextprotocol/tasks` extension in . - Wraps each `[McpServerTool]` invocation so that, when the client opts in to the extension, @@ -80,41 +77,26 @@ When a `TaskStore` is configured the SDK automatically: - Plumbs a `CancellationToken` through to the tool that fires when the client invokes `tasks/cancel`, so cancellation propagates cooperatively. +Alternate-result `tools/call` filters run in registration order, with the Tasks filter creating a task at its position in that order. Filters before Tasks run before task creation. Filters after Tasks run in the background before the ordinary filter pipeline. ASP.NET Core tool authorization uses an alternate-result filter registered before Tasks, so an unauthorized call does not create a task. + +Ordinary `tools/call` filters still run exactly once for task-backed calls. They execute in the background after the task record is created and before the tool body, so validation and telemetry continue to apply. Each background invocation gets an independent DI scope that remains alive until the tool pipeline completes. + For production scenarios that need durability, session isolation, multi-process routing, or -TTL-based cleanup, implement yourself -(see [Implementing a custom task store](#implementing-a-custom-task-store) below). - -#### Custom task handlers - -For full control without a store, set the handlers directly. Each handler is an - that receives an - with typed parameters: - -```csharp -options.Handlers.GetTaskHandler = (context, ct) => -{ - var taskId = context.Params!.TaskId; - // … look up state and return one of the GetTaskResult subtypes. - return new ValueTask(new WorkingTaskResult { TaskId = taskId, /* … */ }); -}; - -options.Handlers.UpdateTaskHandler = (context, ct) => /* return ValueTask */; -options.Handlers.CancelTaskHandler = (context, ct) => /* return ValueTask */; -``` - -> **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting -> into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get` -> handler is wired, the server throws `InvalidOperationException` at request time so misconfigured -> deployments fail loudly instead of shipping unpollable tasks. +TTL-based cleanup, implement yourself +(see the [Implementing a custom task store](#implementing-a-custom-task-store) section). #### Returning a task from a tool handler - -returns , so each invocation can choose -between an immediate result and a background task: +For full control without the store's auto-wrapping, set +. +It returns a , so each invocation can choose +between an immediate result and an alternate result such as a +: ```csharp -options.Handlers.CallToolWithTaskHandler = async (context, ct) => +using ModelContextProtocol.Extensions.Tasks; + +options.Handlers.CallToolWithAlternateHandler = async (context, ct) => { if (ShouldRunInline(context.Params!)) { @@ -122,7 +104,7 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) => } var taskId = await StartBackgroundWorkAsync(context.Params!, ct); - return new CreateTaskResult + var created = new CreateTaskResult { TaskId = taskId, Status = McpTaskStatus.Working, @@ -130,39 +112,27 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) => LastUpdatedAt = DateTimeOffset.UtcNow, PollIntervalMs = 1000, }; + + return new ResultOrAlternate(created, McpTasksJsonContext.Default.CreateTaskResult); }; ``` +> This low-level handler is mutually exclusive with `WithTasks`. When a store is configured, the +> SDK does the wrapping for you and throws `InvalidOperationException` if the alternate handler also +> returns an alternate. Use one mechanism or the other. When you return a task this way, you're also +> responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides +> automatically. + > -> and +> and > are mutually exclusive. Setting one while the other is already non-null throws > `InvalidOperationException` at the property setter. -#### Task scope for server-initiated requests - -When you start background work from a custom -(rather than the SDK's auto-wrapping), use -to route elicitation, sampling, and `roots/list` calls through the task store as input requests -instead of direct JSON-RPC messages: - -```csharp -using (server.CreateMcpTaskScope(taskId, taskStore)) -{ - // ElicitAsync/SampleAsync/RequestRootsAsync calls in here are surfaced as - // entries in the task's inputRequests, then await client responses via tasks/update. - var elicit = await server.ElicitAsync(elicitParams, ct); -} -``` - -`CreateMcpTaskScope` returns an `IDisposable` that restores the prior ambient context on -`Dispose`. The scope is established automatically for `[McpServerTool]` methods that run via -`McpServerOptions.TaskStore`, so this API is only needed for custom handlers. - ### Client usage #### Automatic polling - + handles the full task lifecycle automatically: - Injects the `io.modelcontextprotocol/tasks` extension capability into the request's `_meta`. @@ -176,21 +146,25 @@ handles the full task lifecycle automatically: or throws on `Failed`/`Cancelled`. ```csharp -var result = await client.CallToolAsync( +using ModelContextProtocol.Extensions.Tasks; + +var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments }, - cancellationToken); + cancellationToken: cancellationToken); ``` #### Manual control -Use to receive the raw - without auto-polling, then drive the -lifecycle yourself using , -, and -: +Use to receive the raw + without auto-polling, then drive the +lifecycle yourself using , +, and +: ```csharp -var raw = await client.CallToolRawAsync(requestParams, cancellationToken); +using ModelContextProtocol.Extensions.Tasks; + +var raw = await client.CallToolAsTaskAsync(requestParams, cancellationToken); if (raw.IsTask) { var taskId = raw.TaskCreated!.TaskId; @@ -206,17 +180,17 @@ if (raw.IsTask) #### Stuck-task detector -`CallToolAsync` includes a safety net for misbehaving servers: if the task stays in - across many consecutive polls -without exposing any new input request keys (i.e. every previously requested input has already +`CallToolWithPollingAsync` includes a safety net for misbehaving servers: if the task stays in + across many consecutive polls +without exposing any new input request keys (that is, every previously requested input has already been resolved by the client and yet the server keeps returning `InputRequired`), the client gives up, issues a best-effort `tasks/cancel`, and throws . This guards against a server that never transitions out of `InputRequired` and prevents an unbounded poll loop. -The threshold defaults to `60` consecutive stuck polls and is configurable via -. The effective -wall-clock timeout is roughly `MaxConsecutiveStuckPolls * pollIntervalMs`, so tune the option +The threshold defaults to `60` consecutive stuck polls and is configurable via the +`maxConsecutiveStuckPolls` parameter on `CallToolWithPollingAsync`. The effective +wall-clock timeout is roughly `maxConsecutiveStuckPolls * pollIntervalMs`, so tune the value with the server-side poll cadence in mind. Setting it too low risks false positives for servers that are slow to surface follow-up input requests; setting it too high can mask misbehaving servers. @@ -224,11 +198,11 @@ servers. ### Input requests (multi-round-trip) When a task needs additional input from the client, the server transitions it to - and returns the outstanding -requests in . Each + and returns the outstanding +requests in . Each entry is an arbitrary key paired with a `{ method, params }` envelope representing an equivalent standalone server-to-client request. The client provides answers via -, keyed by the same identifiers. +, keyed by the same identifiers. Supported input request methods: @@ -241,36 +215,36 @@ Per SEP-2663: - Each input request key **must** be unique over the lifetime of the task. - Clients **should** deduplicate keys across polls so a request is only presented to the user - or model once. `CallToolAsync` does this automatically. + or model once. `CallToolWithPollingAsync` does this automatically. - Servers **should** ignore `inputResponses` entries whose key does not currently correspond to an outstanding request, including responses for terminal-state tasks. - follows this rule. + follows this rule. ### Implementing a custom task store -Implement for production scenarios. Key +Implement for production scenarios. Key requirements drawn from the SEP and the SDK contract: -1. **Thread safety** — every method may be called concurrently. +1. **Thread safety** — every method can be called concurrently. 2. **Idempotent terminal transitions** — - , - , and - must be no-ops on a task + , + , and + must be no-ops on a task that is already in a terminal state so a late cancellation cannot overwrite a result. 3. **`InputResponseReceived` event** — after persisting an input response inside - , raise - + , raise + for each resolved entry. This is the only mechanism that wakes a pending `server.ElicitAsync`/`server.SampleAsync` call waiting inside a task scope. In distributed deployments where a different server instance receives the `tasks/update`, the event must be propagated to the originating server (for example via Redis pub/sub, SignalR, or a custom transport). 4. **Strong-consistency on `CreateTaskAsync`** — - must not return until the + must not return until the task is durably persisted, so that a subsequent - with the returned task ID + with the returned task ID resolves immediately — even from a different process or node. Stores backed by - eventually-consistent storage must wait for the write to become visible (quorum + eventually consistent storage must wait for the write to become visible (quorum acknowledgement, write-through, etc.) before returning. Required by SEP-2663 §306. 5. **Singleton under stateless HTTP** — when the server runs in stateless mode (each request spins up a fresh server instance), the same `IMcpTaskStore` instance must be shared across @@ -310,12 +284,12 @@ public sealed class MyTaskStore : IMcpTaskStore ### Status semantics - is the terminal status whenever the + is the terminal status whenever the underlying request produced its standard result, *including a with `IsError = true`*. Per SEP-2663, tool-level error results are not promoted to `Failed`. - is reserved for JSON-RPC protocol-level + is reserved for JSON-RPC protocol-level errors during execution — for example, a malformed request, or an unhandled exception in a custom handler that the SDK converts to a JSON-RPC error. Use for @@ -341,29 +315,48 @@ In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore` #### Immutable store design - uses immutable record snapshots with + uses immutable record snapshots with compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are -exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state. +exposed as `ImmutableDictionary<,>` so observers can't mutate internal state. #### Capability bypass inside a task scope When `server.ElicitAsync`/`server.SampleAsync`/`server.RequestRootsAsync` execute inside a task scope, the SDK intentionally skips the normal client-capability negotiation checks (`ThrowIfElicitationUnsupported`, etc.). The tasks extension itself is the negotiated capability: -the client opted in by including the extension marker in the originating request, so it is +the client opted in by including the extension marker in the originating request, so it's responsible for handling — or rejecting — the input requests surfaced through `tasks/get`. +#### Compatibility with v1 experimental Tasks + +The Tasks extension in v2.0.0 replaces the experimental Tasks implementation shipped in v1.3.0 and +v1.4.x. The implementations are not compatible at either the API or protocol level. A v2 Tasks +client or server must use a connection negotiated to `2026-07-28` or later; it cannot fall back to +the down-level implementation. + +On a connection negotiated to the down-level `2025-11-25` protocol: + +- A v2 client calling a v1 server receives an ordinary tool result. The v2 client does not opt in + to the down-level Tasks protocol, and `GetTaskAsync` rejects use before a `2026-07-28` + connection is negotiated. +- A v1 client calling a v2 server likewise receives an ordinary tool result. The v2 server does + not create Tasks on a down-level connection, and its `tasks/get` endpoint rejects the legacy + request with a method-not-found error. + +Upgrade both peers to the v2 Tasks extension before using Tasks. The extension provides no +compatibility bridge for the previous experimental API. + ### Known limitations - **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on polling exclusively. -- **Lazy task creation**: when a tool runs through `TaskStore`, the store's - is invoked eagerly before +- **Lazy task creation**: when a tool runs through the task store, the store's + is invoked eagerly before the inner handler runs, so tools that complete inline still incur a store write. There is currently no built-in deferral. - **Mid-execution promotion to task**: an `[McpServerTool]` method cannot start executing synchronously and then transition its remaining work to a background task. Use a custom - + if you need that pattern. - **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the task channel when called from inside a task scope, but the client SDK does not currently diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index 1b49995..276415e 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -3,6 +3,10 @@ items: href: index.md - name: Getting Started uid: getting-started +- name: Deployment + items: + - name: Docker + uid: docker-deployment - name: Base Protocol items: - name: Capabilities @@ -17,8 +21,6 @@ items: uid: progress - name: Cancellation uid: cancellation - - name: Tasks - uid: tasks - name: Multi Round-Trip Requests (MRTR) uid: mrtr - name: Client Features @@ -51,5 +53,11 @@ items: items: - name: MCP Apps uid: apps + - name: Tasks + uid: tasks - name: Identity and Roles uid: identity +- name: API Reference + uid: ModelContextProtocol +- name: Experimental APIs + uid: experimental diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index ae552e3..3a68d61 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -39,13 +39,13 @@ Register the tool type when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithTools(); ``` ### Content types -Tools can return various content types. The simplest is a `string`, which is automatically wrapped in a . For richer content, tools can return one or more instances. Tools can also return `DataContent` from Microsoft.Extensions.AI, which is automatically mapped to the appropriate MCP content block: image MIME types become , audio MIME types become , and all other MIME types become with binary resource contents. +Tools can return various content types. The simplest is a `string`, which is automatically wrapped in a . For richer content, tools can return one or more instances. Tools can also return [`DataContent`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.datacontent) from Microsoft.Extensions.AI, which is automatically mapped to the appropriate MCP content block: image MIME types become , audio MIME types become , and all other MIME types become with binary resource contents. #### Text content @@ -121,6 +121,8 @@ public static EmbeddedResourceBlock GetBinaryData(string id) } ``` +The SDK sends binary embedded resources using the MCP resource shape: a URI, MIME type, and base64-encoded `blob`. It does not convert formats such as `application/pdf` into images. How an embedded resource is rendered or made available to a model depends on the client. When targeting clients that do not support a binary format, consider also returning a text representation or another client-supported content block. + #### Mixed content Tools can return multiple content blocks by returning `IEnumerable`: @@ -139,6 +141,16 @@ public static IEnumerable DescribeImage() } ``` +#### Structured content + +Set `UseStructuredContent = true` on or + to advertise an output schema and +serialize the return value into . +The output schema may be any JSON Schema 2020-12 document. Non-object return values are represented +directly, so a tool returning `72` produces `structuredContent: 72`, not +`structuredContent: { "result": 72 }`. Clients should read the value according to the advertised +output schema rather than assuming a `result` wrapper. + #### Content annotations Any content block can include to provide hints about the intended audience and priority: @@ -225,7 +237,7 @@ public static double Divide(double a, double b) #### Protocol errors -Throw to signal a protocol-level error (e.g., invalid parameters or unknown tool). These exceptions propagate as JSON-RPC error responses rather than tool error results: +Throw to signal a protocol-level error (for example, invalid parameters or unknown tool). These exceptions propagate as JSON-RPC error responses rather than tool error results: ```csharp [McpServerTool, Description("Processes the input")] @@ -296,13 +308,13 @@ Tool parameters are described using [JSON Schema 2020-12]. JSON schemas are auto [JSON Schema 2020-12]: https://json-schema.org/specification -| .NET Type | JSON Schema Type | -|-----------|-----------------| -| `string` | `string` | -| `int`, `long` | `integer` | -| `float`, `double` | `number` | -| `bool` | `boolean` | -| Complex types | `object` with `properties` | +| .NET type | JSON schema type | +|-------------------|----------------------------| +| `string` | `string` | +| `int`, `long` | `integer` | +| `float`, `double` | `number` | +| `bool` | `boolean` | +| Complex types | `object` with `properties` | Use `[Description]` attributes on parameters to populate the `description` field in the generated schema. This helps LLMs understand what each parameter expects. @@ -339,7 +351,7 @@ Rules and constraints: - The header name must contain only visible ASCII characters (0x21–0x7E) excluding colon (`:`). - Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. - Header names must be case-insensitively unique within the tool's input schema. -- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `2026-07-28` and later). +- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (`2026-07-28` and later). ### Pre-loading tool definitions on the client diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 354299c..bb4e155 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -7,7 +7,7 @@ uid: transports ## Transports -MCP uses a [transport layer] to handle the communication between clients and servers. Three transport mechanisms are supported: **stdio**, **Streamable HTTP**, and **SSE** (Server-Sent Events, legacy). +MCP uses a [transport layer] to handle the communication between clients and servers. Three transport mechanisms are supported: [**stdio**](#stdio-transport), [**Streamable HTTP**](#streamable-http-transport), and [**SSE**](#sse-transport-legacy) (Server-Sent Events, legacy). [transport layer]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports @@ -32,12 +32,12 @@ var transport = new StdioClientTransport(new StdioClientTransportOptions await using var client = await McpClient.CreateAsync(transport); ``` -Key properties: +The following table describes the key properties: -| Property | Description | -|----------|-------------| -| `Command` | The executable to launch (required) | -| `Arguments` | Command-line arguments for the process | +| Property | Description | +|--------------------|------------------------------------------| +| `Command` | The executable to launch (required) | +| `Arguments` | Command-line arguments for the process | | `WorkingDirectory` | Working directory for the server process | | `EnvironmentVariables` | Environment variables (merged with current when inheriting; `null` values remove variables) | | `InheritEnvironmentVariables` | Whether the server process inherits the current process's environment variables (default: `true`) | @@ -47,7 +47,7 @@ Key properties: #### Environment variable inheritance -By default, the server process inherits **all** environment variables from the current process. This includes credentials, tokens, proxy settings, and internal configuration that may be sensitive or irrelevant to the server. When running third-party or untrusted MCP servers, consider disabling inheritance to prevent unintentional credential leakage: +By default, the server process inherits **all** environment variables from the current process. This includes credentials, tokens, proxy settings, and internal configuration that might be sensitive or irrelevant to the server. When running third-party or untrusted MCP servers, consider disabling inheritance to prevent unintentional credential leakage: ```csharp var transport = new StdioClientTransport(new StdioClientTransportOptions @@ -94,7 +94,7 @@ var transport = new StdioClientTransport(new StdioClientTransportOptions > [!WARNING] > **Security risk (inheriting):** Variables such as `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, `OPENAI_API_KEY`, and similar credentials present in the parent process automatically flow into the child process unless inheritance is disabled. This can unintentionally expose sensitive values to third-party or untrusted MCP servers. > -> **Compatibility risk (not inheriting):** Disabling inheritance can cause the child process to fail to start or behave incorrectly if it relies on variables provided by the OS or shell. `GetDefaultEnvironmentVariables()` covers the most common requirements — `PATH`, `HOME`, and standard system directories — so for most servers it is a safe starting point. For servers that need additional variables not in the default set (such as `DOTNET_ROOT`, `LD_LIBRARY_PATH`, `JAVA_HOME`, or proxy settings like `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`), add them on top as shown in the example above. +> **Compatibility risk (not inheriting):** Disabling inheritance can cause the child process to fail to start or behave incorrectly if it relies on variables provided by the OS or shell. `GetDefaultEnvironmentVariables()` covers the most common requirements — `PATH`, `HOME`, and standard system directories — so for most servers it's a safe starting point. For servers that need additional variables not in the default set (such as `DOTNET_ROOT`, `LD_LIBRARY_PATH`, `JAVA_HOME`, or proxy settings like `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`), add them on top as shown in the previous example. #### stdio server @@ -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 uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) 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. +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 @@ -202,14 +205,13 @@ If you intentionally expose the server through another host name, such as a tunn #### Browser cross-origin access -**Only** enable CORS if you intentionally want browser-based cross-origin access to this server. +**Only** enable cross-origin requests (CORS) if you intentionally want browser-based cross-origin access to this server. CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). For a **stateless** browser client, a narrowly scoped CORS policy usually only needs the headers the browser would otherwise preflight: `Content-Type` for JSON, `Authorization` when the endpoint is protected, and `MCP-Protocol-Version`. If you enable sessions or resumability, also allow `Mcp-Session-Id` and `Last-Event-ID`, and expose `Mcp-Session-Id` on responses so browser code can read it. `Accept` normally doesn't need to be listed because browsers can already send it without extra CORS configuration. - -_In this sample below, the MCP server will allow browser calls from `localhost:5173` where a web application is making the request. In production, this allowed origin list would be configured to the trusted web application domains._ +_In the following sample, the MCP server will allow browser calls from `localhost:5173` where a web application is making the request. In production, this allowed origin list would be configured to the trusted web application domains._ ```json // appsettings.Development.json @@ -230,7 +232,7 @@ builder.Services.AddCors(options => options.AddPolicy("McpBrowserClient", policy => { policy.WithOrigins(allowedOrigins) - // Add GET for standalone/resumable SSE streams and DELETE for stateful session termination. + // Add `GET` for standalone/resumable SSE streams and DELETE for stateful session termination. .WithMethods("POST", "GET", "DELETE") .WithHeaders("Content-Type", "Authorization", "MCP-Protocol-Version", "Mcp-Session-Id") .WithExposedHeaders("Mcp-Session-Id"); @@ -245,9 +247,11 @@ app.MapMcp("/mcp").RequireCors("McpBrowserClient"); #### How messages flow -In Streamable HTTP, client requests arrive as HTTP POST requests. The server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each POST holds its connection until the handler completes. +In Streamable HTTP, client requests arrive as HTTP `POST` requests. The server holds each `POST` response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each `POST` holds its connection until the handler completes. -In stateful mode, the client can also open a long-lived GET request to receive **unsolicited** messages — notifications or server-to-client requests that the server initiates outside any active request handler (e.g., resource-changed notifications from a background watcher). In stateless mode, the GET endpoint is not mapped, so every message must be part of a POST response. See [How Streamable HTTP delivers messages](xref:stateless#how-streamable-http-delivers-messages) for a detailed breakdown. +In stateful mode, the client can also open a long-lived `GET` request to receive **unsolicited** messages — notifications or server-to-client requests that the server initiates outside any active request handler (for example, resource-changed notifications from a background watcher). In stateless mode, the `GET` endpoint is not mapped, so every message must be part of a `POST` response. For a detailed breakdown, see [How Streamable HTTP delivers messages](xref:stateless#how-streamable-http-delivers-messages). + +If your client does not need unsolicited server-to-client messages, or if a long-lived `GET` stream would block other requests under a constrained `HttpClient` connection pool, set to `false`. Direct responses and streaming responses to client `POST` requests still work; only the standalone `GET` stream is skipped. A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: @@ -257,7 +261,9 @@ A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] app.MapMcp("/mcp"); ``` -When using a custom route, Streamable HTTP clients should connect directly to that route (e.g., `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:stateless#legacy-sse-transport)) should connect to `{route}/sse` (e.g., `https://host/mcp/sse`). +When using a custom route, Streamable HTTP clients should connect directly to that route (for example, `https://host/mcp`), while SSE clients (when [legacy SSE is enabled](xref:stateless#legacy-sse-transport)) should connect to `{route}/sse` (for example, `https://host/mcp/sse`). + +For containerized deployments of ASP.NET Core servers, see [Docker deployment](../deployment/docker.md). ### SSE transport (legacy) @@ -287,27 +293,29 @@ await using var client = await McpClient.CreateAsync(transport); SSE-specific configuration options: -| Property | Description | -|----------|-------------| -| `MaxReconnectionAttempts` | Maximum number of reconnection attempts on stream disconnect (default: 5) | -| `DefaultReconnectionInterval` | Wait time between reconnection attempts (default: 1 second) | +| Property | Description | Default | +|-------------------------------|--------------------------------------------------------------|----------| +| `MaxReconnectionAttempts` | Maximum number of reconnection attempts on stream disconnect | 5 | +| `DefaultReconnectionInterval` | Wait time between reconnection attempts | 1 second | #### 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. See [Request backpressure](xref:stateless#request-backpressure) for a detailed comparison and mitigations if you must use SSE. +**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 (the default). Set explicitly for forward compatibility. - options.Stateless = false; + // SSE requires stateful mode; opt in explicitly because stateless mode is the default. + options.SessionMode = HttpServerSessionMode.Stateful; #pragma warning disable MCP9004 // EnableLegacySse is obsolete // Enable legacy SSE endpoints for clients that don't support Streamable HTTP. @@ -325,14 +333,14 @@ app.MapMcp(); app.Run(); ``` -See [Sessions — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details on SSE session lifetime and configuration. +See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details on SSE session lifetime and configuration. ### Transport mode comparison | Feature | stdio | Streamable HTTP (stateless) | Streamable HTTP (stateful) | SSE (legacy, stateful) | |---------|-------|-----------------------------|----------------------------|--------------| | Process model | Child process | Remote HTTP | Remote HTTP | Remote HTTP | -| Direction | Bidirectional | Request-response | Bidirectional | Server→client stream + client→server POST | +| Direction | Bidirectional | Request-response | Bidirectional | Server→client stream + client→server `POST` | | Sessions | Implicit (one per process) | None — each request is independent | `Mcp-Session-Id` tracked in memory | Session ID via query string, tracked in memory | | Server-to-client requests | ✓ | ✗ (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458)) | ✓ | ✓ | | Unsolicited notifications | ✓ | ✗ | ✓ | ✓ | @@ -342,7 +350,7 @@ See [Sessions — Legacy SSE transport](xref:stateless#legacy-sse-transport) for | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | | Best for | Local tools, IDE integrations | Remote servers, production deployments | Local HTTP debugging, server-to-client features | Legacy client compatibility | -For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:stateless). +For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Stateless and Stateful](xref:stateless). ### In-memory transport @@ -377,13 +385,14 @@ var echo = tools.First(t => t.Name == "echo"); Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); ``` -Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:stateless) for how session behavior varies across transports. +Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. For information about how session behavior varies across transports, see [Stateless and Stateful](xref:stateless). -## Cross-Application Access +## Cross-application access The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. The flow consists of two steps: + 1. **RFC 8693 Token Exchange** at the enterprise IdP: OIDC ID token → JWT Authorization Grant (JAG) 2. **RFC 7523 JWT Bearer Grant** at the MCP authorization server: JAG → access token @@ -416,4 +425,4 @@ var tokens = await provider.GetAccessTokenAsync( // Call provider.InvalidateCache() to force a fresh token exchange on the next call. ``` -The provider caches the resulting access token and reuses it until it expires. To force re-authentication (e.g. after a 401 response), call `provider.InvalidateCache()` before retrying. +The provider caches the resulting access token and reuses it until it expires. To force re-authentication (for example, after a 401 response), call `provider.InvalidateCache()` before retrying. diff --git a/docs/experimental.md b/docs/experimental.md index 59a1d75..0704fb9 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -9,7 +9,7 @@ The Model Context Protocol C# SDK uses the [`[Experimental]`](https://learn.micr ## Suppressing experimental diagnostics -When you use an experimental API, the compiler produces a diagnostic (e.g., `MCPEXP001`) to ensure you're aware the API may change. If you want to use the API, suppress the diagnostic in one of these ways: +When you use an experimental API, the compiler produces a diagnostic (for example, `MCPEXP001`) to ensure you're aware the API might change. If you want to use the API, suppress the diagnostic in one of these ways: ### Project-wide suppression @@ -26,9 +26,9 @@ Add the diagnostic ID to `` in your project file: Use `#pragma warning disable` around specific call sites: ```csharp -#pragma warning disable MCPEXP001 // The Extensions feature is part of a future MCP specification version that has not yet been ratified and is subject to change. -capabilities.Extensions = new Dictionary { ... }; -#pragma warning restore MCPEXP001 +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental and may change. +options.RunSessionHandler = static (_, _, _) => Task.CompletedTask; +#pragma warning restore MCPEXP002 ``` For a full list of experimental diagnostic IDs and their descriptions, see the [list of diagnostics](list-of-diagnostics.md#experimental-apis). diff --git a/docs/index.md b/docs/index.md index 3e3d88c..107c28d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,7 @@ For how-to guides, tutorials, and additional guidance, see the [official MCP doc ## Official SDK packages -The official C# SDK packages for stable and prerelease versions are published to the [NuGet Gallery](https://www.nuget.org) under the [ModelContextProtocol](https://www.nuget.org/profiles/ModelContextProtocol) profile. _Prior to v0.5.0, packages were published using the [ModelContextProtocolOfficial](https://www.nuget.org/profiles/ModelContextProtocolOfficial) profile._ +The official C# SDK packages for stable and prerelease versions are published to the [NuGet Gallery](https://www.nuget.org) under the [ModelContextProtocol](https://www.nuget.org/profiles/ModelContextProtocol) profile. Continuous integration builds are published to the modelcontextprotocol organization's [GitHub NuGet package registry](https://github.com/orgs/modelcontextprotocol/packages?ecosystem=nuget). diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index a4a71dd..5773342 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -6,14 +6,14 @@ uid: list-of-diagnostics This document provides information about each of the diagnostics produced by the MCP C# SDK analyzers and source generators. -## Analyzer Diagnostics +## Analyzer diagnostics Analyzer diagnostic IDs are in the format `MCP###`. -| Diagnostic ID | Description | -| :------------ | :---------- | -| `MCP001` | Invalid XML documentation for MCP method | -| `MCP002` | MCP method must be partial to generate `[Description]` attributes | +| Diagnostic ID | Description | +|:--------------|:------------------------------------------------------------------| +| `MCP001` | Invalid XML documentation for MCP method | +| `MCP002` | MCP method must be partial to generate `[Description]` attributes | ## Experimental APIs @@ -23,12 +23,12 @@ As new functionality is introduced to this SDK, new in-development APIs are mark You may use experimental APIs in your application, but we advise against using these APIs in production scenarios as they may not be fully tested nor fully reliable. Additionally, we strongly recommend that library authors do not publish versions of their libraries that depend on experimental APIs as this will quite possibly lead to future breaking changes and diamond problems. -If you use experimental APIs, you will get one of the diagnostics shown below. The diagnostic is there to let you know you're using such an API so that you can avoid accidentally depending on experimental features. You may suppress these diagnostics if desired. +If you use experimental APIs, you will get one of the diagnostics shown below. The diagnostic is there to let you know you're using such an API so that you can avoid accidentally depending on experimental features. You can suppress these diagnostics if desired. | Diagnostic ID | Description | | :------------ | :---------- | | `MCPEXP001` | Experimental APIs tied to MCP specification features. Reuse this ID for newly introduced experimental spec features, and add feature-specific messages/URLs in `Experimentals`. | -| `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). | +| `MCPEXP002` | Experimental SDK extensibility APIs used to implement features in standalone packages without requiring Core to understand those features. For example, the Tasks package uses these APIs to extend the SDK while keeping the Tasks concept out of Core. This includes `McpClient`/`McpServer` subclassing, custom request handlers, alternate handlers and filters, outgoing request interception, and `RunSessionHandler`. These APIs remain experimental until additional extensibility scenarios validate the design. | | `MCPEXP003` | Experimental MCP Apps extension APIs. MCP Apps is the first official MCP extension (`io.modelcontextprotocol/ui`), enabling servers to deliver interactive UIs inside AI clients (see [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). | ## Obsolete APIs @@ -40,8 +40,9 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | Diagnostic ID | Status | Description | | :------------ | :----- | :---------- | | `MCP9001` | In place | The `EnumSchema` and `LegacyTitledEnumSchema` APIs are deprecated as of specification version 2025-11-25. Use the current schema APIs instead. | -| `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | +| `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (for example, `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | | `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 — 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 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. | +| `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 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/docs/roadmap.md b/docs/roadmap.md index 105a039..e012cc3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,20 +4,22 @@ author: jeffhandley description: ModelContextProtocol C# SDK roadmap and spec implementation tracking uid: roadmap --- -## Spec Implementation Tracking +## Spec implementation tracking -The C# SDK tracks implementation of MCP spec components using the [modelcontextprotocol project boards](https://github.com/orgs/modelcontextprotocol/projects?query=is%3Aopen), with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26). +The C# SDK tracks implementation of MCP spec components using the [modelcontextprotocol project boards](https://github.com/orgs/modelcontextprotocol/projects?query=is%3Aopen), with a dedicated project board for each spec revision. See the [2026-07-28 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/41/views/1). -## Current Focus Areas +## Current focus areas -### Next Spec Revision +### Next spec revision -The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). +The C# SDK's standing plan is to release support for each new MCP specification revision alongside the specification, as it did for the 2026-07-28 revision. Accepted SEPs are tracked on the revision's project board throughout development so implementation is ready when the revision is published. The next revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). -### Feedback and End-to-End Scenarios +### Feedback and end-to-end scenarios The C# SDK team is actively responding to feedback and continuing to explore end-to-end scenarios for opportunities to add more APIs that implement common patterns. +After the 2.0.0 stable release that aligns with the 2026-07-28 specification version, we will turn our focus to the collection of [`area-auth` issues](https://github.com/modelcontextprotocol/csharp-sdk/issues?q=is%3Aissue%20label%3Aarea-auth). End-to-end auth experiences and integration with various auth servers have stood out as a theme of feedback that we aim to address in minor versions within [the 2.x milestone](https://github.com/modelcontextprotocol/csharp-sdk/milestone/9). + ## Milestones See the [milestones page](https://github.com/modelcontextprotocol/csharp-sdk/milestones) to see which issues and features are being planned for future versions. diff --git a/docs/toc.yml b/docs/toc.yml index e09c4b5..953382b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -7,7 +7,5 @@ items: href: roadmap.md - name: Versioning href: versioning.md -- name: Experimental APIs - href: experimental.md - name: GitHub href: https://github.com/ModelContextProtocol/csharp-sdk diff --git a/docs/version-picker/version-picker.css b/docs/version-picker/version-picker.css new file mode 100644 index 0000000..3377ad9 --- /dev/null +++ b/docs/version-picker/version-picker.css @@ -0,0 +1,102 @@ +/* + * Version picker widget for the multi-version MCP C# SDK docs site. + * Injected into every generated page by scripts/inject-version-picker.mjs. + * Two placements: inline in the DocFX "modern" navbar, or a fixed floating + * fallback when the navbar cannot be found. + */ +.dv-picker { + --dv-fg: #24292f; + --dv-bg: #ffffff; + --dv-border: #d0d7de; + --dv-accent: #0969da; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + line-height: 1; + color: var(--dv-fg); +} + +.dv-picker__label { + opacity: 0.75; + white-space: nowrap; +} + +.dv-picker__select { + appearance: none; + -webkit-appearance: none; + background-color: var(--dv-bg); + color: var(--dv-fg); + border: 1px solid var(--dv-border); + border-radius: 6px; + padding: 0.25rem 1.55rem 0.25rem 0.55rem; + font: inherit; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23636c76'%3E%3Cpath d='M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.45rem center; + background-size: 0.72rem; +} + +.dv-picker__select:hover { + border-color: var(--dv-accent); +} + +.dv-picker__badge { + display: inline-block; + padding: 0.1rem 0.4rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #9a6700; + background: #fff8c5; + border: 1px solid #eac54f; +} + +.dv-prerelease-banner { + display: flex; + justify-content: center; + align-items: center; + gap: 0.35rem; + min-height: 2.5rem; + padding: 0.4rem 1rem; + color: #24292f; + background: #fff8c5; + border-bottom: 1px solid #eac54f; + font-size: 0.9rem; + text-align: center; +} + +/* Inline in the navbar (preferred placement). */ +.dv-picker--navbar { + margin-left: 0.75rem; +} + +/* Fixed fallback when no navbar brand was found. */ +.dv-picker--floating { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 2147483000; + padding: 0.45rem 0.6rem; + background: var(--dv-bg); + border: 1px solid var(--dv-border); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); +} + +/* DocFX's explicit Bootstrap theme controls the widget appearance. */ +[data-bs-theme="dark"] .dv-picker { + --dv-fg: #e6edf3; + --dv-bg: #161b22; + --dv-border: #30363d; + --dv-accent: #4493f8; +} + +[data-bs-theme="dark"] .dv-prerelease-banner { + color: #e6edf3; + background: #3d2e00; + border-color: #9a6700; +} diff --git a/docs/version-picker/version-picker.js b/docs/version-picker/version-picker.js new file mode 100644 index 0000000..6fbe1c4 --- /dev/null +++ b/docs/version-picker/version-picker.js @@ -0,0 +1,159 @@ +/* + * Version picker widget for the multi-version MCP C# SDK docs site. + * + * Configuration is provided per-page via a `window.__DOCS__` object that + * scripts/inject-version-picker.mjs writes into each page's : + * + * window.__DOCS__ = { + * version: "2.0", // slug of the version this page belongs to + * base: "/", // site base path ("/" for a custom domain) + * default: "1.x", // slug the site root redirects to + * versions: [ // newest first; drives the dropdown + * { slug: "2.0", label: "2.0 (preview)", prerelease: true }, + * { slug: "1.x", label: "1.x (latest)", prerelease: false } + * ] + * }; + * + * The pure helpers (joinPath / stripVersionPrefix / targetPath) are exported so + * they can be unit-tested in Node without a DOM. + */ + +export function joinPath(a, b) { + const left = String(a || "").replace(/\/+$/, ""); + const right = String(b == null ? "" : b).replace(/^\/+/, ""); + return (left + "/" + right).replace(/\/{2,}/g, "/"); +} + +// Given the current pathname, return the part after "//". +export function stripVersionPrefix(pathname, base, currentSlug) { + const prefix = joinPath(joinPath(base, currentSlug), ""); + if (pathname.startsWith(prefix)) return pathname.slice(prefix.length); + // Tolerate a missing trailing slash (e.g. "/2.0"). + const bare = joinPath(base, currentSlug); + if (pathname === bare) return ""; + return ""; +} + +// Derive the deployment base from the page URL. This keeps picker navigation +// within project Pages sites, whose content is hosted below "//". +export function getBasePath(cfg, pathname) { + const marker = `/${cfg.version}/`; + const markerIndex = pathname.indexOf(marker); + if (markerIndex !== -1) return pathname.slice(0, markerIndex + 1) || "/"; + + const bareMarker = `/${cfg.version}`; + if (pathname.endsWith(bareMarker)) { + return pathname.slice(0, -bareMarker.length + 1) || "/"; + } + + return cfg.base || "/"; +} + +// Compute the equivalent path in another version, preserving the sub-page. +export function targetPath(cfg, targetSlug, pathname) { + const base = getBasePath(cfg, pathname); + const sub = stripVersionPrefix(pathname, base, cfg.version); + return joinPath(joinPath(base, targetSlug), sub); +} + +function h(tag, attrs, children) { + const el = document.createElement(tag); + for (const k in attrs || {}) { + if (k === "class") el.className = attrs[k]; + else el.setAttribute(k, attrs[k]); + } + for (const c of children || []) { + el.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + } + return el; +} + +function versionRoot(cfg, slug, pathname) { + return joinPath(joinPath(getBasePath(cfg, pathname), slug), ""); +} + +async function navigate(cfg, slug) { + const candidate = targetPath(cfg, slug, location.pathname); + const home = versionRoot(cfg, slug, location.pathname); + // Best effort: keep the reader on the same page if it exists in the target + // version; otherwise fall back to that version's landing page. + try { + const res = await fetch(candidate, { method: "HEAD" }); + if (res.ok) { + location.assign(candidate + location.hash); + return; + } + } catch (_) { + /* HEAD may be blocked (e.g. file://) -- fall through to the version home. */ + } + location.assign(home); +} + +function previewBadge() { + return h("span", { class: "dv-picker__badge" }, ["preview"]); +} + +function buildPicker(cfg) { + const current = cfg.versions.find((v) => v.slug === cfg.version); + + const select = h("select", { + class: "dv-picker__select", + "aria-label": "Select documentation version", + }); + for (const v of cfg.versions) { + const opt = h("option", { value: v.slug }, [v.label || v.slug]); + if (v.slug === cfg.version) opt.selected = true; + select.appendChild(opt); + } + select.addEventListener("change", () => navigate(cfg, select.value)); + + const children = [h("span", { class: "dv-picker__label" }, ["Version"]), select]; + if (current && current.prerelease) { + children.push(previewBadge()); + } + return h("div", { class: "dv-picker" }, children); +} + +function buildPrereleaseBanner() { + return h("div", { + class: "dv-prerelease-banner", + role: "status", + }, [ + "You are currently viewing", + previewBadge(), + "documentation.", + ]); +} + +function mount(picker) { + const brand = document.querySelector(".navbar-brand"); + if (brand && brand.parentNode) { + picker.classList.add("dv-picker--navbar"); + brand.parentNode.insertBefore(picker, brand.nextSibling); + return; + } + picker.classList.add("dv-picker--floating"); + document.body.appendChild(picker); +} + +function init() { + const cfg = window.__DOCS__; + if (!cfg || !Array.isArray(cfg.versions) || cfg.versions.length < 2) return; + if (document.querySelector(".dv-picker, .dv-prerelease-banner")) return; // idempotent + + const current = cfg.versions.find((v) => v.slug === cfg.version); + if (!current) return; + if (current.prerelease) { + document.body.prepend(buildPrereleaseBanner()); + } + + mount(buildPicker(cfg)); +} + +if (typeof document !== "undefined" && typeof document.createElement === "function") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +} diff --git a/docs/versioning.md b/docs/versioning.md index 68f147a..b94b955 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -16,7 +16,7 @@ Given a version number MAJOR.MINOR.PATCH, the package versions increment the: ## Supported versions -Beginning with the 1.0.0 release, the following support policy will be applied for the official C# ModelContextProtocol SDK packages: +The following support policy applies to stable C# ModelContextProtocol SDK packages: 1. New functionality and additive APIs will be introduced in MINOR releases within the current MAJOR version only. * New functionality will not be added to an earlier MAJOR version. @@ -33,9 +33,21 @@ MAJOR or MINOR version updates might introduce or alter APIs annotated as [`[Exp Experimental APIs require suppression of diagnostic codes specific to the MCP SDK APIs, using an `MCP` prefix. +## MCP specification compatibility + +The 2.0.0 SDK implements the `2026-07-28` MCP specification revision while retaining compatibility with peers that negotiate [`2025-11-25`](https://modelcontextprotocol.io/specification/2025-11-25) and earlier. A v2 client automatically uses the legacy `initialize` handshake when it connects to a down-level server, and a v2 server continues to accept that handshake from a down-level client. Stable, non-deprecated 1.x APIs continue to work without modification on those connections. + +### Tasks exception + +For protocol-level compatibility, Tasks are the sole documented exception. The v2 +[`Tasks`](xref:tasks) extension replaces the experimental Tasks implementation from v1.3.0 and +v1.4.x and is available only after negotiating `2026-07-28` or later. It has no API or wire +compatibility with the down-level implementation: a v2 Tasks client or server does not use +`tasks/*` on a `2025-11-25` connection. + ## Breaking changes -Prior to the release of a stable 1.0.0 set of NuGet packages, the SDK remains in preview and breaking changes can be introduced without prior notice. Thereafter, the SDK follows Semantic Versioning and breaking changes against stable releases require increments to the MAJOR version. +The 2.0.0 SDK will be a stable release after its general availability release. The SDK follows Semantic Versioning, and breaking changes against stable releases require increments to the MAJOR version. If feasible, the SDK will support all versions of the MCP spec. However, if breaking changes to the spec make this infeasible, preference will be given to the most recent version of the MCP spec. This would be considered a breaking change necessitating a new MAJOR version. @@ -59,3 +71,8 @@ If APIs within the SDK become obsolete due to changes in the MCP spec or other e 3. Within a MAJOR version update, obsolete APIs might be removed. API removals are expected to be rare and avoided wherever possible, and `[Obsolete]` attributes will be applied ahead of the API removal. Beginning with the 1.0.0 release, all obsoletions will use diagnostic codes specific to the MCP SDK APIs, using an `MCP` prefix. + +## See also + +- [Experimental APIs](experimental.md) +- [List of diagnostics](list-of-diagnostics.md) diff --git a/global.json b/global.json index fcb4599..9e13a61 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100", + "version": "10.0.101", "rollForward": "minor" } } diff --git a/package-lock.json b/package-lock.json index fc400cc..774becc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,27 +5,27 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.9", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } }, "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" } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.5.tgz", - "integrity": "sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==", + "version": "0.2.0-alpha.9", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.9.tgz", + "integrity": "sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -52,12 +52,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -342,20 +342,20 @@ "license": "Apache-2.0" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -365,6 +365,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -694,9 +707,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -831,9 +844,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -888,9 +901,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -1146,9 +1159,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1414,23 +1427,40 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 3dbb9fb..577d3e1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.9", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs index 983d296..68c37e1 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 @@ builder.Services.AddMcpServer() .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 e0d9680..3d37568 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/AspNetCoreMcpServer.csproj b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj index 29aebc0..ca64119 100644 --- a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj +++ b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj @@ -15,6 +15,7 @@ + diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index f35d0ef..a69f499 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,3 +1,5 @@ +using Azure.Monitor.OpenTelemetry.AspNetCore; +using ModelContextProtocol.AspNetCore; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -25,24 +27,36 @@ builder.Services.AddCors(options => // 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() .WithResources(); -builder.Services.AddOpenTelemetry() +var telemetry = builder.Services.AddOpenTelemetry() .WithTracing(b => b.AddSource("*") .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) .WithMetrics(b => b.AddMeter("*") .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) - .WithLogging() - .UseOtlpExporter(); + .WithLogging(); + +string? applicationInsightsConnectionString = + builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + +if (string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) +{ + telemetry.UseOtlpExporter(); +} +else +{ + telemetry.UseAzureMonitor(options => + options.ConnectionString = applicationInsightsConnectionString); +} // Configure HttpClientFactory for weather.gov API builder.Services.AddHttpClient("WeatherApi", client => diff --git a/samples/AspNetCoreMcpServer/README.md b/samples/AspNetCoreMcpServer/README.md new file mode 100644 index 0000000..9ec124d --- /dev/null +++ b/samples/AspNetCoreMcpServer/README.md @@ -0,0 +1,48 @@ +# ASP.NET Core MCP server sample + +This sample hosts an MCP server with Streamable HTTP and configures OpenTelemetry for MCP operations, ASP.NET Core requests, outgoing HTTP requests, metrics, and structured logs. + +## Export to Application Insights + +Set the Application Insights connection string and run the sample: + +```bash +export APPLICATIONINSIGHTS_CONNECTION_STRING='InstrumentationKey=;IngestionEndpoint=https://.in.applicationinsights.azure.com/' +dotnet run --project samples/AspNetCoreMcpServer +``` + +PowerShell: + +```powershell +$env:APPLICATIONINSIGHTS_CONNECTION_STRING = 'InstrumentationKey=;IngestionEndpoint=https://.in.applicationinsights.azure.com/' +dotnet run --project samples/AspNetCoreMcpServer +``` + +The connection string is read through ASP.NET Core configuration. Keep it outside source control and use your deployment platform's secret configuration in production. + +When `APPLICATIONINSIGHTS_CONNECTION_STRING` is absent or empty, the sample retains its default OTLP exporter. Configure that path with standard `OTEL_EXPORTER_OTLP_*` environment variables. + +## Signals and MCP attributes + +Application Insights receives the signals already emitted by the SDK and the configured ASP.NET Core/OpenTelemetry instrumentation: + +- server activities appear as requests and client activities appear as dependencies; +- structured logs appear as traces; +- MCP histograms appear as custom metrics; +- HTTP server and client instrumentation provides the surrounding transport spans. + +MCP activities can include attributes such as `mcp.method.name`, `mcp.protocol.version`, `mcp.session.id`, `jsonrpc.request.id`, and `gen_ai.tool.name`. The exact attributes depend on the operation and whether sensitive-data telemetry is enabled. + +For example, query MCP request and dependency telemetry in Logs: + +```kusto +union withsource=TelemetryType requests, dependencies +| extend McpMethod = tostring(customDimensions["mcp.method.name"]), + ToolName = tostring(customDimensions["gen_ai.tool.name"]), + McpSession = tostring(customDimensions["mcp.session.id"]) +| where isnotempty(McpMethod) +| project timestamp, TelemetryType, name, success, resultCode, McpMethod, ToolName, McpSession +| order by timestamp desc +``` + +Invalid tool calls, malformed JSON-RPC messages, and other failures appear only when the current SDK or ASP.NET Core instrumentation emits a log or activity for that path. This sample exports existing telemetry; it does not add custom protocol events or per-tool instrumentation. diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index f8c9752..abccbac 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -3,6 +3,7 @@ using EverythingServer.Prompts; 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 @@ builder.Services .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/InMemoryTransport/README.md b/samples/InMemoryTransport/README.md new file mode 100644 index 0000000..e350144 --- /dev/null +++ b/samples/InMemoryTransport/README.md @@ -0,0 +1,42 @@ +# In-Memory Transport Sample + +Demonstrates connecting an MCP client and server in the same process using stream-based +transports over in-memory pipes (`System.IO.Pipelines`) — no child process, no network. + +The server is created directly with `McpServer.Create`, without a host or dependency +injection container, and exposes a single `Echo` tool defined from a delegate with +`McpServerTool.Create`. The client connects over the same pipe pair with +`StreamClientTransport`, lists the server's tools, and invokes the tool. + +This pattern is useful for testing MCP servers, embedding a server inside a larger +application, or running a client and server in the same process without transport overhead. +See [Transports: In-memory transport](../../docs/concepts/transports/transports.md) for more +background. + +## Run + +```bash +dotnet run --project samples/InMemoryTransport/InMemoryTransport.csproj +``` + +Expected output: + +``` +Tool Name: Echo + +Echo: Hello World +``` + +## Key files + +- [`Program.cs`](Program.cs) — the entire sample: pipe setup, server creation, client + connection, tool listing, and tool invocation. + +## Notes + +- `StreamServerTransport` and `StreamClientTransport` work with any `Stream`. This sample + wires them to a pair of `Pipe` instances, one per direction; the client's output stream is + the server's input stream and vice versa. +- The server is started with a fire-and-forget `server.RunAsync()` because both endpoints + live in the same process. `await using` on the server and client ensures both are disposed + when the program exits. diff --git a/samples/ProtectedMcpClient/Program.cs b/samples/ProtectedMcpClient/Program.cs index eba7925..6cda8e7 100644 --- a/samples/ProtectedMcpClient/Program.cs +++ b/samples/ProtectedMcpClient/Program.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Diagnostics; @@ -32,7 +33,7 @@ var transport = new HttpClientTransport(new() OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "ProtectedMcpClient", @@ -68,12 +69,16 @@ if (tools.Any(t => t.Name == "get_alerts")) /// Handles the OAuth authorization URL by starting a local HTTP server and opening a browser. /// This implementation demonstrates how SDK consumers can provide their own authorization flow. /// -/// The authorization URL to open in the browser. -/// The redirect URI where the authorization code will be sent. +/// The context containing the authorization and redirect URIs. /// The to monitor for cancellation requests. The default is . -/// The authorization code extracted from the callback, or null if the operation failed. -static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +/// The authorization result extracted from the callback, or null if the operation failed. +static async Task HandleAuthorizationUrlAsync( + AuthorizationCallbackContext authorizationContext, + CancellationToken cancellationToken) { + var authorizationUrl = authorizationContext.AuthorizationUri; + var redirectUri = authorizationContext.RedirectUri; + Console.WriteLine("Starting OAuth authorization flow..."); Console.WriteLine($"Opening browser to: {authorizationUrl}"); @@ -93,6 +98,8 @@ static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri var context = await listener.GetContextAsync(); var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); var code = query["code"]; + var state = query["state"]; + var iss = query["iss"]; var error = query["error"]; string responseHtml = "

Authentication complete

You can close this window now.

"; @@ -115,7 +122,7 @@ static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri } Console.WriteLine("Authorization code received successfully."); - return code; + return new AuthorizationResult { Code = code, State = state, Iss = iss }; } catch (Exception ex) { diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index f539e73..59ad352 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; 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 @@ builder.Services.AddMcpServer() .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/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index fff3533..7823989 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -1,18 +1,17 @@ // Demonstrates the MCP tasks extension (SEP-2663): // -// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation +// - A server is configured with .WithTasks(store) so that any [McpServerTool] invocation // becomes a background task when the client opts in via the per-request _meta marker. -// - The client invokes the same tool two ways: -// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final -// CallToolResult, just like a synchronous call. -// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls, -// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream -// status updates rather than block on a single await. +// - The client invokes the tool and manually drives the lifecycle via GetTaskAsync. // // Both server and client are wired together in-process over an in-memory pipe so the sample // is self-contained — no separate server process or HTTP transport required. +#pragma warning disable MCPEXP001, MCPEXP002, MCPEXP004 + +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; @@ -21,28 +20,25 @@ using System.Text.Json; Pipe clientToServerPipe = new(), serverToClientPipe = new(); -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions - { - // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be - // automatically wrapped as background tasks when the client opts in. - TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }, - ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], - }); +var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }; + +var services = new ServiceCollection(); +services.AddMcpServer() + .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) + .WithTools([McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })]) + .WithTasks(store); + +await using var serviceProvider = services.BuildServiceProvider(); +var server = serviceProvider.GetRequiredService(); _ = server.RunAsync(); await using McpClient client = await McpClient.CreateAsync( - new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); + new StreamClientTransport( + serverInput: clientToServerPipe.Writer.AsStream(), + serverOutput: serverToClientPipe.Reader.AsStream())); -Console.WriteLine("=== CallToolAsync (auto-poll) ==="); -var auto = await client.CallToolAsync( - new CallToolRequestParams { Name = "run-report" }); -Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}"); -Console.WriteLine(); - -Console.WriteLine("=== CallToolRawAsync (manual poll) ==="); -var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" }); +Console.WriteLine("=== CallToolAsTaskAsync (manual poll) ==="); +var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" }); if (!raw.IsTask) { // Either the server doesn't advertise the tasks extension or it chose to run the call @@ -88,9 +84,6 @@ while (true) continue; case InputRequiredTaskResult inputRequired: - // The auto-poll path (CallToolAsync above) routes these through the registered - // ElicitationHandler/SamplingHandler automatically. The manual path needs to call - // UpdateTaskAsync with responses for each outstanding key. Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))"); continue; } diff --git a/samples/TasksExtension/README.md b/samples/TasksExtension/README.md index f325061..accafa5 100644 --- a/samples/TasksExtension/README.md +++ b/samples/TasksExtension/README.md @@ -6,16 +6,12 @@ The server is configured with an in-memory `IMcpTaskStore`, which is sufficient `[McpServerTool]` method automatically run as a background task when the client opts into the tasks extension on a per-request basis. -The client invokes the same `run-report` tool two ways: - -1. **`CallToolAsync` (auto-poll)** — the SDK injects the opt-in marker into `_meta`, polls - `tasks/get` at the cadence the server suggests, dispatches any input requests through - registered handlers, and returns the final `CallToolResult` (or throws on a terminal - `Failed`/`Cancelled`). -2. **`CallToolRawAsync` (manual poll)** — the caller receives the raw - `ResultOrCreatedTask` and drives the lifecycle directly with - `GetTaskAsync`, `UpdateTaskAsync`, and `CancelTaskAsync`. Useful when you need to surface - progress to a UI or stream task status updates between polls. +The client invokes the `run-report` tool with **`CallToolAsTaskAsync` (manual poll)**. It +receives a `ResultOrCreatedTask` and, when the server runs the call as a +background task, drives the lifecycle directly with `GetTaskAsync` — polling at the server's +suggested cadence until the task reaches a terminal state (`Completed`, `Failed`, or +`Cancelled`). If the server returns an inline result instead of creating a task, the sample +surfaces that result and stops. Both ends of the conversation are connected in-process over an in-memory `Pipe`, so no separate server process or HTTP transport is required. @@ -29,10 +25,7 @@ dotnet run --project samples/TasksExtension/TasksExtension.csproj Expected output: ``` -=== CallToolAsync (auto-poll) === - result: report ready - -=== CallToolRawAsync (manual poll) === +=== CallToolAsTaskAsync (manual poll) === task created: id=… status=Working pollIntervalMs=250 poll 1: still working … … @@ -41,10 +34,6 @@ Expected output: ## Notes -- The `MCPEXP001` warning is suppressed because the tasks extension is still experimental. The - project's `` already includes it; if you copy this pattern into your own project, - either suppress the diagnostic or wrap the experimental APIs in - `#pragma warning disable MCPEXP001`. - For production deployments — especially stateless HTTP servers — implement `IMcpTaskStore` against durable storage and register it as a singleton (see [docs/concepts/tasks/tasks.md](../../docs/concepts/tasks/tasks.md) for the contract). diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj index 2f66bad..fe01a84 100644 --- a/samples/TasksExtension/TasksExtension.csproj +++ b/samples/TasksExtension/TasksExtension.csproj @@ -10,6 +10,8 @@ + +
diff --git a/scripts/finalize-docs-site.mjs b/scripts/finalize-docs-site.mjs new file mode 100644 index 0000000..d054952 --- /dev/null +++ b/scripts/finalize-docs-site.mjs @@ -0,0 +1,138 @@ +// Finalize the combined multi-version docs site: +// * copy the picker assets to /assets/ +// * publish a cleaned docs-versions.json at the site root +// * write a root index.html that redirects to the default version +// * generate unversioned redirect pages for the deployed v1 documentation +// +// Usage: +// node scripts/finalize-docs-site.mjs [--versions ] + +import { readFile, writeFile, mkdir, copyFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { manifestPath } from "./manifest-path.mjs"; + +function parseArgs(argv) { + const positional = []; + const opts = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) opts[a.slice(2)] = argv[++i]; + else positional.push(a); + } + return { positional, opts }; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) + ); +} + +async function* htmlFiles(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* htmlFiles(full); + else if (entry.isFile() && /\.html?$/i.test(entry.name)) yield full; + } +} + +function redirectPage(target, title) { + return ` + + + +${escapeHtml(title)} + + + + + +

Redirecting to ${escapeHtml(title)}…

+ + +`; +} + +async function generateV1Redirects(combinedDir) { + const v1Dir = path.join(combinedDir, "v1"); + const redirects = new Map(); + + for await (const sourceFile of htmlFiles(v1Dir)) { + const relativePath = path.relative(v1Dir, sourceFile).split(path.sep).join("/"); + if (relativePath === "index.html") continue; + + redirects.set(relativePath, `v1/${relativePath}`); + } + + const publicMap = {}; + const redirectEntries = [...redirects].sort(([left], [right]) => left.localeCompare(right)); + for (const [sourcePath, targetPath] of redirectEntries) { + const destination = path.join(combinedDir, sourcePath); + const relativeTarget = path.relative(path.dirname(destination), path.join(combinedDir, targetPath)) + .split(path.sep) + .join("/"); + + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, redirectPage(relativeTarget, "MCP C# SDK documentation")); + publicMap[`/${sourcePath}`] = `/${targetPath}`; + } + + await writeFile( + path.join(combinedDir, "v1-redirects.json"), + JSON.stringify(publicMap, null, 2) + "\n" + ); + + return redirects.size; +} + +async function main() { + const { positional, opts } = parseArgs(process.argv.slice(2)); + const combinedDir = positional[0]; + if (!combinedDir) { + console.error("usage: finalize-docs-site.mjs [--versions ]"); + process.exit(2); + } + + const pickerDir = new URL("../docs/version-picker/", import.meta.url); + const versionsPath = opts.versions + ? path.resolve(opts.versions) + : manifestPath; + const manifest = JSON.parse(await readFile(versionsPath, "utf8")); + + // 1. Copy picker assets. + const assetsDir = path.join(combinedDir, "assets"); + await mkdir(assetsDir, { recursive: true }); + for (const name of ["version-picker.js", "version-picker.css"]) { + await copyFile(new URL(name, pickerDir), path.join(assetsDir, name)); + } + + // 2. Public docs-versions.json (drop build-only fields like `ref` and `$comment`). + const publicManifest = { + default: manifest.default, + versions: manifest.versions.map((v) => ({ + slug: v.slug, + label: v.label, + prerelease: !!v.prerelease, + })), + }; + await writeFile( + path.join(combinedDir, "docs-versions.json"), + JSON.stringify(publicManifest, null, 2) + "\n" + ); + + // 3. Root redirect to the default version (relative -> works under any base). + const def = manifest.default; + const target = `./${def}/`; + await writeFile( + path.join(combinedDir, "index.html"), + redirectPage(target, "MCP C# SDK documentation") + ); + + const redirectCount = await generateV1Redirects(combinedDir); + console.log(`[finalize] assets + docs-versions.json written; root redirects to ${target}; ${redirectCount} v1 redirects written`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/get-docs-version-slug.mjs b/scripts/get-docs-version-slug.mjs new file mode 100644 index 0000000..14aeebd --- /dev/null +++ b/scripts/get-docs-version-slug.mjs @@ -0,0 +1,21 @@ +// Print the docs URL slug for a source tree's VersionPrefix. +// +// Usage: +// node scripts/get-docs-version-slug.mjs + +import { readFile } from "node:fs/promises"; + +const propsPath = process.argv[2]; +if (!propsPath) { + console.error("usage: get-docs-version-slug.mjs "); + process.exit(2); +} + +const props = await readFile(propsPath, "utf8"); +const match = props.match(/\s*([1-9][0-9]*)\.\d+\.\d+\s*<\/VersionPrefix>/); +if (!match) { + console.error(`error: unable to determine VersionPrefix from ${propsPath}`); + process.exit(1); +} + +process.stdout.write(`v${match[1]}\n`); diff --git a/scripts/inject-version-picker.mjs b/scripts/inject-version-picker.mjs new file mode 100644 index 0000000..23623f1 --- /dev/null +++ b/scripts/inject-version-picker.mjs @@ -0,0 +1,116 @@ +// Inject the version-picker widget into every .html page of a built docs site. +// +// Usage: +// node scripts/inject-version-picker.mjs [--base /] [--versions ] +// +// is a single version's built output (e.g. combined/2.0). +// is that version's slug (must match an entry in docs-versions.json). +// The widget config is derived from docs-versions.json and written into each page's +// . The picker assets are referenced relative to each generated page so they +// work from both a custom domain and a project Pages subpath. + +import { readFile, writeFile, readdir } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { manifestPath } from "./manifest-path.mjs"; + +const MARKER = ""; + +function parseArgs(argv) { + const positional = []; + const opts = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) opts[a.slice(2)] = argv[++i]; + else positional.push(a); + } + return { positional, opts }; +} + +function normalizeBase(base) { + let b = (base || "/").trim(); + if (!b.startsWith("/")) b = "/" + b; + if (!b.endsWith("/")) b += "/"; + return b.replace(/\/{2,}/g, "/"); +} + +async function* htmlFiles(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* htmlFiles(full); + else if (entry.isFile() && /\.html?$/i.test(entry.name)) yield full; + } +} + +async function assetRevision(name) { + const contents = await readFile(new URL(`../docs/version-picker/${name}`, import.meta.url)); + return createHash("sha256").update(contents).digest("hex").slice(0, 12); +} + +async function main() { + const { positional, opts } = parseArgs(process.argv.slice(2)); + const [siteDir, slug] = positional; + if (!siteDir || !slug) { + console.error("usage: inject-version-picker.mjs [--base /] [--versions ]"); + process.exit(2); + } + + const base = normalizeBase(opts.base); + const versionsPath = opts.versions + ? path.resolve(opts.versions) + : manifestPath; + const manifest = JSON.parse(await readFile(versionsPath, "utf8")); + + if (!manifest.versions.some((v) => v.slug === slug)) { + console.error(`error: slug "${slug}" is not present in docs-versions.json`); + process.exit(1); + } + + const config = { + version: slug, + base, + default: manifest.default, + versions: manifest.versions.map((v) => ({ + slug: v.slug, + label: v.label, + prerelease: !!v.prerelease, + })), + }; + + const json = JSON.stringify(config).replace(//i); + if (idx === -1) { + skipped++; + continue; + } + const assetBase = path.relative(path.dirname(file), assetsDir).split(path.sep).join("/") + "/"; + const snippet = + `\n${MARKER}\n` + + `\n` + + `\n` + + `\n`; + await writeFile(file, html.slice(0, idx) + snippet + html.slice(idx)); + injected++; + } + + console.log(`[inject] ${slug}: injected into ${injected} page(s), skipped ${skipped}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/list-versions.mjs b/scripts/list-versions.mjs new file mode 100644 index 0000000..8c1ccfb --- /dev/null +++ b/scripts/list-versions.mjs @@ -0,0 +1,17 @@ +// Print the versions to build, one per line, as "\t". +// Consumed by the multi-version docs workflow to drive its build loop. +// +// Usage: node scripts/list-versions.mjs [--versions ] + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { manifestPath } from "./manifest-path.mjs"; + +const arg = process.argv.slice(2); +const i = arg.indexOf("--versions"); +const versionsPath = i !== -1 ? path.resolve(arg[i + 1]) : manifestPath; + +const manifest = JSON.parse(await readFile(versionsPath, "utf8")); +for (const v of manifest.versions) { + process.stdout.write(`${v.slug}\t${v.ref}\n`); +} diff --git a/scripts/manifest-path.mjs b/scripts/manifest-path.mjs new file mode 100644 index 0000000..485509f --- /dev/null +++ b/scripts/manifest-path.mjs @@ -0,0 +1,16 @@ +// Location of the docs-versions manifest. +// +// This manifest is a build-time artifact produced during docs publishing, which +// only ever runs in CI -- never on a developer's machine. It therefore lives in +// the runner's temporary directory (RUNNER_TEMP on GitHub Actions, falling back +// to the OS temp dir for local testing) so it never touches the repository tree +// and needs no .gitignore entry. Every script agrees on this path, so the +// workflow does not need to thread it between steps. + +import os from "node:os"; +import path from "node:path"; + +export const manifestPath = path.join( + process.env.RUNNER_TEMP || os.tmpdir(), + "docs-versions.json" +); diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index 1af5ce5..e61f841 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -13,9 +13,10 @@ namespace ModelContextProtocol; /// These APIs may change as the specification evolves. /// /// -/// MCPEXP002 covers experimental SDK APIs that are unrelated to the MCP specification, -/// such as subclassing internal types or SDK-specific extensibility hooks. These APIs may -/// change or be removed based on SDK design feedback. +/// MCPEXP002 covers SDK extensibility APIs that enable features to be implemented +/// in standalone packages without requiring Core to understand those features. The Tasks +/// package is one such consumer. These APIs remain experimental until additional +/// extensibility scenarios validate the design. /// /// /// @@ -65,31 +66,31 @@ internal static class Experimentals public const string Apps_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp003"; /// - /// Diagnostic ID for experimental SDK APIs unrelated to the MCP specification, - /// such as subclassing McpClient/McpServer or referencing RunSessionHandler. + /// Diagnostic ID for SDK extensibility APIs that enable independently packaged features, + /// such as Tasks, without requiring Core awareness of those features. /// /// /// This diagnostic ID covers experimental SDK-level extensibility APIs. All constants /// in this group share the same diagnostic ID so users need only one suppression point /// for SDK design preview features. /// - public const string Subclassing_DiagnosticId = "MCPEXP002"; + public const string Extensibility_DiagnosticId = "MCPEXP002"; /// - /// Message for experimental subclassing of McpClient and McpServer. + /// Message for experimental extensibility points in the C# SDK implementation. /// - public const string Subclassing_Message = "Subclassing McpClient and McpServer is experimental and subject to change."; + public const string Extensibility_Message = "This C# SDK extensibility API is experimental and subject to change."; /// - /// URL for experimental subclassing of McpClient and McpServer. + /// URL for experimental extensibility points in the C# SDK implementation. /// - public const string Subclassing_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; + public const string Extensibility_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; /// /// Diagnostic ID for the experimental RunSessionHandler API. /// /// - /// This uses the same diagnostic ID as because + /// This uses the same diagnostic ID as because /// both are experimental SDK APIs unrelated to the MCP specification. /// public const string RunSessionHandler_DiagnosticId = "MCPEXP002"; diff --git a/src/Common/HttpResponseMessageExtensions.cs b/src/Common/HttpResponseMessageExtensions.cs index 05ef092..d155e84 100644 --- a/src/Common/HttpResponseMessageExtensions.cs +++ b/src/Common/HttpResponseMessageExtensions.cs @@ -23,27 +23,39 @@ internal static class HttpResponseMessageExtensions { if (!response.IsSuccessStatusCode) { - string? responseBody = null; - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); - - if (responseBody.Length > MaxResponseBodyLength) - { - responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "..."; - } - } - catch - { - // Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it. - } - - throw CreateHttpRequestException(response, responseBody); + throw await CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); } } + /// + /// Creates an for a non-success response, making a best-effort attempt to + /// include the server's response body in the exception message so the diagnostic isn't lost. + /// + /// The non-success HTTP response message. + /// The token to monitor for cancellation requests. + /// An with the response status and, when available, its body. + public static async Task CreateHttpRequestExceptionWithBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + string? responseBody = null; + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + + if (responseBody.Length > MaxResponseBodyLength) + { + responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "..."; + } + } + catch + { + // Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it. + } + + return CreateHttpRequestException(response, responseBody); + } + /// /// Creates an for a non-success response, including the response body in the message. /// @@ -57,10 +69,52 @@ internal static class HttpResponseMessageExtensions ? $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase})." : $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase}). Response body: {responseBody}"; + return HttpRequestExceptionExtensions.Create(message, innerException: null, response.StatusCode); + } +} + +/// +/// Helpers for preserving HTTP status codes on across all target frameworks. +/// +internal static class HttpRequestExceptionExtensions +{ + internal const string StatusCodeDataKey = "ModelContextProtocol.HttpStatusCode"; + + /// + /// Creates an and preserves its HTTP status code. + /// + public static HttpRequestException Create(string message, Exception? innerException, HttpStatusCode? statusCode) + { #if NET - return new HttpRequestException(message, inner: null, response.StatusCode); + var exception = new HttpRequestException(message, innerException, statusCode); #else - return new HttpRequestException(message); + var exception = new HttpRequestException(message, innerException); #endif + + if (statusCode is not null) + { + exception.Data[StatusCodeDataKey] = statusCode.Value; + } + + return exception; + } + + /// + /// Gets the preserved HTTP status code from an . + /// + public static HttpStatusCode? GetStatusCode(this HttpRequestException exception) + { +#if NET + if (exception.StatusCode is { } statusCode) + { + return statusCode; + } +#endif + + return exception.Data[StatusCodeDataKey] switch + { + HttpStatusCode storedStatusCode => storedStatusCode, + _ => null, + }; } } diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 31800e2..c08326e 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -9,25 +9,6 @@ namespace ModelContextProtocol.Protocol; /// internal static class McpHttpHeaders { - /// - /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize - /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled - /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) - /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request - /// version against it (see ), so it underpins the more - /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it - /// by default. - /// - public const string July2026ProtocolVersion = "2026-07-28"; - - /// - /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP - /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. - /// It is the default version for the legacy initialize and session-resume code paths, and the - /// version the server advertises when a peer requests an unsupported version on the legacy handshake. - /// - public const string November2025ProtocolVersion = "2025-11-25"; - /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -71,27 +52,4 @@ internal static class McpHttpHeaders /// internal const string ToolContextKey = "Mcp.Tool"; - /// - /// Returns if the given protocol version is - /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. - /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. - /// - internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) - => !string.IsNullOrEmpty(protocolVersion) - && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; - - /// - /// Returns if the given protocol version requires standard MCP request headers - /// (Mcp-Method, Mcp-Name). - /// - public static bool SupportsStandardHeaders(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); - - /// - /// Returns if the negotiated protocol version reports unresolvable - /// resource URIs with the standard JSON-RPC (-32602) - /// rather than the legacy (-32002). - /// - internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); } diff --git a/src/Common/McpProtocolVersions.cs b/src/Common/McpProtocolVersions.cs new file mode 100644 index 0000000..09b1f5b --- /dev/null +++ b/src/Common/McpProtocolVersions.cs @@ -0,0 +1,113 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Internal helpers for MCP protocol revision strings and protocol-era behavior gates. +/// +internal static class McpProtocolVersions +{ + /// + /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize + /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled + /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) + /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request + /// version against it (see ), so it underpins the more + /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it + /// by default. + /// + public const string July2026ProtocolVersion = "2026-07-28"; + + /// + /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP + /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. + /// It is the default version for the initialize and session-resume code paths, and the version + /// the server advertises when a peer requests an unsupported version on the initialize handshake. + /// + public const string November2025ProtocolVersion = "2025-11-25"; + + /// The 2025-06-18 MCP protocol revision. + public const string June2025ProtocolVersion = "2025-06-18"; + + /// The 2025-03-26 MCP protocol revision. + public const string March2025ProtocolVersion = "2025-03-26"; + + /// The 2024-11-05 MCP protocol revision. + public const string November2024ProtocolVersion = "2024-11-05"; + + /// + /// Protocol versions that still use the initialize handshake. + /// + internal static readonly string[] InitializeHandshakeProtocolVersions = + [ + November2024ProtocolVersion, + March2025ProtocolVersion, + June2025ProtocolVersion, + November2025ProtocolVersion, + ]; + + /// + /// Protocol versions that use per-request metadata instead of the initialize handshake. + /// + internal static readonly string[] PerRequestMetadataProtocolVersions = + [ + July2026ProtocolVersion, + ]; + + /// + /// All protocol versions supported by this implementation. + /// + internal static readonly string[] SupportedProtocolVersions = + [ + .. InitializeHandshakeProtocolVersions, + .. PerRequestMetadataProtocolVersions, + ]; + + /// + /// Returns if the given protocol version is + /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. + /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. + /// + internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) + => !string.IsNullOrEmpty(protocolVersion) + && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; + + /// + /// Returns if the given protocol version is supported by this implementation. + /// + internal static bool IsSupportedProtocolVersion(string? protocolVersion) + => protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version is available through the + /// initialize handshake. + /// + internal static bool SupportsInitializeHandshake(string? protocolVersion) + => protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version requires the handshake-free + /// per-request metadata path. + /// + internal static bool RequiresPerRequestMetadata(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); + + /// + /// Returns if the given protocol version requires standard MCP request headers + /// (Mcp-Method, Mcp-Name). + /// + internal static bool RequiresStandardHeaders(string? protocolVersion) + => RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the given protocol version supports Streamable HTTP sessions. + /// + internal static bool SupportsHttpSessions(string? protocolVersion) + => !RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the negotiated protocol version reports unresolvable + /// resource URIs with the standard JSON-RPC (-32602) + /// rather than the legacy (-32002). + /// + internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); +} diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 542fddb..217ff97 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -44,6 +44,10 @@ 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"; + public const string AuthorizationRedirectDelegate_Message = "AuthorizationRedirectDelegate cannot provide the RFC 9207 issuer and is retained for compatibility only. Use AuthorizationCallbackHandler instead."; + public const string AuthorizationRedirectDelegate_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5e1e495..2b120c4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,8 +5,8 @@ https://csharp.sdk.modelcontextprotocol.io https://github.com/modelcontextprotocol/csharp-sdk git - 2.0.0 - preview.2 + 2.2.0 + ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm @@ -18,7 +18,7 @@ $(RepoRoot)\Open.snk true true - 1.3.0 + 2.0.0
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..0c3c509 --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,15 @@ + + + + + + <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)"> + [%(_ProjectReferencesWithVersions.ProjectVersion)] + + + + diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationCallToolFilterGuardSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationCallToolFilterGuardSetup.cs new file mode 100644 index 0000000..8f41a5b --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationCallToolFilterGuardSetup.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore; + +internal sealed class AuthorizationFiltersMarker; + +internal sealed class AuthorizationCallToolFilterGuardSetup(AuthorizationFiltersMarker? marker = null) : IPostConfigureOptions +{ + public void PostConfigure(string? name, McpServerOptions options) + { + if (marker is not null) + { + return; + } + +#pragma warning disable MCPEXP002 // The guard must run before Tasks can dispatch the request. + options.Filters.Request.CallToolWithAlternateFilters.Insert(0, static async (context, next, cancellationToken) => + { + if (AuthorizationFilterSetup.HasAuthorizationMetadata(context.MatchedPrimitive)) + { + throw new InvalidOperationException( + "Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. " + + "Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters."); + } + + return await next(context, cancellationToken); + }); +#pragma warning restore MCPEXP002 + } +} diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index 3f58707..6290dd5 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -11,14 +11,16 @@ namespace ModelContextProtocol.AspNetCore; /// /// Evaluates authorization policies from endpoint metadata. /// -internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? policyProvider = null) : IConfigureOptions, IPostConfigureOptions +internal sealed class AuthorizationFilterSetup( + IAuthorizationPolicyProvider? policyProvider = null, + AuthorizationFiltersMarker? marker = null) : IConfigureOptions, IPostConfigureOptions { private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked"; public void Configure(McpServerOptions options) { ConfigureListToolsFilter(options); - ConfigureCallToolFilter(options); + ConfigureOrdinaryCallToolFilter(options); ConfigureListResourcesFilter(options); ConfigureListResourceTemplatesFilter(options); @@ -30,8 +32,13 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol public void PostConfigure(string? name, McpServerOptions options) { + // Add tool authorization after all regular configuration so it always wraps Tasks. + if (marker is not null) + { + ConfigureCallToolFilter(options); + } + CheckListToolsFilter(options); - CheckCallToolFilter(options); CheckListResourcesFilter(options); CheckListResourceTemplatesFilter(options); @@ -79,7 +86,7 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol }); } - private void ConfigureCallToolFilter(McpServerOptions options) + private void ConfigureOrdinaryCallToolFilter(McpServerOptions options) { options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => { @@ -95,18 +102,22 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol }); } - private static void CheckCallToolFilter(McpServerOptions options) + private void ConfigureCallToolFilter(McpServerOptions options) { - options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => +#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch. + options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) => { - if (HasAuthorizationMetadata(context.MatchedPrimitive) - && !context.Items.ContainsKey(AuthorizationFilterInvokedKey)) + var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); + if (!authResult.Succeeded) { - throw new InvalidOperationException("Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters."); + throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); } + context.Items[AuthorizationFilterInvokedKey] = true; + return await next(context, cancellationToken); }); +#pragma warning restore MCPEXP002 } private void ConfigureListResourcesFilter(McpServerOptions options) @@ -374,7 +385,7 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol : AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build()); } - private static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive) + internal static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive) { // If no primitive was found for this request or there is IAllowAnonymous metadata anywhere on the class or method, // the request should go through as normal. diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 732821b..a522683 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -34,6 +34,7 @@ public static class HttpMcpServerBuilderExtensions builder.Services.AddHostedService(); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationCallToolFilterGuardSetup>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, HttpServerTransportOptionsSetup>()); if (configureOptions is not null) @@ -55,14 +56,20 @@ public static class HttpMcpServerBuilderExtensions /// /// This method automatically configures authorization filters for all MCP server handlers. These filters respect /// authorization attributes such as - /// and . + /// and . Tool authorization runs in the alternate-result pipeline before + /// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task. + /// Each call to this method also adds an ordinary call-tool authorization checkpoint at that point in the filter + /// pipeline. Call this method again after any call-tool filter that changes the matched tool or user to authorize + /// the replacement using the updated context. /// public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) { ArgumentNullException.ThrowIfNull(builder); // Allow the authorization filters to get added multiple times in case other middleware changes the matched primitive. + builder.Services.TryAddSingleton(); builder.Services.AddTransient, AuthorizationFilterSetup>(); + builder.Services.TryAddEnumerable(ServiceDescriptor.Transient, AuthorizationFilterSetup>()); return builder; } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs new file mode 100644 index 0000000..829b7e8 --- /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 e0c8a88..7ecfc07 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-era client downgrades to - /// the legacy initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id is always rejected by the 2026-07-28 and later - /// revisions, regardless of this property's value. - /// + /// 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 b11fe81..de11d8a 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -36,7 +36,7 @@ internal sealed partial class IdleTrackingBackgroundService : BackgroundService 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 c95a5a8..e5fc3fa 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -32,7 +32,7 @@ public static class McpEndpointRouteBuilderExtensions 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 class McpEndpointRouteBuilderExtensions .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/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 44bac1b..4c46acd 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -25,6 +25,7 @@ + diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 0ecf7ab..f26e9ed 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -13,6 +13,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -32,24 +33,15 @@ internal sealed class StreamableHttpHandler( /// /// All protocol versions supported by this implementation. - /// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core. /// - private static readonly HashSet s_supportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + private static readonly string[] s_supportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// 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-era - /// client falls back to a legacy 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 = - [.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))]; + private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -61,14 +53,16 @@ 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) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) - { - await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); - return; - } - // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. // ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation, // so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, @@ -111,12 +105,54 @@ internal sealed class StreamableHttpHandler( // Notifications carry no id, so this stays default (null), which is correct. var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default; - if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) + // 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, IsStatelessOnly, out var protocolVersionError)) + { + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId); + return; + } + + if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError)) + { + await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId); + return; + } + + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId); return; } + if (!ValidateRequiredPerRequestMeta(context, message, out var requiredMetaError)) + { + await WriteJsonRpcErrorDetailAsync(context, requiredMetaError, StatusCodes.Status400BadRequest, requestId); + return; + } + + // SEP-2575 removed these RPCs from the per-request-metadata HTTP surface (initialize and + // ping in favor of server/discover, logging/setLevel in favor of _meta logLevel, and the + // resource subscription pair in favor of subscriptions/listen). A request for a method the + // server does not implement is rejected with 404 Not Found and Method not found. Rejecting + // here gives HTTP the required status; the server protocol boundary enforces the same method + // set for other transports. +#pragma warning disable MCP9005 // logging/setLevel is deprecated (SEP-2577); referenced here only to reject it as removed. + if (RequiresPerRequestMetadataProtocol(context) && + message is JsonRpcRequest + { + Method: RequestMethods.Initialize or RequestMethods.Ping or RequestMethods.LoggingSetLevel + or RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe, + } removedMethodRequest) +#pragma warning restore MCP9005 + { + await WriteJsonRpcErrorAsync(context, + $"Method '{removedMethodRequest.Method}' is not available on protocol version '{context.Request.Headers[McpProtocolVersionHeaderName]}'.", + StatusCodes.Status404NotFound, (int)McpErrorCode.MethodNotFound, requestId); + return; + } + var session = await GetOrCreateSessionAsync(context, message, requestId); if (session is null) { @@ -125,8 +161,33 @@ internal sealed class StreamableHttpHandler( await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); + Func? onResponseStarting = null; + if (RequiresPerRequestMetadataProtocol(context)) + { + // SEP-2575 maps some JSON-RPC error codes onto HTTP statuses (404 for a method the server + // does not implement, 400 for missing-capability and unsupported-version rejections). The + // status line can only be chosen before the first response byte, so the transport defers + // its eager header flush and reports the first response message here. + onResponseStarting = firstMessage => + { + if (firstMessage is JsonRpcError { Error: { } errorDetail } && !context.Response.HasStarted) + { + context.Response.StatusCode = (McpErrorCode)errorDetail.Code switch + { + McpErrorCode.MethodNotFound => StatusCodes.Status404NotFound, + McpErrorCode.MissingRequiredClientCapability => StatusCodes.Status400BadRequest, + McpErrorCode.UnsupportedProtocolVersion => StatusCodes.Status400BadRequest, + McpErrorCode.HeaderMismatch => StatusCodes.Status400BadRequest, + _ => context.Response.StatusCode, + }; + } + + return default; + }; + } + InitializeSseResponse(context); - var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted); + var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, onResponseStarting, context.RequestAborted); if (!wroteResponse) { // We wound up writing nothing, so there should be no Content-Type response header. @@ -137,7 +198,8 @@ internal sealed class StreamableHttpHandler( public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -148,11 +210,12 @@ internal sealed class StreamableHttpHandler( // The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited // server-to-client messages; clients use subscriptions/listen (POST) instead. Because Streamable HTTP // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, - "Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -183,7 +246,7 @@ internal sealed class StreamableHttpHandler( 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.", @@ -254,7 +317,8 @@ internal sealed class StreamableHttpHandler( public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -264,11 +328,12 @@ internal sealed class StreamableHttpHandler( // Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567), // so the DELETE is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, - "Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -297,7 +362,7 @@ internal sealed class StreamableHttpHandler( { 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; @@ -374,52 +439,43 @@ internal sealed class StreamableHttpHandler( private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default) { - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - // The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { - if (!string.IsNullOrEmpty(sessionId)) + if (HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateful) { - // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). - await WriteJsonRpcErrorAsync(context, - "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", - StatusCodes.Status400BadRequest, requestId: requestId); - return null; - } - - if (!HttpServerTransportOptions.Stateless) - { - // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-era client falls back to the legacy - // 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(); if (string.IsNullOrEmpty(sessionId)) { // 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 @@ -435,21 +491,21 @@ internal sealed class StreamableHttpHandler( /// /// Returns when the request's MCP-Protocol-Version header declares a /// revision that operates without sessions, so the server must serve it statelessly. Such requests - /// never carry an Mcp-Session-Id and never perform the legacy initialize handshake + /// do not use an Mcp-Session-Id and never perform the initialize handshake /// (SEP-2575 + SEP-2567). /// - private static bool IsJuly2026OrLaterProtocol(HttpContext context) + private static bool RequiresPerRequestMetadataProtocol(HttpContext context) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader); + 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. @@ -478,23 +534,24 @@ internal sealed class StreamableHttpHandler( }; } - 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; @@ -542,7 +599,7 @@ internal sealed class StreamableHttpHandler( 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; @@ -659,18 +716,72 @@ internal sealed class StreamableHttpHandler( internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + /// + /// Validates that a request riding a per-request-metadata protocol revision carries the required + /// _meta fields beyond the protocol version (which + /// already checked): io.modelcontextprotocol/clientCapabilities, which must be present and a + /// JSON object. Performed at the HTTP layer so the rejection can use 400 Bad Request as SEP-2575 + /// requires; a malformed (non-object) value would otherwise fail later during deserialization as a + /// generic internal error on a 200 response. clientInfo is optional, so its absence is not + /// rejected. + /// + private static bool ValidateRequiredPerRequestMeta( + HttpContext context, + JsonRpcMessage message, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + { + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + if (message is JsonRpcRequest { Params: var requestParams } && + McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && + (requestParams is not JsonObject paramsObj || + paramsObj["_meta"] is not JsonObject metaObj || + metaObj[MetaKeys.ClientCapabilities] is not JsonObject)) + { + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidParams, + Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ClientCapabilities}' as a JSON object.", + }; + return false; + } + + errorDetail = null; + return true; + } + /// /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility, /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the /// rejection uses the error code with a data payload /// listing the server's supported versions so the client can select a fallback. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + private static bool ValidateProtocolVersionHeader( + HttpContext context, + IReadOnlyList supportedProtocolVersions, + bool stateless, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + !supportedProtocolVersions.Contains(protocolVersionHeader)) { + // On a stateless server, restrict the advertised list to the per-request-metadata + // revisions: those are what server/discover advertises, and SEP-2575 clients use this + // list to pick a retry version for server/discover, so the two must agree. Requests for + // the older initialize-handshake revisions are still accepted above; only the error + // payload for unknown versions narrows. Stateful servers keep the full configured list + // (their 2026-07-28 refusal advertises the session-supporting versions separately in + // WriteUnsupportedProtocolVersionErrorAsync). + IReadOnlyList advertisedVersions = supportedProtocolVersions; + if (stateless) + { + var metadataVersions = supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata).ToArray(); + if (metadataVersions.Length > 0) + { + advertisedVersions = metadataVersions; + } + } + errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, @@ -678,7 +789,7 @@ internal sealed class StreamableHttpHandler( Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = [.. s_supportedProtocolVersions], + Supported = [.. advertisedVersions], Requested = protocolVersionHeader, }, GetRequiredJsonTypeInfo()), @@ -690,12 +801,132 @@ internal sealed class StreamableHttpHandler( return true; } + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return s_supportedProtocolVersions; + } + + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; + } + /// - /// 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-era client then falls back to the legacy initialize handshake - /// (SEP-2575). + /// Validates that HTTP requests declare matching protocol versions in the + /// MCP-Protocol-Version header and the corresponding body field. + /// + private static bool ValidateProtocolVersionEnvelope( + HttpContext context, + JsonRpcMessage message, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + { + if (message is not (JsonRpcRequest or JsonRpcNotification)) + { + errorDetail = null; + return true; + } + + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize, Params: JsonObject initializeParams } && + initializeParams["protocolVersion"] is JsonValue initializeProtocolVersionValue && + initializeProtocolVersionValue.TryGetValue(out string? initializeProtocolVersion) && + !string.IsNullOrEmpty(protocolVersionHeader) && + !string.Equals(protocolVersionHeader, initializeProtocolVersion, StringComparison.Ordinal)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body params.protocolVersion value '{initializeProtocolVersion}'."); + return false; + } + + // Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve + // the version from the session or metadata, falling back to legacy handling when neither selects one. + if (string.IsNullOrEmpty(protocolVersionHeader) || + McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader)) + { + errorDetail = null; + return true; + } + + // Header validity was checked first, so only supported modern headers reach this point. + if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta)) + { + // Notifications are exempt from the required-_meta rejection: they are fire-and-forget + // (a JSON-RPC error response has no recipient), and rejecting them silently drops + // client signals like notifications/cancelled, leaving server-side work running. + if (message is not JsonRpcRequest) + { + errorDetail = null; + return true; + } + + // A missing (rather than mismatched) per-request metadata field is an Invalid params + // rejection per SEP-2575; HeaderMismatch (-32020) is reserved for values that are + // present on both sides but disagree. + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidParams, + Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ProtocolVersion}'.", + }; + return false; + } + + if (!string.Equals(protocolVersionHeader, protocolVersionMeta, StringComparison.Ordinal)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body _meta/{MetaKeys.ProtocolVersion} value '{protocolVersionMeta}'."); + return false; + } + + errorDetail = null; + return true; + } + + private static JsonRpcErrorDetail CreateHeaderMismatchError(string message) => new() + { + Code = (int)McpErrorCode.HeaderMismatch, + Message = message, + }; + + private static bool TryGetProtocolVersionMeta(JsonRpcMessage message, [NotNullWhen(true)] out string? protocolVersion) + { + var parameters = message switch + { + JsonRpcRequest request => request.Params, + JsonRpcNotification notification => notification.Params, + _ => null, + }; + + string? value = null; + if (parameters is JsonObject paramsObj && + paramsObj["_meta"] is JsonObject metaObj && + metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && + protocolVersionValue.TryGetValue(out value) && + !string.IsNullOrEmpty(value)) + { + protocolVersion = value; + return true; + } + + protocolVersion = null; + return false; + } + + /// + /// 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) { @@ -703,8 +934,8 @@ internal sealed class StreamableHttpHandler( var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpHttpHeaders.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 { @@ -724,14 +955,14 @@ internal sealed class StreamableHttpHandler( /// /// The HTTP context containing the request headers. /// The JSON-RPC message to validate against. - /// The tool collection to look up tool schemas for parameter header validation. + /// The server options containing tools and custom request routing metadata. /// Set to the error message if validation fails; null otherwise. /// True if validation passes; false otherwise. - internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection? toolCollection, [NotNullWhen(false)] out string? errorMessage) + internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerOptions serverOptions, [NotNullWhen(false)] out string? errorMessage) { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion)) + if (!McpProtocolVersions.RequiresStandardHeaders(protocolVersion)) { errorMessage = null; return true; @@ -766,8 +997,10 @@ internal sealed class StreamableHttpHandler( return false; } - // From here on, only validate resources/read, tools/call, and prompts/get requests - if (mcpMethodInBody is not (RequestMethods.ToolsCall or RequestMethods.ResourcesRead or RequestMethods.PromptsGet)) +#pragma warning disable MCPEXP002 + var routingNameParameter = GetRoutingNameParameter(mcpMethodInBody, serverOptions.RequestHandlers); +#pragma warning restore MCPEXP002 + if (routingNameParameter is null) { errorMessage = null; return true; @@ -806,13 +1039,7 @@ internal sealed class StreamableHttpHandler( JsonRpcNotification notification => notification.Params, _ => null, }; - var mcpNameInBody = mcpMethodInBody switch - { - RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"), - RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"), - RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"), - _ => null, - }; + var mcpNameInBody = GetJsonNodeStringProperty(bodyParams, routingNameParameter); // Check that the header value matches the body value if the body value is present. if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) @@ -822,7 +1049,7 @@ internal sealed class StreamableHttpHandler( } // Validate Mcp-Param-* custom headers against tool schema - if (!ValidateCustomParamHeaders(context, message, toolCollection, out errorMessage)) + if (!ValidateCustomParamHeaders(context, message, serverOptions.ToolCollection, out errorMessage)) { return false; } @@ -831,6 +1058,38 @@ internal sealed class StreamableHttpHandler( return true; } +#pragma warning disable MCPEXP002 + private static string? GetRoutingNameParameter( + string? method, + IList? requestHandlers) + { + var builtInParameter = method switch + { + RequestMethods.ToolsCall or RequestMethods.PromptsGet => "name", + RequestMethods.ResourcesRead => "uri", + _ => null, + }; + + if (builtInParameter is not null) + { + return builtInParameter; + } + + if (requestHandlers is not null) + { + foreach (var requestHandler in requestHandlers) + { + if (string.Equals(requestHandler.Method, method, StringComparison.Ordinal)) + { + return requestHandler.RoutingNameParameter; + } + } + } + + return null; + } +#pragma warning restore MCPEXP002 + /// /// Validates that all parameters annotated with x-mcp-header in the tool's input schema /// have corresponding Mcp-Param-* headers present in the request, and that any present diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs new file mode 100644 index 0000000..cbc4538 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Provides the information needed to complete an OAuth authorization request. +/// +public sealed class AuthorizationCallbackContext +{ + /// + /// Gets the authorization URI that the user needs to visit. + /// + public required Uri AuthorizationUri { get; init; } + + /// + /// Gets the redirect URI where the authorization response will be sent. + /// + public required Uri RedirectUri { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs index a811e51..b8f6a37 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs @@ -1,4 +1,3 @@ - namespace ModelContextProtocol.Authentication; /// @@ -9,20 +8,13 @@ namespace ModelContextProtocol.Authentication; /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. The task result contains the authorization code if successful, or null if the operation failed or was cancelled. /// -/// -/// This delegate provides SDK consumers with full control over how the OAuth authorization flow is handled. -/// Implementers can choose to: -/// -/// -/// Start a local HTTP server and open a browser (default behavior) -/// Display the authorization URL to the user for manual handling -/// Integrate with a custom UI or authentication flow -/// Use a different redirect mechanism altogether -/// -/// -/// The implementation should handle user interaction to visit the authorization URL and extract -/// the authorization code from the callback. The authorization code is typically provided as -/// a query parameter in the redirect URI callback. -/// +/// This delegate cannot return the iss parameter from the authorization response, so +/// RFC 9207 issuer validation is +/// skipped when it is used. Use for +/// issuer-aware authorization flows. /// -public delegate Task AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken); \ No newline at end of file +[Obsolete( + ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Message, + DiagnosticId = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_DiagnosticId, + UrlFormat = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Url)] +public delegate Task AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken); diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs new file mode 100644 index 0000000..4da082f --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs @@ -0,0 +1,54 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the result of an OAuth authorization redirect, containing the authorization code, +/// state, and optionally the issuer identifier from the authorization response. +/// +/// +/// +/// The property must be populated from the state query parameter in the +/// redirect URI. The SDK validates it against the value sent in the authorization request to bind +/// the response to the initiating transaction and mitigate cross-site request forgery attacks. +/// +/// +/// The property should be populated from the iss query parameter in the +/// redirect URI when present, as specified by +/// RFC 9207. +/// This enables the SDK to validate that the authorization response originated from the expected +/// authorization server, mitigating mix-up attacks. +/// +/// +public sealed class AuthorizationResult +{ + /// + /// Gets the authorization code returned by the authorization server. + /// + public string? Code { get; init; } + + /// + /// Gets the state value returned in the authorization response. + /// + /// + /// Implementations of must populate this + /// property from the state query parameter of the redirect URI callback. The SDK requires an + /// exact match with the state sent in the authorization request before exchanging the authorization code. + /// + public string? State { get; init; } + + /// + /// Gets the issuer identifier returned in the authorization response per + /// RFC 9207. + /// + /// + /// + /// This value should be extracted from the iss query parameter of the redirect URI. + /// When present, the SDK validates it against the expected authorization server issuer to + /// prevent mix-up attacks. + /// + /// + /// Implementations of should populate this + /// property whenever the iss parameter is present in the redirect URI callback. + /// + /// + public string? Iss { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs index 87df296..d8d684a 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs @@ -72,4 +72,11 @@ internal sealed class AuthorizationServerMetadata /// [JsonPropertyName("client_id_metadata_document_supported")] public bool ClientIdMetadataDocumentSupported { get; set; } + + /// + /// Indicates whether the authorization server includes the iss parameter in authorization responses + /// as defined in RFC 9207. + /// + [JsonPropertyName("authorization_response_iss_parameter_supported")] + public bool AuthorizationResponseIssParameterSupported { get; set; } } diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 0bfb19a..df1c44c 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -70,18 +70,47 @@ public sealed class ClientOAuthOptions public ScopeSelectorDelegate? ScopeSelector { get; set; } /// - /// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow. + /// Gets or sets the callback that handles the OAuth authorization flow. /// /// /// - /// This delegate is responsible for handling the OAuth authorization URL and obtaining the authorization code. - /// If not specified, a default implementation will be used that prompts the user to enter the code manually. + /// This callback receives the authorization and redirect URIs in an + /// and returns the authorization response. + /// If not specified, a default implementation prompts the user to enter the full redirect URL manually. /// /// /// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture - /// the authorization code from the OAuth redirect. + /// the authorization response. They must return the code and state query parameters, + /// and should return the iss query parameter when present, from the redirect URI callback. + /// The SDK requires an exact state match before exchanging the code. Returning iss enables + /// the SDK to validate the parameter per + /// RFC 9207, which mitigates + /// mix-up attacks. + /// + /// + /// This property cannot be configured together with . /// /// + public Func>? AuthorizationCallbackHandler { get; set; } + + /// + /// Gets or sets the legacy authorization redirect delegate for handling the OAuth authorization flow. + /// + /// + /// + /// This delegate returns only the authorization code and cannot provide the state or iss + /// parameter from the authorization response. Consequently, state and RFC 9207 issuer validation are + /// skipped when this delegate is used. Use for response-bound, + /// issuer-aware authorization flows. + /// + /// + /// This property cannot be configured together with . + /// + /// + [Obsolete( + ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Message, + DiagnosticId = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_DiagnosticId, + UrlFormat = ModelContextProtocol.Obsoletions.AuthorizationRedirectDelegate_Url)] public AuthorizationRedirectDelegate? AuthorizationRedirectDelegate { get; set; } /// @@ -113,8 +142,9 @@ public sealed class ClientOAuthOptions /// /// /// - /// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri", - /// which should instead be configured via . + /// Parameters specified cannot override or append to any automatically set parameters like + /// redirect_uri or state. The redirect URI should instead be configured via + /// , while state is generated uniquely for each authorization transaction. /// /// public IDictionary AdditionalAuthorizationParameters { get; set; } = new Dictionary(); diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index c3b1af7..785e3cc 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -31,13 +31,17 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly ScopeSelectorDelegate? _scopeSelector; private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; - private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; + private readonly Func> _authorizationCallbackHandler; + private readonly bool _validateAuthorizationResponseState; + private readonly bool _validateAuthorizationResponseIssuer; private readonly Uri? _clientMetadataDocumentUri; + private readonly string? _configuredClientId; - // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) + // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) private readonly string? _dcrClientName; private readonly Uri? _dcrClientUri; private readonly string? _dcrInitialAccessToken; + private readonly string? _dcrConfiguredApplicationType; private readonly Func? _dcrResponseDelegate; private readonly HttpClient _httpClient; @@ -46,15 +50,27 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private string? _clientId; private string? _clientSecret; private string? _tokenEndpointAuthMethod; + private string? _clientCredentialsAuthorizationServer; private ITokenCache _tokenCache; private AuthorizationServerMetadata? _authServerMetadata; + + // Coalesces concurrent token acquisition so that when multiple in-flight requests observe an + // expired token (or a 401) at the same time, only the first runs the refresh/authorization flow + // while the others await its result. This also serializes all reads and writes to the mutable auth + // state below (_authServerMetadata, _clientId, _clientSecret, _tokenEndpointAuthMethod) as well as + // the accumulated scope set and step-up tracking (_accumulatedScopes, _hasAttemptedStepUp), so those + // fields need no separate lock. + // + // Intentionally not disposed: this instance is only ever used via WaitAsync/Release (never its + // AvailableWaitHandle), so SemaphoreSlim allocates no unmanaged resource and there is nothing to + // dispose. Do not access AvailableWaitHandle, or this field will need deterministic disposal. + private readonly SemaphoreSlim _tokenAcquisitionLock = new(1, 1); // The accumulated scope set lives for this provider's lifetime and is intentionally not keyed by // resource or authorization server. This is safe today because one ClientOAuthProvider is created // per HttpClientTransport, i.e. per endpoint/resource. If a provider were ever reused across // multiple resources or auth servers, accumulated scopes could be sent to a server that rejects // them (invalid_scope). Accumulation is scoped per "resource and operation" combination (SEP-2350). private readonly HashSet _accumulatedScopes = new(StringComparer.Ordinal); - private readonly object _scopeAccumulatorLock = new(); private bool _hasAttemptedStepUp; /// @@ -82,6 +98,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } _clientId = options.ClientId; + _configuredClientId = options.ClientId; _clientSecret = options.ClientSecret; _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options)); _configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes); @@ -92,13 +109,48 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient // Set up authorization server selection strategy _authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector; - // Set up authorization URL handler (use default if not provided) - _authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler; + // Set up authorization callback handler (use default if not provided). +#pragma warning disable MCP9007 // Read the obsolete property to provide source and binary compatibility. + var authorizationRedirectDelegate = options.AuthorizationRedirectDelegate; + + if (options.AuthorizationCallbackHandler is not null && authorizationRedirectDelegate is not null) + { + throw new ArgumentException( + $"{nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} and {nameof(ClientOAuthOptions.AuthorizationRedirectDelegate)} cannot both be configured.", + nameof(options)); + } +#pragma warning restore MCP9007 + + if (options.AuthorizationCallbackHandler is not null) + { + _authorizationCallbackHandler = options.AuthorizationCallbackHandler; + _validateAuthorizationResponseState = true; + _validateAuthorizationResponseIssuer = true; + } + else if (authorizationRedirectDelegate is not null) + { + _authorizationCallbackHandler = async (context, cancellationToken) => new AuthorizationResult + { + Code = await authorizationRedirectDelegate( + context.AuthorizationUri, + context.RedirectUri, + cancellationToken).ConfigureAwait(false), + }; + _validateAuthorizationResponseState = false; + _validateAuthorizationResponseIssuer = false; + } + else + { + _authorizationCallbackHandler = DefaultAuthorizationUrlHandler; + _validateAuthorizationResponseState = true; + _validateAuthorizationResponseIssuer = true; + } _dcrClientName = options.DynamicClientRegistration?.ClientName; _dcrClientUri = options.DynamicClientRegistration?.ClientUri; _dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken; _dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate; + _dcrConfiguredApplicationType = options.DynamicClientRegistration?.ApplicationType; _tokenCache = options.TokenCache ?? new InMemoryTokenCache(); } @@ -110,20 +162,33 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private static Uri? DefaultAuthServerSelector(IReadOnlyList availableServers) => availableServers.FirstOrDefault(); /// - /// Default authorization URL handler that displays the URL to the user for manual input. + /// Default authorization URL handler that displays the URL to the user and parses the resulting redirect URL. /// - /// The authorization URL to handle. - /// The redirect URI where the authorization code will be sent. + /// The context containing the authorization and redirect URIs. /// The to monitor for cancellation requests. - /// The authorization code entered by the user, or null if none was provided. - private static Task DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) + /// The authorization result parsed from the redirect URL. + private static Task DefaultAuthorizationUrlHandler( + AuthorizationCallbackContext context, + CancellationToken cancellationToken) { Console.WriteLine($"Please open the following URL in your browser to authorize the application:"); - Console.WriteLine($"{authorizationUrl}"); + Console.WriteLine($"{context.AuthorizationUri}"); Console.WriteLine(); - Console.Write("Enter the authorization code from the redirect URL: "); - var authorizationCode = Console.ReadLine(); - return Task.FromResult(authorizationCode); + Console.Write("Enter the full redirect URL: "); + var redirectUrl = Console.ReadLine(); + if (!Uri.TryCreate(redirectUrl, UriKind.Absolute, out var responseUri)) + { + ThrowFailedToHandleUnauthorizedResponse( + "The entered redirect URL is not a valid absolute URL. Paste the full redirect URL from the browser address bar."); + } + + var queryParams = HttpUtility.ParseQueryString(responseUri.Query); + return Task.FromResult(new() + { + Code = queryParams["code"], + State = queryParams["state"], + Iss = queryParams["iss"], + }); } internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) @@ -145,7 +210,10 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (ShouldRetryWithNewAccessToken(response)) { - return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, cancellationToken).ConfigureAwait(false); + // Capture the token that produced this challenge so the retry path can detect whether + // another concurrent caller already replaced it in the cache. + var usedAccessToken = request.Headers.Authorization?.Parameter; + return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); } return response; @@ -161,15 +229,34 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return (tokens.AccessToken, false); } - // Try to refresh the access token if it is invalid and we have a refresh token. - if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken) + // A refresh is only possible if we have both the auth server metadata and a refresh token. + if (_authServerMetadata is null || tokens?.RefreshToken is not { Length: > 0 }) + { + // No valid token - auth handler will trigger the 401 flow + return (null, false); + } + + // Serialize the refresh so concurrent callers that all saw the expired token don't each fire + // their own refresh. Waiters re-check the cache after acquiring the lock and reuse the token + // produced by whoever refreshed first. + using var _ = await _tokenAcquisitionLock.LockAsync(cancellationToken).ConfigureAwait(false); + + var current = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false); + if (current is not null && !current.IsExpired) + { + return (current.AccessToken, true); + } + + if (_authServerMetadata is not null && + current?.RefreshToken is { Length: > 0 } refreshToken && + CachedTokensMatchClientCredentials(current, _clientCredentialsAuthorizationServer)) { var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false); return (accessToken, true); } // No valid token - auth handler will trigger the 401 flow - return (null, false); + return (null, true); } private static bool ShouldRetryWithNewAccessToken(HttpResponseMessage response) @@ -208,6 +295,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient JsonRpcMessage? originalJsonRpcMessage, HttpResponseMessage response, bool attemptedRefresh, + string? usedAccessToken, CancellationToken cancellationToken) { if (response.Headers.WwwAuthenticate.Count == 0) @@ -220,7 +308,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}]."); } - var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, cancellationToken).ConfigureAwait(false); + var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); @@ -241,8 +329,31 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient /// /// The HTTP response that triggered the authentication challenge. /// Indicates whether a token refresh has already been attempted. + /// The access token that produced the challenge, or if none was sent. /// The to monitor for cancellation requests. - private async Task GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken) + private async Task GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, string? usedAccessToken, CancellationToken cancellationToken) + { + // Serialize the authorization flow so concurrent 401/403 challenges don't each run a full + // refresh/registration/interactive authorization and race on the shared auth state below. + using var _ = await _tokenAcquisitionLock.LockAsync(cancellationToken).ConfigureAwait(false); + + // While we waited for the lock, another concurrent caller may have already acquired or + // refreshed the token. Reuse the cached token if it is both still valid and different from + // the one that produced this challenge (otherwise we'd just replay the rejected token). When + // no token was sent (usedAccessToken is null, e.g. concurrent cold-start requests), any valid + // cached token was obtained by another caller and is safe to reuse. This is limited to 401; a + // 403 insufficient_scope challenge must still run the step-up flow. + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && + await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { IsExpired: false } cached && + !string.Equals(cached.AccessToken, usedAccessToken, StringComparison.Ordinal)) + { + return cached.AccessToken; + } + + return await GetAccessTokenCoreAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + } + + private async Task GetAccessTokenCoreAsync(HttpResponseMessage response, bool attemptedRefresh, string? usedAccessToken, CancellationToken cancellationToken) { // Get available authorization servers from the 401 or 403 response var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, cancellationToken).ConfigureAwait(false); @@ -262,17 +373,26 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) { bool introducesNewScopes = ChallengeIntroducesNewScopes(protectedResourceMetadata); - lock (_scopeAccumulatorLock) + if (_hasAttemptedStepUp && !introducesNewScopes) { - if (_hasAttemptedStepUp && !introducesNewScopes) + // A step-up has already run and this challenge asks for nothing new. If that step-up + // produced a different, still-valid token (for example another concurrent caller ran + // it while this one waited on the lock), reuse that token instead of failing, since it + // already reflects the accumulated scopes. Only fail when there is no newer token to + // try, which is the genuine repeated-failure case where the stepped-up token itself + // was rejected again. + if (await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { IsExpired: false } steppedUpToken && + !string.Equals(steppedUpToken.AccessToken, usedAccessToken, StringComparison.Ordinal)) { - ThrowFailedToHandleUnauthorizedResponse( - "A repeated insufficient_scope challenge added no scope beyond those already requested, " + - "so step-up authorization cannot satisfy the request."); + return steppedUpToken.AccessToken; } - _hasAttemptedStepUp = true; + ThrowFailedToHandleUnauthorizedResponse( + "A repeated insufficient_scope challenge added no scope beyond those already requested, " + + "so step-up authorization cannot satisfy the request."); } + + _hasAttemptedStepUp = true; } // Convert string URIs to Uri objects for the selector @@ -307,13 +427,28 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient // The existing access token must be invalid to have resulted in a 401 response, but refresh might still work. var resourceUri = GetResourceUri(protectedResourceMetadata); + // Restore any client registration persisted alongside the tokens. On a cold start a durable + // token cache may hold a refresh token together with the client ID it was issued to, while this + // provider has not assigned a client ID yet. Restoring it here makes the refresh below possible + // and avoids a redundant dynamic client registration in the assignment block. + var cachedTokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false); + RestoreCachedClientCredentials(cachedTokens, selectedAuthServer); + BindClientCredentialsToAuthorizationServer(selectedAuthServer); + var cachedTokensMatchClientCredentials = + CachedTokensMatchClientCredentials(cachedTokens, selectedAuthServer.OriginalString); + // Only attempt a token refresh if we haven't attempted to already for this request. // Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes - // should not be used for expired access tokens. This is important because 403 forbiden responses can - // be used for incremental consent which cannot be acheived with a simple refresh. + // should not be used for expired access tokens. This is important because 403 forbidden responses can + // be used for incremental consent which cannot be achieved with a simple refresh. + // A refresh also requires a client ID. On a cold start one may not be available yet (and could not + // be restored from the cache), in which case we fall through to the client-ID assignment block and + // the authorization-code flow below rather than throwing. if (!attemptedRefresh && response.StatusCode == System.Net.HttpStatusCode.Unauthorized && - await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { Length: > 0 } refreshToken }) + !string.IsNullOrEmpty(_clientId) && + cachedTokensMatchClientCredentials && + cachedTokens is { RefreshToken: { Length: > 0 } refreshToken }) { var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); if (accessToken is not null) @@ -337,6 +472,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } } + _clientCredentialsAuthorizationServer = selectedAuthServer.OriginalString; + // Determine the token endpoint auth method from server metadata if not already set by DCR. _tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault(); @@ -366,8 +503,15 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private async Task GetAuthServerMetadataAsync(Uri authServerUri, string? resourceUri, CancellationToken cancellationToken) { + // Tracks whether at least one well-known endpoint returned a structurally valid metadata document. + // This distinguishes "no metadata document was found" (eligible for the 2025-03-26 legacy fallback) + // from "a metadata document exists but failed PKCE validation" (must not fall back to synthesized defaults). + var metadataDocumentFound = false; + McpException? pkceValidationFailure = null; + foreach (var wellKnownEndpoint in GetWellKnownAuthorizationServerMetadataUris(authServerUri)) { + AuthorizationServerMetadata? metadata; try { var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); @@ -377,7 +521,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false); + metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false); if (metadata is null) { @@ -395,23 +539,94 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement."); } - metadata.ResponseTypesSupported ??= ["code"]; - metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"]; - metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"]; - metadata.CodeChallengeMethodsSupported ??= ["S256"]; + // Validate the issuer in the metadata document per RFC 8414 Section 3.3: + // the issuer value MUST be identical to the issuer identifier used to construct + // the well-known URL. + // Skip validation in legacy backcompat mode (resourceUri is null) because the + // authServerUri was derived from the server origin rather than from Protected + // Resource Metadata, so it may not match the server's canonical issuer. + // Note: resourceUri is null exclusively in the 2025-03-26 legacy path. For newer + // protocol versions, ExtractProtectedResourceMetadata throws if the PRM document + // omits the resource field (VerifyResourceMatch returns false for null Resource), + // so we never reach this point with resourceUri == null in non-legacy flows. + if (resourceUri is not null && metadata.Issuer is null) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization server metadata from '{wellKnownEndpoint}' did not provide the required issuer (RFC 8414 Section 2)."); + } - return metadata; + // RFC 8414 requires an identical issuer value, so do not normalize URI case, + // trailing slashes, or percent-encoding before comparison. + if (resourceUri is not null && + !string.Equals(metadata.Issuer!.OriginalString, authServerUri.OriginalString, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization server metadata issuer '{metadata.Issuer}' does not match the expected issuer '{authServerUri}' (RFC 8414 Section 3.3)."); + } + + // A structurally valid metadata document was discovered. Even if it fails PKCE validation + // below, its existence disqualifies the legacy fallback that would otherwise synthesize S256. + metadataDocumentFound = true; + } + catch (McpException) + { + // Metadata validation failures are security signals and must not fall back to + // another well-known endpoint. + throw; } catch (Exception ex) { LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); + continue; } + + try + { + // Per the MCP spec, clients MUST verify PKCE support via authorization server metadata and refuse + // to proceed without it. Rather than failing on the first document that lacks it, skip to the next + // discovery endpoint: different well-known endpoints (OAuth 2.0 vs OpenID Connect) can return + // different documents for the same server, and OpenID Connect metadata commonly includes the field. + var codeChallengeMethods = metadata.CodeChallengeMethodsSupported; + if (codeChallengeMethods is null) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization server metadata from '{wellKnownEndpoint}' does not include 'code_challenge_methods_supported'. MCP clients require PKCE support and must refuse to proceed."); + } + + if (!codeChallengeMethods.Contains("S256")) + { + var advertisedMethods = codeChallengeMethods.Count > 0 + ? string.Join(", ", codeChallengeMethods) + : ""; + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization server metadata from '{wellKnownEndpoint}' does not advertise required PKCE method 'S256' in 'code_challenge_methods_supported'. Advertised methods: {advertisedMethods}."); + } + } + catch (Exception ex) + { + LogAuthServerMetadataMissingPkceSupport(ex, wellKnownEndpoint); + pkceValidationFailure = ex as McpException ?? new McpException(ex.Message, ex); + continue; + } + + metadata.ResponseTypesSupported ??= ["code"]; + metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"]; + metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"]; + + return metadata; + } + + // A discovered metadata document that failed PKCE validation must not be replaced with synthesized + // defaults. Surface the specific PKCE failure regardless of whether PRM was available. + if (metadataDocumentFound && pkceValidationFailure is not null) + { + throw pkceValidationFailure; } if (resourceUri is null) { - // 2025-03-26 backcompat: when PRM is unavailable and auth server metadata discovery - // also fails, fall back to default endpoint paths per the 2025-03-26 spec. + // 2025-03-26 backcompat: when PRM is unavailable and no auth server metadata document was + // discovered, fall back to default endpoint paths per the 2025-03-26 spec. return BuildDefaultAuthServerMetadata(authServerUri); } @@ -488,24 +703,53 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { - var codeVerifier = GenerateCodeVerifier(); + var codeVerifier = GenerateRandomBase64UrlValue(); var codeChallenge = GenerateCodeChallenge(codeVerifier); + var state = GenerateRandomBase64UrlValue(); - var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge); - var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false); + var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state); - if (string.IsNullOrEmpty(authCode)) + var authResult = await _authorizationCallbackHandler( + new AuthorizationCallbackContext + { + AuthorizationUri = authUrl, + RedirectUri = _redirectUri, + }, + cancellationToken).ConfigureAwait(false); + + if (authResult is null) { - ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code."); + ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null authorization result."); } - return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false); + if (_validateAuthorizationResponseState) + { + ValidateStateResponse(authResult!.State, state); + } + + if (string.IsNullOrEmpty(authResult.Code)) + { + ThrowFailedToHandleUnauthorizedResponse("The authorization callback returned a null or empty authorization code."); + } + + if (_validateAuthorizationResponseIssuer) + { + ValidateIssuerResponse(authResult!.Iss, authServerMetadata); + } + + return await ExchangeCodeForTokenAsync( + protectedResourceMetadata, + authServerMetadata, + authResult.Code!, + codeVerifier, + cancellationToken).ConfigureAwait(false); } private Uri BuildAuthorizationUrl( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, - string codeChallenge) + string codeChallenge, + string state) { var resourceUri = GetResourceUri(protectedResourceMetadata); @@ -516,6 +760,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ["response_type"] = "code", ["code_challenge"] = codeChallenge, ["code_challenge_method"] = "S256", + ["state"] = state, }; if (resourceUri is not null) @@ -636,6 +881,12 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient TokenType = tokenResponse.TokenType, Scope = tokenResponse.Scope, ObtainedAt = DateTimeOffset.UtcNow, + // Persist the client registration alongside the tokens so a durable cache can use the + // refresh token after a process restart without re-running dynamic client registration. + ClientId = _clientId, + ClientSecret = _clientSecret, + TokenEndpointAuthMethod = _tokenEndpointAuthMethod, + AuthorizationServer = _clientCredentialsAuthorizationServer, }; await _tokenCache.StoreTokensAsync(tokens, cancellationToken).ConfigureAwait(false); @@ -677,6 +928,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint); + var dcrApplicationType = ResolveApplicationType(_dcrConfiguredApplicationType, _redirectUri); var registrationRequest = new DynamicClientRegistrationRequest { RedirectUris = [_redirectUri.ToString()], @@ -686,6 +938,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ClientName = _dcrClientName, ClientUri = _dcrClientUri?.ToString(), Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata), + ApplicationType = dcrApplicationType, }; var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest); @@ -707,7 +960,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (!httpResponse.IsSuccessStatusCode) { var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - ThrowFailedToHandleUnauthorizedResponse($"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent}"); + ThrowFailedToHandleUnauthorizedResponse( + $"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent} " + + $"(application_type: '{dcrApplicationType}', redirect_uri: '{_redirectUri}')."); } using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); @@ -741,6 +996,18 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } } + private static string ResolveApplicationType(string? configuredApplicationType, Uri redirectUri) + => configuredApplicationType ?? InferApplicationType(redirectUri); + + private static string InferApplicationType(Uri redirectUri) + { + if (redirectUri.Scheme is "http" or "https") + { + return redirectUri.IsLoopback ? "native" : "web"; + } + return "native"; + } + private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) => protectedResourceMetadata.Resource; @@ -765,13 +1032,10 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (currentOperationScopes.Count == 0) { - lock (_scopeAccumulatorLock) - { - // If we have previously requested scopes but nothing new, return the accumulated set. - return _accumulatedScopes.Count > 0 - ? string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)) - : null; - } + // If we have previously requested scopes but nothing new, return the accumulated set. + return _accumulatedScopes.Count > 0 + ? string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)) + : null; } // Per SEP-2350: Compute the union of previously requested scopes and newly challenged scopes @@ -780,16 +1044,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient // offline_access (AugmentScopeWithOfflineAccess) and any ScopeSelector are applied per request // in ComputeEffectiveScope and are intentionally not accumulated, so the selector always sees // the full union and the operation stays idempotent. - lock (_scopeAccumulatorLock) + foreach (var scope in currentOperationScopes) { - foreach (var scope in currentOperationScopes) - { - _accumulatedScopes.Add(scope); - } - - // Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3). - return string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)); + _accumulatedScopes.Add(scope); } + + // Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3). + return string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)); } /// @@ -834,14 +1095,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return false; } - lock (_scopeAccumulatorLock) + foreach (var scope in currentOperationScopes) { - foreach (var scope in currentOperationScopes) + if (!_accumulatedScopes.Contains(scope)) { - if (!_accumulatedScopes.Contains(scope)) - { - return true; - } + return true; } } @@ -879,6 +1137,76 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return scope + " " + OfflineAccess; } + /// + /// Validates that an authorization response is bound to the transaction that initiated it. + /// + /// The state returned in the authorization response. + /// The state sent in the authorization request. + private static void ValidateStateResponse(string? state, string expectedState) + { + if (string.IsNullOrEmpty(state)) + { + ThrowFailedToHandleUnauthorizedResponse( + "The authorization response did not include the required state parameter."); + } + + if (!string.Equals(state, expectedState, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + "The authorization response state did not match the state sent in the authorization request."); + } + } + + /// + /// Validates the iss parameter from an authorization response per + /// RFC 9207. + /// + /// The issuer identifier received in the authorization response, or null if absent. + /// The authorization server metadata containing the expected issuer. + private void ValidateIssuerResponse(string? iss, AuthorizationServerMetadata authServerMetadata) + { + var expectedIssuer = authServerMetadata.Issuer?.OriginalString; + + if ((authServerMetadata.AuthorizationResponseIssParameterSupported || !string.IsNullOrEmpty(iss)) && + expectedIssuer is null) + { + ThrowFailedToHandleUnauthorizedResponse( + "Authorization server metadata did not provide an issuer required to validate the authorization response."); + } + + if (authServerMetadata.AuthorizationResponseIssParameterSupported) + { + // Server advertises iss support: iss MUST be present and match. + if (string.IsNullOrEmpty(iss)) + { + ThrowFailedToHandleUnauthorizedResponse( + "Authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response."); + } + + // Use exact string comparison per RFC 9207 / RFC 3986 §6.2.1. + if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'."); + } + } + else + { + // Server does not advertise iss support: if iss is present, still validate it. + // RFC 9207 cannot protect against a server that neither advertises support nor + // returns an iss parameter, so an absent iss is accepted in that case. + if (!string.IsNullOrEmpty(iss)) + { + if (!string.Equals(iss, expectedIssuer, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + $"Authorization response issuer '{iss}' does not match expected issuer '{expectedIssuer}'."); + } + } + // If iss is absent and not advertised, proceed normally. + } + } + /// /// Verifies that the resource URI in the metadata matches the original request URL. /// Accepts either an exact match with the full request URL, or a match with the base URL @@ -1100,7 +1428,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase)); } - private static string GenerateCodeVerifier() + private static string GenerateRandomBase64UrlValue() { #if NET9_0_OR_GREATER Span bytes = stackalloc byte[32]; @@ -1139,6 +1467,98 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private string GetClientIdOrThrow() => _clientId ?? throw new InvalidOperationException("Client ID is not available. This may indicate an issue with dynamic client registration."); + /// + /// Restores the client registration persisted alongside cached tokens when this provider has not been + /// assigned a client ID yet. This allows a durable to use a refresh token that + /// survived a process restart without re-running dynamic client registration. Credentials are restored + /// only when the cache binds them to the currently selected authorization server. An explicitly configured + /// client ID always takes precedence, so nothing is restored when one is already available. + /// + /// + /// Callers must hold _tokenAcquisitionLock: this writes the shared _clientId, + /// _clientSecret, and _tokenEndpointAuthMethod fields, which the lock serializes. + /// A single cache still stores only one registration, but the persisted authorization-server issuer + /// prevents that registration from being reused with a different server. + /// + private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selectedAuthServer) + { + if (tokens is null) + { + return; + } + + // The guard above guarantees a non-null container, but older nullable flow analysis on + // netstandard2.0/net472 may not preserve that narrowing, so capture a non-null local. + var cached = tokens!; + + if (_configuredClientId is not null) + { + if (!string.Equals(cached.ClientId, _configuredClientId, StringComparison.Ordinal)) + { + return; + } + + // Restore the issuer binding independently from the secret. A rotated configured secret + // must not make credentials previously bound to another issuer appear portable. + _clientCredentialsAuthorizationServer = cached.AuthorizationServer; + + if (string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal) && + string.Equals(cached.ClientSecret, _clientSecret, StringComparison.Ordinal)) + { + _tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod; + } + + return; + } + + if (!string.IsNullOrEmpty(_clientId)) + { + return; + } + + if (string.IsNullOrEmpty(cached.ClientId) || + !string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal)) + { + return; + } + + // Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the + // secret and auth method must already be in place before _clientId becomes observable. + _clientSecret ??= cached.ClientSecret; + _tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod; + _clientId = cached.ClientId; + _clientCredentialsAuthorizationServer = cached.AuthorizationServer; + } + + private bool CachedTokensMatchClientCredentials(TokenContainer? tokens, string? authorizationServer) => + tokens is not null && + string.Equals(tokens.AuthorizationServer, authorizationServer, StringComparison.Ordinal) && + string.Equals(tokens.ClientId, _clientId, StringComparison.Ordinal) && + string.Equals(tokens.ClientSecret, _clientSecret, StringComparison.Ordinal) && + string.Equals(tokens.TokenEndpointAuthMethod, _tokenEndpointAuthMethod, StringComparison.Ordinal); + + private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer) + { + if (_clientCredentialsAuthorizationServer is null || + string.Equals(_clientCredentialsAuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal)) + { + return; + } + + if (_configuredClientId is not null) + { + ThrowFailedToHandleUnauthorizedResponse( + $"The authorization server changed from '{_clientCredentialsAuthorizationServer}' to '{selectedAuthServer.OriginalString}', " + + "but explicitly configured client credentials cannot be assumed to be valid for the new authorization server."); + } + + _clientId = null; + _clientSecret = null; + _tokenEndpointAuthMethod = null; + _authServerMetadata = null; + _clientCredentialsAuthorizationServer = null; + } + [DoesNotReturn] private static void ThrowFailedToHandleUnauthorizedResponse(string message) => throw new McpException($"Failed to handle unauthorized response with 'Bearer' scheme. {message}"); @@ -1155,6 +1575,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient [LoggerMessage(Level = LogLevel.Error, Message = "Error fetching auth server metadata from {Endpoint}")] partial void LogErrorFetchingAuthServerMetadata(Exception ex, Uri endpoint); + [LoggerMessage(Level = LogLevel.Warning, Message = "Authorization server metadata from {Endpoint} does not satisfy PKCE requirements; skipping it and trying the next discovery endpoint.")] + partial void LogAuthServerMetadataMissingPkceSupport(Exception ex, Uri endpoint); + [LoggerMessage(Level = LogLevel.Information, Message = "Performing dynamic client registration with {RegistrationEndpoint}")] partial void LogPerformingDynamicClientRegistration(Uri registrationEndpoint); diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs index 5d145a5..88db6bd 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs @@ -34,6 +34,24 @@ public sealed class DynamicClientRegistrationOptions /// public string? InitialAccessToken { get; set; } + /// + /// Gets or sets the OIDC application_type sent during dynamic client registration. + /// + /// + /// + /// When , the SDK infers the value from the configured + /// : loopback hosts (localhost, + /// 127.0.0.1, [::1]) and custom-scheme URIs map to "native"; remote + /// http:// and https:// URIs map to "web". + /// + /// + /// When set explicitly, the value is sent without modification. Use this to account for + /// authorization-server-specific requirements or to retry a registration with an adjusted + /// application type. + /// + /// + public string? ApplicationType { get; set; } + /// /// Gets or sets the delegate used for handling the dynamic client registration response. /// diff --git a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs index 8496610..6ad8bf2 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs @@ -48,4 +48,10 @@ internal sealed class DynamicClientRegistrationRequest /// [JsonPropertyName("scope")] public string? Scope { get; init; } + + /// + /// Gets or sets the OIDC application type ("native" or "web") for the client. + /// + [JsonPropertyName("application_type")] + public string? ApplicationType { get; init; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs index 9dd440b..e1a1df3 100644 --- a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs @@ -25,6 +25,11 @@ internal sealed class ExchangeJwtBearerGrantOptions /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the token endpoint authentication method. + /// + public string? TokenEndpointAuthMethod { get; set; } + /// /// Gets or sets the scopes to request (space-separated). Optional. /// diff --git a/src/ModelContextProtocol.Core/Authentication/ITokenCache.cs b/src/ModelContextProtocol.Core/Authentication/ITokenCache.cs index 3dc6e63..62d57b9 100644 --- a/src/ModelContextProtocol.Core/Authentication/ITokenCache.cs +++ b/src/ModelContextProtocol.Core/Authentication/ITokenCache.cs @@ -3,6 +3,11 @@ namespace ModelContextProtocol.Authentication; /// /// Allows the client to cache access tokens beyond the lifetime of the transport. /// +/// +/// Implementations must be safe for concurrent use. A single cache instance may be shared by multiple +/// in-flight requests, and in particular can be invoked concurrently +/// (it is called on the request hot path without holding the provider's token-acquisition lock). +/// public interface ITokenCache { /// diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs index c949037..ecc5eb3 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs @@ -164,7 +164,18 @@ internal static class IdentityAssertionGrant ["client_id"] = options.ClientId, }; - if (!string.IsNullOrEmpty(options.ClientSecret)) + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint); + + if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal)) + { + formData.Remove("client_id"); + var credentials = $"{Uri.EscapeDataString(options.ClientId)}:{Uri.EscapeDataString(options.ClientSecret ?? string.Empty)}"; + httpRequest.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials))); + } + else if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_post", StringComparison.Ordinal) && + !string.IsNullOrEmpty(options.ClientSecret)) { formData["client_secret"] = options.ClientSecret!; } @@ -174,12 +185,7 @@ internal static class IdentityAssertionGrant formData["scope"] = options.Scope!; } - using var requestContent = new FormUrlEncodedContent(formData); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) - { - Content = requestContent - }; - + httpRequest.Content = new FormUrlEncodedContent(formData); httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs index 359c66f..41d2ea3 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -28,6 +28,12 @@ namespace ModelContextProtocol.Authentication; /// via the RFC 7523 JWT Bearer grant. /// /// +/// +/// Concurrency: a single provider instance may be shared across concurrent requests. Token +/// acquisition is coalesced through an internal lock, so if several callers observe an expired or +/// absent token at the same time, only one runs the exchange flow and the others await and reuse +/// its result. The cached token is refreshed at most once per expiry. +/// /// /// /// @@ -56,6 +62,15 @@ public sealed class IdentityAssertionGrantProvider private TokenContainer? _cachedTokens; + // Coalesces concurrent token acquisition so that when multiple in-flight requests observe an + // expired/absent token at the same time, only the first runs the exchange flow while the others + // await its result. Also serializes writes to _cachedTokens and _resolvedIdpTokenEndpoint. + // + // Intentionally not disposed: this instance is only ever used via WaitAsync/Wait/Release (never + // its AvailableWaitHandle), so SemaphoreSlim allocates no unmanaged resource and there is nothing + // to dispose. Do not access AvailableWaitHandle, or this field will need deterministic disposal. + private readonly SemaphoreSlim _tokenAcquisitionLock = new(1, 1); + /// /// Initializes a new instance of the class. /// @@ -87,6 +102,22 @@ public sealed class IdentityAssertionGrantProvider throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}"); } + if (options.TokenEndpointAuthMethod is not null && + options.TokenEndpointAuthMethod is not ("client_secret_basic" or "client_secret_post" or "none")) + { + throw new ArgumentException( + $"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.", + $"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}"); + } + + if (options.TokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" && + string.IsNullOrEmpty(options.ClientSecret)) + { + throw new ArgumentException( + $"{nameof(options.ClientSecret)} is required when {nameof(options.TokenEndpointAuthMethod)} is '{options.TokenEndpointAuthMethod}'.", + $"{nameof(options)}.{nameof(options.ClientSecret)}"); + } + _options = options; _httpClient = httpClient; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -105,12 +136,33 @@ public sealed class IdentityAssertionGrantProvider Uri authorizationServerUrl, CancellationToken cancellationToken = default) { - // Return cached token if still valid + // Return cached token if still valid. Read the field once into a local so a concurrent + // InvalidateCache (which nulls _cachedTokens) cannot turn this lock-free check into a null + // dereference or a null return between the null check and the return. + var cachedBeforeLock = _cachedTokens; + if (cachedBeforeLock is not null && !cachedBeforeLock.IsExpired) + { + return cachedBeforeLock; + } + + // Serialize the exchange so concurrent callers that all saw the expired/absent token don't + // each run the full multi-step flow. Waiters re-check the cache after acquiring the lock and + // reuse the token produced by whoever ran the exchange first. + using var _ = await _tokenAcquisitionLock.LockAsync(cancellationToken).ConfigureAwait(false); + if (_cachedTokens is not null && !_cachedTokens.IsExpired) { return _cachedTokens; } + return await AcquireAccessTokenAsync(resourceUrl, authorizationServerUrl, cancellationToken).ConfigureAwait(false); + } + + private async Task AcquireAccessTokenAsync( + Uri resourceUrl, + Uri authorizationServerUrl, + CancellationToken cancellationToken) + { _logger.LogDebug("Starting Cross-Application Access flow for resource {ResourceUrl}", resourceUrl); // Step 1: Discover MCP authorization server metadata to find the token endpoint @@ -161,6 +213,7 @@ public sealed class IdentityAssertionGrantProvider Assertion = jag, ClientId = _options.ClientId, ClientSecret = _options.ClientSecret, + TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata), Scope = _options.Scope, }, _httpClient, cancellationToken).ConfigureAwait(false); @@ -170,12 +223,68 @@ public sealed class IdentityAssertionGrantProvider return tokens; } + private string SelectTokenEndpointAuthMethod(AuthorizationServerMetadata metadata) + { + var supportedMethods = metadata.TokenEndpointAuthMethodsSupported; + if (_options.TokenEndpointAuthMethod is { } configuredMethod) + { + if (supportedMethods is { Count: > 0 } && + !supportedMethods.Contains(configuredMethod, StringComparer.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"The configured token endpoint authentication method '{configuredMethod}' is not advertised by the MCP authorization server."); + } + + return configuredMethod; + } + + if (string.IsNullOrEmpty(_options.ClientSecret)) + { + if (supportedMethods is null or { Count: 0 } || + supportedMethods.Contains("none", StringComparer.Ordinal)) + { + return "none"; + } + + throw new IdentityAssertionGrantException( + "The MCP authorization server does not advertise a token endpoint authentication method usable without a client secret."); + } + + // Preserve the provider's existing client_secret_post behavior when it is available. + // RFC 8414 defines this metadata as a list of supported methods, not a preference order. + if (supportedMethods is null or { Count: 0 } || + supportedMethods.Contains("client_secret_post", StringComparer.Ordinal)) + { + return "client_secret_post"; + } + + if (supportedMethods.Contains("client_secret_basic", StringComparer.Ordinal)) + { + return "client_secret_basic"; + } + + throw new IdentityAssertionGrantException( + "The MCP authorization server does not advertise a supported token endpoint authentication method."); + } + /// /// Clears any cached tokens, forcing a fresh token exchange on the next call to . /// + /// + /// This blocks until any token acquisition that is currently in progress completes, so that the + /// invalidation is not silently overwritten by a concurrent exchange storing a freshly obtained token. + /// public void InvalidateCache() { - _cachedTokens = null; + _tokenAcquisitionLock.Wait(); + try + { + _cachedTokens = null; + } + finally + { + _tokenAcquisitionLock.Release(); + } } private string? _resolvedIdpTokenEndpoint; diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs index c6cc7f8..ecbd055 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs @@ -16,6 +16,17 @@ public sealed class IdentityAssertionGrantProviderOptions /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the authentication method used at the MCP authorization server's token endpoint. + /// + /// + /// Supported values are client_secret_basic, client_secret_post, and none. + /// Set this to the method assigned to the pre-registered MCP client. When omitted, the provider + /// preserves its existing client_secret_post behavior when supported, then falls back to + /// another compatible method advertised by the authorization server. + /// + public string? TokenEndpointAuthMethod { get; set; } + /// /// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional. /// diff --git a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs index 8126137..68d4fb8 100644 --- a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs +++ b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs @@ -35,5 +35,56 @@ public sealed class TokenContainer /// public required DateTimeOffset ObtainedAt { get; set; } + /// + /// Gets or sets the OAuth client ID that these tokens were issued to. + /// + /// + /// This is persisted alongside the tokens so that a durable can survive a + /// process restart: on a cold start the client ID is restored from the cache, allowing a persisted + /// to be used without re-running dynamic client registration or prompting + /// the user to re-authorize. It reflects the client ID currently in use, whether that was obtained via + /// dynamic client registration, a client-id metadata document, or configured explicitly. On a cold + /// start it is only restored when no client ID has been configured, so an explicitly configured client + /// ID always takes precedence. + /// + public string? ClientId { get; set; } + + /// + /// Gets or sets the OAuth client secret that these tokens were issued to, if any. + /// + /// + /// This is persisted alongside so a durable can use a + /// persisted after a restart. It is only populated when a client secret was + /// issued (for example via dynamic client registration). + /// + /// Security: persisting this means a durable stores a confidential client + /// credential, not just the refresh token (which for a confidential client is not usable on its own). + /// Cache implementations that persist to durable storage must protect these values at rest (for + /// example with OS-level encryption or a dedicated secret store); otherwise a cache compromise would + /// expose a complete, usable credential set. + /// + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the token endpoint authentication method associated with . + /// + /// + /// This is persisted alongside so a refresh performed on a cold start uses the + /// same authentication method that was negotiated when the client was registered (for example + /// none for a public client rather than the default client_secret_post). + /// + public string? TokenEndpointAuthMethod { get; set; } + + /// + /// Gets or sets the authorization server issuer that issued the client credentials and tokens. + /// + /// + /// OAuth client credentials are bound to an authorization server and must not be reused with a + /// different issuer. Durable cache implementations should persist this value alongside the token + /// and client registration so restored credentials can be validated before use. + /// + public string? AuthorizationServer { get; set; } + internal bool IsExpired => ExpiresIn is not null && DateTimeOffset.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn.Value); } diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 015d404..7563acd 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -77,27 +77,57 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) { // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server. - // It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion, - // -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other - // application-level error). Don't fall back to SSE — that would mask the real signal - // and surface a misleading "session id required" error from the SSE GET path. - // Adopt the Streamable HTTP transport and throw the structured exception so the - // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport - // first makes the catch filter below leave the now-owned transport alone. + // Adopt it before surfacing the failure so the catch filter leaves the now-owned + // transport alone, and never mask the response by attempting deprecated SSE. LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; - throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + + if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + + // TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response + // body and status without consuming the network stream a second time. + throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); } else { // Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this // is an older deployment that expects the SSE transport (which establishes its // protocol via GET /sse rather than POST). Fall back to SSE per the original - // behavior. + // behavior. Capture the underlying error (status + body) before falling back so that, + // if SSE also fails, we can surface the real Streamable HTTP diagnostic to the caller + // instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526). + // This reads the response body a second time for the application/json case, where + // TryReadJsonRpcErrorAsync above already read it. HttpContent buffers after the first + // read, so this returns the same buffered content and is safe (not a second stream + // consumption). For the common non-JSON error responses (415, 405, plain text) + // TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read. + var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); + + if (IsDiscoverProbeRejection(message, response.StatusCode)) + { + // The server/discover probe is protocol negotiation, not transport detection. A server + // predating SEP-2575 rejects the session-less POST with 400 (can't parse the request) or + // 404 (requires Mcp-Session-Id on every non-initialize POST) whether it speaks Streamable + // HTTP or SSE, so neither status is evidence about which transport to use. McpClientImpl + // .ConnectAsync treats exactly these two statuses as "initialize-handshake server" and + // immediately retries with initialize on this same transport — and that attempt still + // falls back to SSE, so an SSE-only server is reached one POST later rather than not at + // all. Attempting SSE here instead spends a GET whose result is discarded on every + // connect to a Streamable-HTTP-only server that predates SEP-2575, and logs a "falling + // back to SSE transport" line that misreports settled protocol negotiation as a failure. + LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode); + + await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); + throw streamableHttpError; + } + LogStreamableHttpFailed(_name, response.StatusCode); await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); - await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false); + await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false); } } catch when (ActiveTransport is null) @@ -110,7 +140,16 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport } } - private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken) + /// + /// Returns when the failed request was the SEP-2575 server/discover probe and the + /// status is one of the two already reads as "this server requires the initialize + /// handshake", meaning the SSE fallback cannot contribute anything the initialize retry won't. + /// + private static bool IsDiscoverProbeRejection(JsonRpcMessage message, HttpStatusCode statusCode) => + statusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound && + message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }; + + private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken) { if (_options.KnownSessionId is not null) { @@ -128,6 +167,20 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport LogUsingSSE(_name); ActiveTransport = sseTransport; } + catch (Exception sseError) when (streamableHttpError is not null && sseError is not OperationCanceledException) + { + // SSE fallback also failed. Surface the original Streamable HTTP error as the primary failure so the + // user sees the real server diagnostic (e.g. 415 Unsupported Media Type) instead of the unrelated + // SSE-fallback error (e.g. a 405 from a Streamable-HTTP-only server that doesn't accept GET). Preserve + // the original status code and attach the SSE failure as the inner exception so neither is lost, and + // keep HttpRequestException as the surfaced type so existing callers can still catch it and read StatusCode. + await sseTransport.DisposeAsync().ConfigureAwait(false); + LogSseFallbackFailedAfterStreamableHttp(_name, sseError); + throw HttpRequestExceptionExtensions.Create( + streamableHttpError.Message, + sseError, + streamableHttpError.GetStatusCode()); + } catch { await sseTransport.DisposeAsync().ConfigureAwait(false); @@ -158,6 +211,9 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")] private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode); + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} server/discover probe rejected with status code {StatusCode}; skipping the SSE fallback so the initialize handshake is attempted instead.")] + private partial void LogSkippingSseFallbackForDiscoverProbe(string endpointName, HttpStatusCode statusCode); + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")] private partial void LogUsingStreamableHttp(string endpointName); @@ -166,4 +222,7 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")] private partial void LogUsingSSE(string endpointName); -} \ No newline at end of file + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} SSE fallback failed after Streamable HTTP also failed; surfacing both errors.")] + private partial void LogSseFallbackFailedAfterStreamableHttp(string endpointName, Exception sseError); +} diff --git a/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs index f97c346..ce95f65 100644 --- a/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs @@ -94,6 +94,24 @@ public sealed class HttpClientTransportOptions /// public string? KnownSessionId { get; set; } + /// + /// Gets or sets a value indicating whether the Streamable HTTP transport opens the standalone GET SSE stream + /// used to receive unsolicited server-to-client messages. + /// + /// + /// + /// This defaults to , matching the Streamable HTTP behavior of opening a standalone GET + /// SSE stream after initialization so the server can push unsolicited server-to-client messages. + /// + /// + /// Set this to for servers where the client does not need unsolicited server-to-client + /// messages, or when a long-lived standalone GET would block other requests under a constrained + /// connection pool. Direct responses and streaming responses to client POST requests + /// still work; only the standalone GET stream is skipped. + /// + /// + public bool EnableStandaloneGetStream { get; set; } = true; + /// /// Gets or sets a value indicating whether this transport endpoint is responsible for ending the session on dispose. /// diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 9ecfe1c..bccdd7a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -733,6 +733,12 @@ public abstract partial class McpClient : McpSession /// For a simpler API that handles both subscription and notification registration in a single call, /// use . /// + /// + /// The 2026-07-28 protocol revision (SEP-2575) removed resources/subscribe in favor of + /// with resourceSubscriptions. On a session that + /// negotiated that revision or later, this method throws an with + /// . + /// /// public Task SubscribeToResourceAsync( SubscribeRequestParams requestParams, @@ -931,6 +937,12 @@ public abstract partial class McpClient : McpSession /// The result of the request. /// is . /// The request failed or the server returned an error response. + /// + /// The 2026-07-28 protocol revision (SEP-2575) removed resources/unsubscribe in favor of + /// with resourceSubscriptions. On a session that + /// negotiated that revision or later, this method throws an with + /// . + /// public Task UnsubscribeFromResourceAsync( UnsubscribeRequestParams requestParams, CancellationToken cancellationToken = default) @@ -944,25 +956,17 @@ public abstract partial class McpClient : McpSession McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - /// /// Invokes a tool on the server. /// - /// The name of the tool to call on the server. + /// The name of the tool to invoke. /// An optional dictionary of arguments to pass to the tool. - /// An optional progress reporter for server notifications. + /// An optional progress handler for tracking operation progress. /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// The from the tool execution. + /// The result of the tool invocation. /// is . /// The request failed or the server returned an error response. - /// - /// This overload supports the tasks extension transparently. If the server responds with a - /// task handle rather than an immediate result, this method polls tasks/get until the - /// task completes, dispatching any entries through - /// the client's registered sampling and elicitation handlers along the way. Use - /// to disable automatic polling. - /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, @@ -1033,221 +1037,19 @@ public abstract partial class McpClient : McpSession /// The result of the request. /// is . /// The request failed or the server returned an error response. - /// - /// This method automatically includes the io.modelcontextprotocol/tasks extension capability - /// in the request metadata. If the server returns a task handle instead of an immediate result, - /// this method transparently polls tasks/get until the task completes, fails, or is cancelled. - /// Use - /// to receive the raw without automatic polling. - /// - public async ValueTask CallToolAsync( + public ValueTask CallToolAsync( CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); - - if (!augmented.IsTask) - { - return augmented.Result!; - } - - return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false); + return SendRequestAsync( + RequestMethods.ToolsCall, + requestParams, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken); } - - /// - /// Polls a task until it reaches a terminal state and returns the final . - /// - private async ValueTask PollTaskToCompletionAsync( - CreateTaskResult taskCreated, - CancellationToken cancellationToken) - { - // If the server claims InputRequired but never publishes new input requests after we have - // already responded to everything it asked for, treat that as a stuck task. The client - // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll - // loop when the server is misbehaving. The threshold is configurable via - // McpClientOptions.MaxConsecutiveStuckPolls. - int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls; - - string taskId = taskCreated.TaskId; - long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; - HashSet? resolvedRequestKeys = null; - bool isFirstPoll = true; - int consecutiveStuckPolls = 0; - - while (true) - { - // Skip the delay before the first poll: many tasks complete almost immediately and we - // don't want to pay the poll interval as gratuitous latency. - if (!isFirstPoll) - { - await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); - } - isFirstPoll = false; - - var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - - // Update poll interval if the server changed it. - if (taskResult.PollIntervalMs is { } newInterval) - { - pollIntervalMs = newInterval; - } - - switch (taskResult) - { - case CompletedTaskResult completed: - return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); - - case FailedTaskResult failed: - throw new McpException($"Task '{taskId}' failed: {failed.Error}"); - - case CancelledTaskResult: - throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); - - case InputRequiredTaskResult inputRequired: - // Dedup: only resolve input requests we haven't already responded to. - var newRequests = new Dictionary(); - if (inputRequired.InputRequests is { } incomingRequests) - { - foreach (var kvp in incomingRequests) - { - if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) - { - newRequests[kvp.Key] = kvp.Value; - } - } - } - - if (newRequests.Count > 0) - { - consecutiveStuckPolls = 0; - - IDictionary inputResponses; - try - { - inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // The input handler failed (e.g., ElicitationHandler threw or no handler was registered). - // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired - // until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating the original exception. - } - - throw; - } - - await UpdateTaskAsync(new UpdateTaskRequestParams - { - TaskId = taskId, - InputResponses = inputResponses, - }, cancellationToken).ConfigureAwait(false); - - resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); - foreach (var key in inputResponses.Keys) - { - resolvedRequestKeys.Add(key); - } - } - else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) - { - // Best-effort cancel of the server-side task so it doesn't leak until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating an exception. - } - - throw new McpException( - $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + - "without publishing new input requests after all previously requested inputs were resolved."); - } - - break; - - case WorkingTaskResult: - // Continue polling. - consecutiveStuckPolls = 0; - break; - - default: - throw new McpException( - $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); - } - } - } - - /// - /// Invokes a tool on the server with task extension support, returning the raw response - /// without automatic polling. The caller is responsible for handling task lifecycle. - /// - /// The request parameters to send. The tasks extension capability will be injected into the request metadata. - /// The to monitor for cancellation requests. The default is . - /// A that is either an immediate result or a task handle. - /// is . - /// The request failed or the server returned an error response. - /// - /// - /// Unlike , this method does not - /// automatically poll for task completion. If the server returns a , - /// the caller must manage polling via . - /// - /// - public async ValueTask> CallToolRawAsync( - CallToolRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - - var paramsWithMeta = new CallToolRequestParams - { - Name = requestParams.Name, - Arguments = requestParams.Arguments, - // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call - // (no task capability envelope) so the server returns a direct CallToolResult and never - // creates a task. - Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, - }; - - JsonRpcRequest jsonRpcRequest = new() - { - Method = RequestMethods.ToolsCall, - Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams), - }; - - JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); - - // Discriminate based on resultType field. - if (response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && - resultTypeNode?.GetValue() == "task") - { - var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult) - ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); - return new ResultOrCreatedTask(taskCreated); - } - - var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from response."); - return new ResultOrCreatedTask(callToolResult); - } - /// /// Sets the logging level for the server to control which log messages are sent to the client. /// @@ -1302,111 +1104,6 @@ public abstract partial class McpClient : McpSession McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The stable identifier of the task to retrieve. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - GetTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(GetTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksGet, - requestParams, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Provides input responses to a task that is in the state. - /// - /// The request parameters containing the task ID and input responses. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the update. - /// is . - /// The request failed or the server returned an error response. - public ValueTask UpdateTaskAsync( - UpdateTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksUpdate, - requestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The stable identifier of the task to cancel. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - CancelTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksCancel, - requestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult, - cancellationToken: cancellationToken); - } - - /// Converts a dictionary with values to a dictionary with values. private static Dictionary? ToArgumentsDictionary( IReadOnlyDictionary? arguments, JsonSerializerOptions options) { @@ -1425,43 +1122,4 @@ public abstract partial class McpClient : McpSession return result; } - // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: - // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) - { - JsonObject meta = existingMeta is not null - ? (JsonObject)existingMeta.DeepClone() - : []; - - if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) - { - capsRoot = []; - meta[MetaKeys.ClientCapabilities] = capsRoot; - } - - if (capsRoot["extensions"] is not JsonObject extensionsRoot) - { - extensionsRoot = []; - capsRoot["extensions"] = extensionsRoot; - } - - extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); - return meta; - } - - /// - /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks - /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session - /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel - /// on an older session is a programming error rather than a recoverable protocol condition. - /// - private void ThrowIfTasksNotSupported(string operationName) - { - if (!IsJuly2026OrLaterProtocol()) - { - throw new InvalidOperationException( - $"'{operationName}' requires a newer protocol revision that supports tasks. " + - $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'."); - } - } } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index da85e97..67bb21a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -12,7 +12,7 @@ public abstract partial class McpClient : McpSession /// /// Initializes a new instance of the class. /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] protected McpClient() { } @@ -29,14 +29,16 @@ public abstract partial class McpClient : McpSession /// /// /// This property provides identification details about the connected server, including its name and version. - /// It is populated during the initialization handshake and is available after a successful connection. + /// It is populated during the initialization handshake or from server/discover result metadata. /// /// /// This information can be useful for logging, debugging, compatibility checks, and displaying server /// information to users. /// /// - /// The client is not connected. + /// + /// The client is not connected, or the server omitted the optional server identity metadata. + /// public abstract Implementation ServerInfo { get; } /// @@ -73,7 +75,7 @@ public abstract partial class McpClient : McpSession public abstract Task Completion { get; } /// - /// Resolves input requests embedded in an by dispatching + /// Resolves input requests by dispatching /// each request to the appropriate registered handler. /// /// @@ -82,16 +84,9 @@ public abstract partial class McpClient : McpSession /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - private protected abstract ValueTask> ResolveInputRequestsAsync( + public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); - /// - /// Gets the maximum number of consecutive stuck-in- polls - /// allowed by before the client cancels and throws. - /// Sourced from . - /// - private protected abstract int MaxConsecutiveStuckPolls { get; } - /// /// Inspects a received cacheable result (tools/list, prompts/list, resources/list, /// resources/templates/list, or resources/read) so derived clients can emit diagnostics. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 39f6579..d1f2a9d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Collections.Concurrent; +using System.Net; using System.Text.Json; using System.Text.Json.Nodes; @@ -170,7 +171,8 @@ internal sealed partial class McpClientImpl : McpClient public override ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected."); /// - public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException("The client is not connected."); + public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException( + "The client is not connected, or the connected server did not provide optional server identity metadata."); /// public override string? ServerInstructions => _serverInstructions; @@ -179,10 +181,9 @@ internal sealed partial class McpClientImpl : McpClient public override Task Completion => _sessionHandler.CompletionTask; /// - private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; /// - private protected override async ValueTask> ResolveInputRequestsAsync( + public override async ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken) { @@ -294,24 +295,20 @@ internal sealed partial class McpClientImpl : McpClient // handshake. Instead, the client calls server/discover to learn the server's // capabilities and then begins sending normal RPCs that carry protocolVersion / // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion - // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize - // handshake when the server doesn't support it. The legacy branch below runs only when + // prefers the 2026-07-28 revision and automatically falls back to the initialize + // handshake when the server doesn't support it. The initialize branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). - if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) + if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion)) { - string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; - - // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being - // on the 2026-07-28 revision when SendRequestAsync is invoked for server/discover. - _negotiatedProtocolVersion = preferredVersion; - _sessionHandler.NegotiatedProtocolVersion = preferredVersion; + string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion; DiscoverResult? discoverResult = null; - bool fallbackToLegacy = false; + bool fallbackToInitialize = false; IList? serverSupportedVersions = null; + string discoverVersion = preferredVersion; - // Apply a probe timeout so dual-era clients don't block forever waiting for a - // legacy server that silently drops unknown methods (per stdio.mdx fallback rules). + // Apply a probe timeout so dual-path clients don't block forever waiting for an + // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is // always bounded by InitializationTimeout (only applied when it is the tighter bound). var probeTimeout = _options.DiscoverProbeTimeout; @@ -323,75 +320,104 @@ internal sealed partial class McpClientImpl : McpClient try { - discoverResult = await SendRequestAsync( - RequestMethods.ServerDiscover, - new DiscoverRequestParams(), - McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, - McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); } catch (UnsupportedProtocolVersionException ex) { - // Spec-recognized modern-server signal: -32022 with data.supported[]. The server is - // modern but doesn't speak our preferred version. Retry with a mutually supported - // version from data.supported[] instead of falling back to legacy initialize. - fallbackToLegacy = true; + // Spec-recognized SEP-2575 signal: -32022 with data.supported[]. The server is + // refusing our preferred version. Retry with a supported per-request metadata + // version if one exists; otherwise fall back to initialize with the highest + // mutually supported initialize-capable version. serverSupportedVersions = (IList)ex.Supported; + var retryVersion = serverSupportedVersions + .Where(McpProtocolVersions.PerRequestMetadataProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault(); + + if (retryVersion is not null) + { + if (_options.ProtocolVersion is { } pinnedVersion && + StringComparer.Ordinal.Compare(retryVersion, pinnedVersion) < 0) + { + throw new McpException( + $"The server does not support the requested protocol version '{pinnedVersion}'. " + + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + + $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."); + } + + discoverVersion = retryVersion; + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + } + else + { + fallbackToInitialize = true; + } } catch (MissingRequiredClientCapabilityException) { - // Spec-recognized modern-server signal: -32021. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32021. The server rejected // our capability set. Surface as-is (no fallback): the user must add capabilities. throw; } catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) { - // Spec-recognized modern-server signal: -32020. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32020. The server rejected // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no - // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. + // fallback): falling back to initialize wouldn't fix a malformed envelope. throw; } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. - // Any non-modern JSON-RPC error from the probe indicates a legacy server. + // Any non-SEP-2575 JSON-RPC error from the probe indicates an initialize-handshake server. // Common causes include MethodNotFound from a server that has no // server/discover handler, InvalidParams from a server confused by the // SEP-2575 _meta envelope, ParseError from a server that can't handle our - // payload shape, or any other transport-defined error. The three modern-server + // payload shape, or any other transport-defined error. The three SEP-2575 // signals (-32022 UnsupportedProtocolVersion, -32021 // MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and // never reach here. - fallbackToLegacy = true; + fallbackToInitialize = true; + } + catch (HttpRequestException ex) when ( + ex.GetStatusCode() is HttpStatusCode.BadRequest or HttpStatusCode.NotFound) + { + // A server predating SEP-2575 can reject the session-less server/discover POST at the + // HTTP layer instead of with a JSON-RPC error: 400 when it cannot parse the request, + // 404 when it requires Mcp-Session-Id on every non-initialize POST. A 400 carrying a + // structured JSON-RPC error is surfaced as McpProtocolException and handled above, so + // anything reaching here is plain or empty. Either way this is an initialize-handshake + // server, so fall back. Other statuses stay uncaught and surface to the caller. + fallbackToInitialize = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no - // response within a reasonable timeout means the server is legacy. Fall back. - fallbackToLegacy = true; + // response within a reasonable timeout means the server requires initialize. Fall back. + fallbackToInitialize = true; } - if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(discoverVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // 2026-07-28 version. Fall back to legacy initialize with the highest - // mutually-supported version from supportedVersions[]. - fallbackToLegacy = true; + // version we are using. Fall back to initialize with the highest + // mutually-supported initialize-capable version from supportedVersions[]. + fallbackToInitialize = true; serverSupportedVersions = discoverResult.SupportedVersions; } - if (fallbackToLegacy) + if (fallbackToInitialize) { - // Reset negotiated state and try legacy initialize. + // Reset negotiated state and try initialize. _negotiatedProtocolVersion = null; _sessionHandler.NegotiatedProtocolVersion = null; string fallbackVersion = serverSupportedVersions? - .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .Where(McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // A non-null ProtocolVersion is also the minimum: refuse to fall back below the // explicitly requested version. String.Compare is the spec's prescribed ordering @@ -403,33 +429,52 @@ internal sealed partial class McpClientImpl : McpClient $"The server does not support the requested protocol version '{pinnedVersion}'. " + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + (serverSupportedVersions is null - ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + ? "The server appears to require the initialize handshake." : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); } - await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + await PerformInitializeHandshakeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); } else { + var discoveredServerInfo = GetServerInfoFromDiscover(discoverResult!); + if (_logger.IsEnabled(LogLevel.Information)) { LogServerCapabilitiesReceived(_endpointName, capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + serverInfo: discoveredServerInfo is null + ? "(none)" + : JsonSerializer.Serialize(discoveredServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); } _serverCapabilities = discoverResult!.Capabilities; - _serverInfo = discoverResult.ServerInfo; + _serverInfo = discoveredServerInfo; _serverInstructions = discoverResult.Instructions; } + + async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken) + { + // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being + // on a per-request metadata revision when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + + return await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + } } else { - // Legacy initialize handshake. Reached only when the caller explicitly pinned a + // initialize handshake. Reached only when the caller explicitly pinned a // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // _options.ProtocolVersion is non-null here. - string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; - await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); + string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; + await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -449,10 +494,34 @@ internal sealed partial class McpClientImpl : McpClient } /// - /// Performs the legacy initialize handshake (initialize request + initialized notification), + /// Resolves the server identity from a server/discover result. The 2026-07-28 revision carries + /// serverInfo in the result's _meta/io.modelcontextprotocol/serverInfo field rather than + /// the result body. Identity is optional, so a server that omits it yields . + /// + private static Implementation? GetServerInfoFromDiscover(DiscoverResult discoverResult) + { + if (discoverResult.Meta is { } meta && + meta.TryGetPropertyValue(MetaKeys.ServerInfo, out JsonNode? serverInfoNode)) + { + if (serverInfoNode is null) + { + throw new JsonException( + $"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation."); + } + + return JsonSerializer.Deserialize(serverInfoNode, McpJsonUtilities.JsonContext.Default.Implementation) + ?? throw new JsonException( + $"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation."); + } + + return null; + } + + /// + /// Performs the initialize handshake (initialize request + initialized notification), /// records the negotiated protocol version, and stores the server capabilities/info/instructions. /// - private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) + private async Task PerformInitializeHandshakeAsync(string requestProtocol, CancellationToken cancellationToken) { var initializeResponse = await SendRequestAsync( RequestMethods.Initialize, @@ -479,18 +548,16 @@ internal sealed partial class McpClientImpl : McpClient _serverInstructions = initializeResponse.Instructions; // When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. - // When the user pinned the 2026-07-28 version but we fell back (e.g., legacy server rejected - // server/discover), or when no version was pinned, accept any supported response. This is the - // spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever - // version the server advertises. + // When no version was pinned, accept any supported initialize-handshake response. initialize cannot negotiate + // the 2026-07-28 and later protocol revisions. bool isResponseProtocolValid; - if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) + if (_options.ProtocolVersion is { } optionsProtocol && !McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) { isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; } else { - isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); } if (!isResponseProtocolValid) { @@ -525,7 +592,7 @@ internal sealed partial class McpClientImpl : McpClient _serverInstructions = resumeOptions.ServerInstructions; _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion ?? _options.ProtocolVersion - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; @@ -691,7 +758,7 @@ internal sealed partial class McpClientImpl : McpClient /// /// Injects the 2026-07-28 protocol's per-request _meta fields (protocol version, client info, /// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision - /// (SEP-2575). No-op on a legacy session. + /// (SEP-2575). No-op on an initialize-handshake session. /// private void InjectRequestMetaIfNeeded(JsonRpcRequest request) { diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cef..61a0613 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,16 +52,20 @@ public sealed class McpClientOptions /// /// /// + /// Supported values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, + /// and 2026-07-28. + /// + /// /// When (the default), the client prefers the latest revision (2026-07-28), /// which removed the initialize handshake and Streamable HTTP sessions. It probes with - /// server/discover and automatically falls back to the legacy initialize handshake, - /// downgrading to any version the server advertises, when the server does not support that revision. + /// server/discover and automatically falls back to the initialize handshake, + /// downgrading to an initialize-capable version the server advertises, when the server does not support that revision. /// /// /// When non-, this value is both the requested version and the minimum the client /// will accept: the client requests exactly this version and refuses to downgrade below it, throwing an /// instead of falling back. Setting it to 2026-07-28 therefore disables - /// the automatic legacy-server fallback, and setting it to a version that still supports Streamable HTTP + /// the automatic initialize-handshake server fallback, and setting it to a version that still supports Streamable HTTP /// sessions, such as 2025-11-25, forces the initialize handshake and fails if the server /// negotiates a different version. To try more than one version, leave this unset for automatic fallback /// or retry the connection with a different value. @@ -90,7 +94,7 @@ public sealed class McpClientOptions /// /// Gets or sets the timeout applied to the server/discover probe that the client issues - /// before falling back to the legacy initialize handshake. + /// before falling back to the initialize handshake. /// /// /// The probe timeout. The default value is 5 seconds. Use @@ -102,17 +106,17 @@ public sealed class McpClientOptions /// This timeout only has an effect when the client prefers the 2026-07-28 protocol revision, that is, /// when is (the default) or 2026-07-28. /// In that mode the client first probes the server with a - /// server/discover request. A legacy server that predates the 2026-07-28 revision may + /// server/discover request. A server that predates the 2026-07-28 revision may /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the - /// client concludes the server is legacy and falls back to the initialize handshake on the - /// same connection. When the caller pins a legacy , no probe is issued + /// client concludes the server requires initialize and falls back to that handshake on the + /// same connection. When the caller pins an initialize-capable , no probe is issued /// and this value has no effect. /// /// - /// The default is intentionally short so that dual-era clients fall back quickly against legacy + /// The default is intentionally short so that dual-path clients fall back quickly against initialize-handshake /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or - /// satellite links) where a short probe could trigger the legacy fallback before a server on the new revision - /// server has had a chance to respond. The probe is always also bounded by + /// satellite links) where a short probe could trigger the initialize fallback before a server on the + /// per-request metadata revision has had a chance to respond. The probe is always also bounded by /// , which governs the overall connect budget: if this value is /// greater than or equal to , the probe is effectively bounded by /// alone. @@ -147,41 +151,4 @@ public sealed class McpClientOptions } } - /// - /// Gets or sets the maximum number of consecutive task polls during which a task may report - /// without publishing any new input requests, before - /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws - /// an . - /// - /// - /// The maximum number of consecutive stuck polls allowed. The default value is 60. - /// - /// - /// - /// This guard prevents an unbounded poll loop when the server keeps a task in - /// but never publishes new input requests after the - /// client has already responded to every previously surfaced request. It only affects the - /// long-poll path used by ; - /// it does not affect direct calls. - /// - /// - /// Callers should size this value with the configured server-side poll interval in mind: the - /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs. - /// Setting this to a very small value can cause false positives for servers that are slow to - /// surface follow-up input requests; setting it too large can mask misbehaving servers. - /// - /// - /// The value is less than 1. - public int MaxConsecutiveStuckPolls - { - get; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1."); - } - field = value; - } - } = 60; } diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index 6a378ca..f9d353e 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -135,7 +135,7 @@ public sealed class McpClientTool : AIFunction // would lose that information. So, we only do the translation if there is no additional information to preserve. if (result.IsError is not true && result.StructuredContent is null && - result.Meta is not { Count: > 0 }) + !HasApplicationResultMetadata(result.Meta)) { switch (result.Content.Count) { @@ -150,6 +150,26 @@ public sealed class McpClientTool : AIFunction return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult); } + private static bool HasApplicationResultMetadata(JsonObject? meta) + { + if (meta is null) + { + return false; + } + + foreach (var property in meta) + { + // Server identity is protocol metadata already exposed through McpClient.ServerInfo. + // It should not force an otherwise simple tool result into its JSON envelope. + if (!string.Equals(property.Key, MetaKeys.ServerInfo, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + /// /// Invokes the tool on the server. /// diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index fb91898..99bdc1e 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -23,6 +23,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase private Task? _receiveTask; private readonly ILogger _logger; private readonly TaskCompletionSource _connectionEstablished; + private volatile bool _sseAdopted; /// /// SSE transport for a single session. Unlike stdio it does not launch a process, but connects to an existing server. @@ -66,7 +67,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase { LogTransportConnectFailed(Name, ex); await CloseAsync().ConfigureAwait(false); - throw new IOException("Failed to connect transport.", ex); + throw; } } @@ -107,6 +108,8 @@ internal sealed partial class SseClientSessionTransport : TransportBase throw HttpResponseMessageExtensions.CreateHttpRequestException(response, responseBody); } + + _sseAdopted = true; } private async Task CloseAsync() @@ -129,7 +132,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase } finally { - SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); } } @@ -190,7 +193,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase } else { - SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails + SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails { HttpStatusCode = failureStatusCode, Exception = ex, @@ -198,12 +201,22 @@ internal sealed partial class SseClientSessionTransport : TransportBase LogTransportReadMessagesFailed(Name, ex); _connectionEstablished.TrySetException(ex); - throw; } } finally { - SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + } + } + + private void SetSseDisconnected(Exception error) + { + // If AutoDetect is still probing SSE, leave its shared message channel open so it can + // retry with another transport. A successful POST means SSE was selected and owns the + // channel from that point on, matching Streamable HTTP's adoption behavior. + if (_options.TransportMode is not HttpTransportMode.AutoDetect || _sseAdopted) + { + SetDisconnected(error); } } diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 26ffdce..5b29449 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -26,6 +26,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa private string? _negotiatedProtocolVersion; private Task? _getReceiveTask; + private bool _streamableHttpAdopted; private volatile ClientTransportClosedException? _disconnectError; private readonly SemaphoreSlim _disposeLock = new(1, 1); @@ -54,7 +55,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa if (_options.KnownSessionId is { } knownSessionId) { SessionId = knownSessionId; - _getReceiveTask = ReceiveUnsolicitedMessagesAsync(); + _streamableHttpAdopted = true; + StartUnsolicitedMessageStreamIfEnabled(); } } @@ -67,19 +69,18 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a // JSON-RPC error envelope means the peer is signalling something application-level about // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the - // connect-time logic can react. For example, the three modern protocol error codes + // connect-time logic can react. For example, the three per-request metadata protocol error codes // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, // -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from - // legacy servers that don't understand the SEP-2575 _meta envelope) become generic - // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. + // initialize-handshake servers that don't understand the SEP-2575 _meta envelope) become generic + // McpProtocolException instances and trigger the initialize-handshake fallback path. // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. - // The three modern protocol error codes are also surfaced for non-400 status codes + // The three per-request metadata protocol error codes are also surfaced for non-400 status codes // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && - (response.StatusCode == HttpStatusCode.BadRequest || - IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -87,10 +88,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsModernProtocolErrorCode(McpErrorCode code) => - code is McpErrorCode.UnsupportedProtocolVersion - or McpErrorCode.MissingRequiredClientCapability - or McpErrorCode.HeaderMismatch; + internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) => + statusCode == HttpStatusCode.BadRequest || + (McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; /// /// Reads a JSON-RPC error envelope from an application/json response body, returning @@ -186,7 +188,10 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa if (response.Content.Headers.ContentType?.MediaType == "application/json") { var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - rpcResponseOrError = await ProcessMessageAsync(responseContent, rpcRequest, cancellationToken).ConfigureAwait(false); + if (responseContent.Length > 0) + { + rpcResponseOrError = await ProcessMessageAsync(responseContent, rpcRequest, cancellationToken).ConfigureAwait(false); + } } else if (response.Content.Headers.ContentType?.MediaType == "text/event-stream") { @@ -217,7 +222,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. - if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) + if (response.Headers.TryGetValues(McpHttpHeaders.SessionId, out var sessionIdValues)) { SessionId = sessionIdValues.FirstOrDefault(); } @@ -225,7 +230,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult); _negotiatedProtocolVersion = initializeResult?.ProtocolVersion; - _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); + _streamableHttpAdopted = true; + StartUnsolicitedMessageStreamIfEnabled(); } else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse) { @@ -238,6 +244,14 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa return response; } + private void StartUnsolicitedMessageStreamIfEnabled() + { + if (_options.EnableStandaloneGetStream) + { + _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); + } + } + /// /// Reads the protocol version from a request's _meta/io.modelcontextprotocol/protocolVersion field, /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Returns for messages that @@ -295,7 +309,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa { // If we're auto-detecting the transport and failed to connect, leave the message Channel open for the SSE transport. // This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case. - if (_options.TransportMode is not HttpTransportMode.AutoDetect || _getReceiveTask is not null) + if (_options.TransportMode is not HttpTransportMode.AutoDetect || _streamableHttpAdopted) { // _disconnectError is set when the server returns 404 indicating session expiry. // When null, this is a graceful client-initiated closure (no error). @@ -528,7 +542,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa string? protocolVersion, string? lastEventId = null) { - if (sessionId is not null) + if (sessionId is not null && McpProtocolVersions.SupportsHttpSessions(protocolVersion)) { headers.Add(McpHttpHeaders.SessionId, sessionId); } @@ -578,7 +592,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa headers.Add(McpHttpHeaders.Method, method); // Add Mcp-Name header for methods that target a specific named resource - string? name = message switch +#pragma warning disable MCPEXP002 + string? name = message.Context?.RoutingName ?? message switch { JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request => GetParamsStringProperty(request.Params, "name"), @@ -586,6 +601,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa => GetParamsStringProperty(request.Params, "uri"), _ => null, }; +#pragma warning restore MCPEXP002 if (name is not null) { diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index e686b45..0497d61 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -1,2160 +1,3 @@  - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - \ No newline at end of file + \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index da1b898..b193bb8 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -54,7 +54,13 @@ public static partial class McpJsonUtilities return options; } - internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => + /// + /// Gets the resolved for from the specified options. + /// + /// The type whose serialization metadata should be resolved. + /// The serializer options providing the type-info resolver chain. + /// The resolved . + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8); @@ -120,12 +126,6 @@ public static partial class McpJsonUtilities [JsonSerializable(typeof(ResourceUpdatedNotificationParams))] [JsonSerializable(typeof(RootsListChangedNotificationParams))] [JsonSerializable(typeof(ToolListChangedNotificationParams))] - [JsonSerializable(typeof(TaskStatusNotificationParams))] - [JsonSerializable(typeof(WorkingTaskNotificationParams))] - [JsonSerializable(typeof(CompletedTaskNotificationParams))] - [JsonSerializable(typeof(FailedTaskNotificationParams))] - [JsonSerializable(typeof(CancelledTaskNotificationParams))] - [JsonSerializable(typeof(InputRequiredTaskNotificationParams))] // MCP Request Params / Results [JsonSerializable(typeof(CallToolRequestParams))] @@ -174,19 +174,6 @@ public static partial class McpJsonUtilities [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(GetTaskRequestParams))] - [JsonSerializable(typeof(GetTaskResult))] - [JsonSerializable(typeof(WorkingTaskResult))] - [JsonSerializable(typeof(CompletedTaskResult))] - [JsonSerializable(typeof(FailedTaskResult))] - [JsonSerializable(typeof(CancelledTaskResult))] - [JsonSerializable(typeof(InputRequiredTaskResult))] - [JsonSerializable(typeof(UpdateTaskRequestParams))] - [JsonSerializable(typeof(UpdateTaskResult))] - [JsonSerializable(typeof(CancelTaskRequestParams))] - [JsonSerializable(typeof(CancelTaskResult))] - [JsonSerializable(typeof(CreateTaskResult))] - // MCP Content [JsonSerializable(typeof(ContentBlock))] [JsonSerializable(typeof(TextContentBlock))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index e1bd084..1747a63 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -55,7 +55,7 @@ public abstract partial class McpSession : IAsyncDisposable /// definition of "is this peer speaking the 2026-07-28 or later revision" used by both the client and server. /// internal bool IsJuly2026OrLaterProtocol() => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); /// /// Sends a JSON-RPC request to the connected session and waits for a response. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index a0122c9..d2b0745 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -29,18 +29,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent."); /// - /// All protocol versions supported by this implementation. The version constants live on + /// All protocol versions supported by this implementation. The era-specific lists live on /// so the shared source file is the single source of truth. - /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. /// - internal static readonly string[] SupportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + internal static readonly string[] SupportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// Checks if the given protocol version supports priming events. @@ -53,7 +45,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// internal static bool SupportsPrimingEvent(string? protocolVersion) { - const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + const string MinResumabilityProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; if (protocolVersion is null) { @@ -81,7 +73,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable return false; } - return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; + return string.Compare(protocolVersion, McpProtocolVersions.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; } private readonly bool _isServer; @@ -162,7 +154,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion)) + if (McpProtocolVersions.RequiresPerRequestMetadata(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", @@ -409,14 +401,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { - // Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any - // filters run so they (and downstream handlers) can read client info / capabilities / - // protocol version / log level without re-parsing. - if (_isServer && message is JsonRpcRequest incomingRequest) - { - PopulateContextFromMeta(incomingRequest); - } - Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -552,65 +536,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable return result; } - /// - /// Reads the 2026-07-28 protocol's per-request _meta fields off the request and projects them onto - /// so they're available without re-parsing throughout the pipeline. - /// - /// - /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, - /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field - /// that's already set on the context (e.g., - /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone - /// unless explicitly overwritten by a non-null value parsed here. - /// - internal static void PopulateContextFromMeta(JsonRpcRequest request) - { - if (request.Params is not JsonObject paramsObj) - { - return; - } - - if (paramsObj["_meta"] is not JsonObject metaObj) - { - return; - } - - var context = request.Context ??= new JsonRpcMessageContext(); - - if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && - protocolVersion.TryGetValue(out string? protocolVersionValue)) - { - // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already - // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with - // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant 2026-07-28 clients recognize as a modern-server signal and surface as-is rather - // than mistaking it for a legacy server and falling back to the initialize handshake. - if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) - { - throw new McpProtocolException( - $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", - McpErrorCode.HeaderMismatch); - } - - context.ProtocolVersion = protocolVersionValue; - } - - if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) - { - context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); - } - - if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) - { - context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); - } - - if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) - { - context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); - } - } - /// /// Injects the 2026-07-28 protocol's per-request _meta fields into an outgoing request. /// Protocol version and client info overwrite any existing values; client capabilities are merged @@ -618,7 +543,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// /// /// Used by on a 2026-07-28 or later session to carry protocol version, client - /// info, and client capabilities on every outgoing request (replacing what the legacy + /// info, and client capabilities on every outgoing request (replacing what the /// initialize handshake previously negotiated once). /// internal static void InjectRequestMeta( @@ -970,9 +895,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable tags.Add("network.protocol.name", "http"); } - if (NegotiatedProtocolVersion is not null) + string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; + if (protocolVersion is not null) { - tags.Add("mcp.protocol.version", NegotiatedProtocolVersion); + tags.Add("mcp.protocol.version", protocolVersion); } if (activity is { IsAllDataRequested: true }) diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 455c367..3fbef03 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -7,6 +7,7 @@ ModelContextProtocol.Core Core .NET SDK for the Model Context Protocol (MCP) README.md + $(MSBuildThisFileDirectory)CompatibilitySuppressions.xml $(NoWarn);MCPEXP001 $(NoWarn);MCPEXP001;MCPEXP003 - - false diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs new file mode 100644 index 0000000..833f2b2 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -0,0 +1,397 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware client operations. +/// +public static class McpTasksClientExtensions +{ + /// + /// Calls a tool and returns either an immediate result or a created task. + /// + public static async ValueTask> CallToolAsTaskAsync( + this McpClient client, + CallToolRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var paramsWithMeta = new CallToolRequestParams + { + Name = requestParams.Name, + Arguments = requestParams.Arguments, + Meta = IsJuly2026OrLaterProtocol(client) ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, + }; + + JsonRpcRequest jsonRpcRequest = new() + { + Method = RequestMethods.ToolsCall, + Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + string.Equals(resultTypeNode?.GetValue(), "task", StringComparison.Ordinal)) + { + var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult) + ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); + return new ResultOrCreatedTask(taskCreated); + } + + var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from response."); + return new ResultOrCreatedTask(callToolResult); + } + + /// + /// Calls a tool and, if needed, polls the created task to completion. + /// + public static async ValueTask CallToolWithPollingAsync( + this McpClient client, + CallToolRequestParams requestParams, + int maxConsecutiveStuckPolls = 60, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var augmented = await client.CallToolAsTaskAsync(requestParams, cancellationToken).ConfigureAwait(false); + if (!augmented.IsTask) + { + return augmented.Result!; + } + + return await PollTaskToCompletionAsync(client, augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a task by ID. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Retrieves a task using explicit request parameters. + /// + public static async ValueTask GetTaskAsync( + this McpClient client, + GetTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); + requestParams = new GetTaskRequestParams + { + TaskId = requestParams.TaskId, + Meta = GetMetaWithTaskCapability(requestParams.Meta), + }; + JsonRpcRequest jsonRpcRequest = CreateTaskRequest( + TasksProtocol.MethodTasksGet, + JsonSerializer.SerializeToNode(requestParams, McpTasksJsonContext.Default.GetTaskRequestParams), + requestParams.TaskId); + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.GetTaskResult) + ?? throw new JsonException("Unexpected JSON result in response."); + } + + /// + /// Updates a task with input responses. + /// + public static async ValueTask UpdateTaskAsync( + this McpClient client, + UpdateTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync)); + + // Manually construct the JSON params because InputResponses is backed by an internal + // property in Core that the extension's source-gen context cannot access for serialization. + JsonObject paramsObj = new() + { + ["taskId"] = requestParams.TaskId, + ["_meta"] = GetMetaWithTaskCapability(requestParams.Meta), + }; + + if (requestParams.InputResponses is { Count: > 0 } inputResponses) + { + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + inputResponses, + McpJsonUtilities.DefaultOptions.GetTypeInfo>()); + } + + JsonRpcRequest jsonRpcRequest = CreateTaskRequest( + TasksProtocol.MethodTasksUpdate, + paramsObj, + requestParams.TaskId); + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult) + ?? new UpdateTaskResult(); + } + + /// + /// Requests task cancellation by ID. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Requests task cancellation using explicit request parameters. + /// + public static async ValueTask CancelTaskAsync( + this McpClient client, + CancelTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); + requestParams = new CancelTaskRequestParams + { + TaskId = requestParams.TaskId, + Meta = GetMetaWithTaskCapability(requestParams.Meta), + }; + JsonRpcRequest jsonRpcRequest = CreateTaskRequest( + TasksProtocol.MethodTasksCancel, + JsonSerializer.SerializeToNode(requestParams, McpTasksJsonContext.Default.CancelTaskRequestParams), + requestParams.TaskId); + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.CancelTaskResult) + ?? new CancelTaskResult(); + } + + private static JsonRpcRequest CreateTaskRequest(string method, JsonNode? parameters, string taskId) => + new() + { + Method = method, + Params = parameters, + Context = new JsonRpcMessageContext + { + RoutingName = taskId, + }, + }; + + private static async ValueTask PollTaskToCompletionAsync( + McpClient client, + CreateTaskResult taskCreated, + int maxConsecutiveStuckPolls, + CancellationToken cancellationToken) + { + string taskId = taskCreated.TaskId; + long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; + HashSet? resolvedRequestKeys = null; + bool isFirstPoll = true; + int consecutiveStuckPolls = 0; + + while (true) + { + if (!isFirstPoll) + { + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); + } + + isFirstPoll = false; + + var taskResult = await GetTaskAsync(client, taskId, cancellationToken).ConfigureAwait(false); + if (taskResult.PollIntervalMs is { } newInterval) + { + pollIntervalMs = newInterval; + } + + switch (taskResult) + { + case CompletedTaskResult completed: + return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); + + case FailedTaskResult failed: + throw new McpException($"Task '{taskId}' failed: {failed.Error}"); + + case CancelledTaskResult: + throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); + + case InputRequiredTaskResult inputRequired: + var newRequests = new Dictionary(); + if (inputRequired.InputRequests is { } incomingRequests) + { + foreach (var kvp in incomingRequests) + { + if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) + { + newRequests[kvp.Key] = kvp.Value; + } + } + } + + if (newRequests.Count > 0) + { + consecutiveStuckPolls = 0; + + IDictionary inputResponses; + try + { + inputResponses = await client.ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw; + } + + await UpdateTaskAsync(client, new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, cancellationToken).ConfigureAwait(false); + + resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); + foreach (var key in inputResponses.Keys) + { + resolvedRequestKeys.Add(key); + } + } + else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw new McpException( + $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + + "without publishing new input requests after all previously requested inputs were resolved."); + } + + break; + + case WorkingTaskResult: + consecutiveStuckPolls = 0; + break; + + default: + throw new McpException($"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); + } + } + } + + private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) + { + JsonObject meta = existingMeta is not null + ? (JsonObject)existingMeta.DeepClone() + : []; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) + { + capsRoot = []; + meta[MetaKeys.ClientCapabilities] = capsRoot; + } + + if (capsRoot["extensions"] is not JsonObject extensionsRoot) + { + extensionsRoot = []; + capsRoot["extensions"] = extensionsRoot; + } + + if (!extensionsRoot.ContainsKey(TasksProtocol.ExtensionId)) + { + extensionsRoot[TasksProtocol.ExtensionId] = new JsonObject(); + } + + return meta; + } + + private static bool IsJuly2026OrLaterProtocol(McpClient client) => + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(client.NegotiatedProtocolVersion); + + private static void ThrowIfTasksNotSupported(McpClient client, string operationName) + { + if (!IsJuly2026OrLaterProtocol(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires a newer protocol revision that supports tasks " + + $"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later). " + + $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs new file mode 100644 index 0000000..13c92ee --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides source-generated JSON serialization metadata for MCP Tasks extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CreateTaskResult))] +[JsonSerializable(typeof(GetTaskRequestParams))] +[JsonSerializable(typeof(GetTaskResult))] +[JsonSerializable(typeof(WorkingTaskResult))] +[JsonSerializable(typeof(CompletedTaskResult))] +[JsonSerializable(typeof(FailedTaskResult))] +[JsonSerializable(typeof(CancelledTaskResult))] +[JsonSerializable(typeof(InputRequiredTaskResult))] +[JsonSerializable(typeof(UpdateTaskRequestParams))] +[JsonSerializable(typeof(UpdateTaskResult))] +[JsonSerializable(typeof(CancelTaskRequestParams))] +[JsonSerializable(typeof(CancelTaskResult))] +[JsonSerializable(typeof(TaskStatusNotificationParams))] +[JsonSerializable(typeof(WorkingTaskNotificationParams))] +[JsonSerializable(typeof(CompletedTaskNotificationParams))] +[JsonSerializable(typeof(FailedTaskNotificationParams))] +[JsonSerializable(typeof(CancelledTaskNotificationParams))] +[JsonSerializable(typeof(InputRequiredTaskNotificationParams))] +public sealed partial class McpTasksJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj new file mode 100644 index 0000000..cfeef00 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -0,0 +1,50 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Tasks + MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs similarity index 92% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs index ee458d0..f4d6ff6 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task. diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs similarity index 77% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs index 4d06686..5176cfa 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/cancel request. This is an empty acknowledgement. @@ -19,4 +20,9 @@ namespace ModelContextProtocol.Protocol; /// public sealed class CancelTaskResult : Result { + /// Initializes a new task cancellation acknowledgement. + public CancelTaskResult() + { + ResultType = "complete"; + } } diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs index 6d9bac2..7cbb5d1 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs @@ -1,8 +1,9 @@ +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result returned by a server when it creates a task in lieu of a standard result. @@ -24,6 +25,14 @@ namespace ModelContextProtocol.Protocol; /// public sealed class CreateTaskResult : Result { + /// + /// Initializes a new instance of the class. + /// + public CreateTaskResult() + { + ResultType = "task"; + } + /// /// Gets or sets the stable identifier for this task. /// diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs index 52b82d9..a865bf6 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/get request to poll for task completion. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs index 3fea8cc..4d083d8 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/get request, containing the full task state. @@ -169,7 +169,7 @@ public abstract class GetTaskResult : Result } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -315,7 +315,7 @@ public abstract class GetTaskResult : Result foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs similarity index 94% rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs index 3b705a9..90ee12d 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the status of an MCP task. diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs similarity index 91% rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs index 87b8574..1e9100c 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs @@ -1,4 +1,5 @@ -namespace ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a request that supports task-augmented execution, which may be either @@ -31,7 +32,10 @@ public class ResultOrCreatedTask where TResult : Result /// The standard result returned by the server. public ResultOrCreatedTask(TResult result) { - Throw.IfNull(result); + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } _result = result; } @@ -41,7 +45,10 @@ public class ResultOrCreatedTask where TResult : Result /// The task creation result returned by the server. public ResultOrCreatedTask(CreateTaskResult taskCreated) { - Throw.IfNull(taskCreated); + if (taskCreated is null) + { + throw new ArgumentNullException(nameof(taskCreated)); + } _taskCreated = taskCreated; } diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs index 4e859a2..020896a 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a notifications/tasks notification sent by the server @@ -171,7 +171,7 @@ public abstract class TaskStatusNotificationParams : NotificationParams } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -311,7 +311,7 @@ public abstract class TaskStatusNotificationParams : NotificationParams foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); @@ -390,4 +390,3 @@ public sealed class InputRequiredTaskNotificationParams : TaskStatusNotification [JsonPropertyName("inputRequests")] public IDictionary? InputRequests { get; set; } } - diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs similarity index 93% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs index 07b45de..aeeb2c7 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/update request to provide input responses diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs similarity index 74% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs index 531039c..0a74886 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/update request. This is an empty acknowledgement. @@ -18,4 +19,9 @@ namespace ModelContextProtocol.Protocol; /// public sealed class UpdateTaskResult : Result { + /// Initializes a new task update acknowledgement. + public UpdateTaskResult() + { + ResultType = "complete"; + } } diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs similarity index 99% rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs index 337cb19..6851f21 100644 --- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an interface for storing and managing the lifecycle of MCP tasks. diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs similarity index 73% rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs index 7625982..d1014c1 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an in-memory implementation of for development and testing scenarios. @@ -15,13 +15,21 @@ namespace ModelContextProtocol.Server; /// Tasks are not persisted across process restarts. /// /// -/// For production scenarios requiring durability, session isolation, or TTL-based cleanup, -/// implement a custom . +/// Tasks created with a are discarded once their time-to-live +/// elapses (as permitted by SEP-2663): an expired task is removed on access, and an opportunistic +/// throttled sweep reclaims expired tasks that are never polled again. Tasks created without a +/// time-to-live are retained until the process exits. +/// +/// +/// For production scenarios requiring durability, session isolation, or more advanced retention +/// policies, implement a custom . /// /// public class InMemoryMcpTaskStore : IMcpTaskStore { private readonly ConcurrentDictionary _tasks = new(); + private static readonly long s_sweepIntervalTicks = TimeSpan.FromSeconds(30).Ticks; + private long _lastSweepTicks = DateTimeOffset.UtcNow.UtcTicks; /// /// Gets or sets the default poll interval in milliseconds for new tasks. @@ -32,13 +40,19 @@ public class InMemoryMcpTaskStore : IMcpTaskStore /// /// Gets or sets the default time-to-live for new tasks, or for unlimited. /// + /// + /// When set to a positive value, tasks are discarded once this duration elapses from their + /// creation. A or non-positive value keeps tasks until the process exits. + /// public TimeSpan? DefaultTimeToLive { get; set; } /// public Task CreateTaskAsync(CancellationToken cancellationToken = default) { - var taskId = Guid.NewGuid().ToString("N"); var now = DateTimeOffset.UtcNow; + SweepExpired(now); + + var taskId = Guid.NewGuid().ToString("N"); var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs); _tasks[taskId] = info; @@ -49,8 +63,19 @@ public class InMemoryMcpTaskStore : IMcpTaskStore /// public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) { - _tasks.TryGetValue(taskId, out var info); - return Task.FromResult(info); + var now = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var info)) + { + if (IsExpired(info, now)) + { + _tasks.TryRemove(taskId, out _); + return Task.FromResult(null); + } + + return Task.FromResult(info); + } + + return Task.FromResult(null); } /// @@ -198,6 +223,35 @@ public class InMemoryMcpTaskStore : IMcpTaskStore private static bool IsTerminal(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; + private static bool IsExpired(McpTaskInfo info, DateTimeOffset now) => + info.TimeToLive is { } ttl && ttl > TimeSpan.Zero && now - info.CreatedAt >= ttl; + + private void SweepExpired(DateTimeOffset now) + { + long last = Interlocked.Read(ref _lastSweepTicks); + if (now.UtcTicks - last < s_sweepIntervalTicks) + { + return; + } + + // Ensure only one caller runs the sweep per interval; concurrent callers skip it. + if (Interlocked.CompareExchange(ref _lastSweepTicks, now.UtcTicks, last) != last) + { + return; + } + + foreach (var kvp in _tasks) + { + if (IsExpired(kvp.Value, now)) + { + // TaskId values are unique GUIDs that are never reused, and CreatedAt/TimeToLive + // are immutable after creation, so an entry judged expired here stays expired. + // Removing by key is therefore safe even if the value was concurrently updated. + _tasks.TryRemove(kvp.Key, out _); + } + } + } + private void Update(string taskId, Func transform) { SpinWait spin = default; diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs similarity index 92% rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs index 14447bd..c1cedf6 100644 --- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs @@ -1,6 +1,6 @@ using ModelContextProtocol.Protocol; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides data for the event. diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs new file mode 100644 index 0000000..9045cea --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs @@ -0,0 +1,16 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Specifies how a tool call participates in the MCP Tasks extension. +/// +public enum McpTaskExecutionMode +{ + /// The tool always executes synchronously. + Synchronous, + + /// The tool executes as a task when the client declares the Tasks extension. + Optional, + + /// The tool requires the client to declare the Tasks extension. + Required, +} diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs similarity index 94% rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs index d275fb3..a9a86ed 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the state of a task in an . diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs new file mode 100644 index 0000000..e61466a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -0,0 +1,476 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for to enable MCP Tasks support. +/// +public static class McpTasksBuilderExtensions +{ + /// + /// Enables MCP Tasks support backed by the specified task store. + /// + /// + /// Tasks are implemented as an alternate-result call-tool filter. Alternate-result filters registered before + /// the Tasks filter run before task creation. Filters registered after it, along with all ordinary call-tool + /// filters, run in the background before the tool. + /// + /// The server builder. + /// The task store. + /// The builder provided in . + public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) + => WithTasks(builder, store, static _ => { }); + + /// + /// Enables MCP Tasks support backed by the specified task store. + /// + /// The server builder. + /// The task store. + /// A callback that configures per-call task execution behavior. + /// The builder provided in . + public static IMcpServerBuilder WithTasks( + this IMcpServerBuilder builder, + IMcpTaskStore store, + Action configure) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(configure); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (store is null) throw new ArgumentNullException(nameof(store)); + if (configure is null) throw new ArgumentNullException(nameof(configure)); +#endif + + McpTasksOptions taskOptions = new(); + configure(taskOptions); +#if NET + ArgumentNullException.ThrowIfNull(taskOptions.ExecutionModeSelector); +#else + if (taskOptions.ExecutionModeSelector is null) throw new ArgumentNullException(nameof(taskOptions.ExecutionModeSelector)); +#endif + + // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the + // background task body has somewhere to report failures. It is optional: if no logging is + // registered, the options fall back to NullLoggerFactory. + builder.Services.AddSingleton>( + sp => new McpTasksConfigureOptions( + store, + sp.GetRequiredService(), + sp.GetService(), + taskOptions)); + return builder; + } + + private sealed class McpTasksConfigureOptions( + IMcpTaskStore store, + IServiceScopeFactory serviceScopeFactory, + ILoggerFactory? loggerFactory, + McpTasksOptions taskOptions) : IConfigureOptions + { + private readonly IMcpTaskStore _store = store; + private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory; + private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + private readonly McpTasksOptions _taskOptions = taskOptions; + private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); + + public void Configure(McpServerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) throw new ArgumentNullException(nameof(options)); +#endif + + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId)) + { + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + } + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksGet, + RoutingNameParameter = "taskId", + Handler = HandleGetTask, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + RoutingNameParameter = "taskId", + Handler = HandleUpdateTask, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksCancel, + RoutingNameParameter = "taskId", + Handler = HandleCancelTask, + }); + + // Use a filter rather than a handler so it wraps around Core's tool dispatch. + // This ensures it intercepts tool calls BEFORE the tool is invoked, allowing + // it to spawn background execution and return the task alternate immediately. + options.Filters.Request.CallToolWithAlternateFilters.Insert( + options.Filters.Request.CallToolWithAlternateFilters.Count, + async (request, next, cancellationToken) => + { + var executionMode = _taskOptions.ExecutionModeSelector(request); + if (executionMode is not McpTaskExecutionMode.Synchronous and + not McpTaskExecutionMode.Optional and + not McpTaskExecutionMode.Required) + { + throw new InvalidOperationException( + $"{nameof(McpTasksOptions.ExecutionModeSelector)} returned an invalid {nameof(McpTaskExecutionMode)} value."); + } + + if (executionMode == McpTaskExecutionMode.Synchronous) + { + return await next(request, cancellationToken).ConfigureAwait(false); + } + + if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || + !HasTaskExtensionOptIn(request.JsonRpcRequest)) + { + if (executionMode == McpTaskExecutionMode.Required) + { + throw CreateMissingTasksCapabilityException(); + } + + return await next(request, cancellationToken).ConfigureAwait(false); + } + + return await RunAsTaskAsync(next, request, cancellationToken).ConfigureAwait(false); + }); + } + + private async ValueTask> RunAsTaskAsync( + McpRequestHandler> next, + RequestContext request, + CancellationToken cancellationToken) + { + var executionScope = _serviceScopeFactory.CreateAsyncScope(); + var executionRequest = new RequestContext( + request.Server, + request.JsonRpcRequest, + request.Params) + { + MatchedPrimitive = request.MatchedPrimitive, + Services = executionScope.ServiceProvider, + }; + + McpTaskInfo taskInfo; + try + { + taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await executionScope.DisposeAsync().ConfigureAwait(false); + throw; + } + + var taskId = taskInfo.TaskId; + executionRequest.Server = request.Server.WithMcpTaskOutgoingRequestInterceptor(taskId, _store); + var cts = new CancellationTokenSource(); + _cancellationSources[taskId] = cts; + + // Capture the token before dispatching. Cancellation can remove and dispose the source + // before the background delegate starts. + var taskCancellationToken = cts.Token; + _ = Task.Run( + () => ExecuteTaskAsync(next, executionRequest, taskId, taskCancellationToken, executionScope), + CancellationToken.None); + + return ResultOrAlternate.FromAlternate( + ToCreateTaskResult(taskInfo), + McpTasksJsonContext.Default.CreateTaskResult); + } + + private async Task ExecuteTaskAsync( + McpRequestHandler> next, + RequestContext request, + string taskId, + CancellationToken taskCancellationToken, + AsyncServiceScope executionScope) + { + try + { + try + { + await ExecuteToolPipelineAsync(next, request, taskId, taskCancellationToken).ConfigureAwait(false); + } + finally + { + await executionScope.DisposeAsync().ConfigureAwait(false); + } + } + catch (Exception outer) + { + // Expected outcomes are recorded by ExecuteToolPipelineAsync. Reaching here means a + // store operation, task scope, or service scope failed. Record it best-effort. + _logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId); + + try + { + var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (Exception storeEx) + { + _logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId); + } + } + finally + { + if (_cancellationSources.TryRemove(taskId, out var registeredCts)) + { + registeredCts.Dispose(); + } + } + } + + private async Task ExecuteToolPipelineAsync( + McpRequestHandler> next, + RequestContext request, + string taskId, + CancellationToken taskCancellationToken) + { + try + { + var augmented = await next(request, taskCancellationToken).ConfigureAwait(false); + + if (augmented.IsAlternate) + { + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InternalError, + Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + return; + } + + var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) + { + await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch (InputRequiredException) + { + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidRequest, + Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (McpProtocolException mcpEx) + { + // SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape. + var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (Exception ex) + { + // Non-protocol exceptions are wrapped as CallToolResult { IsError = true }, + // matching Core's BuildInitialCallToolFilter behavior. + var errorResult = new CallToolResult + { + IsError = true, + Content = [new TextContentBlock + { + Text = ex is McpException + ? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}" + : $"An error occurred invoking '{request.Params?.Name}'.", + }], + }; + var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + } + + private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet); + GateToTasksCapability(request); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + if (info is null) + { + throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams); + } + + return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult); + } + + private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate); + GateToTasksCapability(request); + + var taskId = request.Params?["taskId"]?.GetValue() + ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams); + + // Deserialize inputResponses using Core's options which can access the internal + // InputResponsesCore backing property on RequestParams. The extension's source-gen + // context cannot see that internal member. + var inputResponses = request.Params?["inputResponses"]?.Deserialize( + McpJsonUtilities.DefaultOptions.GetTypeInfo>()) + ?? new Dictionary(); + + await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false); + + return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult); + } + + private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel); + GateToTasksCapability(request); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + + if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + + return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult); + } + + private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method) + { + if (!IsJuly2026OrLaterProtocolRequest(request)) + { + throw new McpProtocolException( + $"The method '{method}' requires a newer protocol revision that supports tasks " + + $"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later); " + + $"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + } + + private static void GateToTasksCapability(JsonRpcRequest request) + { + if (!HasTaskExtensionOptIn(request)) + { + throw CreateMissingTasksCapabilityException(); + } + } + + private static MissingRequiredClientCapabilityException CreateMissingTasksCapabilityException() => + new( + new ClientCapabilities + { + Extensions = new Dictionary + { + [TasksProtocol.ExtensionId] = new JsonObject(), + }, + }, + $"The request requires the '{TasksProtocol.ExtensionId}' client extension capability."); + + private static bool HasTaskExtensionOptIn(JsonRpcRequest request) => + request.Context?.ClientCapabilities?.Extensions?.ContainsKey(TasksProtocol.ExtensionId) is true || + request.Params?["_meta"]?[MetaKeys.ClientCapabilities]?["extensions"] is JsonObject extensions && + extensions.ContainsKey(TasksProtocol.ExtensionId); + + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion); + + private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() + { + TaskId = info.TaskId, + Status = info.Status, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + }; + + private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch + { + McpTaskStatus.Working => new WorkingTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.Completed => new CompletedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), + ResultType = "complete", + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), + ResultType = "complete", + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + InputRequests = info.InputRequests is IDictionary dict + ? dict + : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(), + ResultType = "complete", + }, + _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs new file mode 100644 index 0000000..818a53f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs @@ -0,0 +1,22 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Configures server-side MCP Tasks behavior. +/// +public sealed class McpTasksOptions +{ + /// + /// Gets or sets the callback that selects task execution behavior for each tool call. + /// + /// + /// The default treats every tool as task-capable, preserving the behavior of + /// WithTasks. + /// The callback may inspect to select + /// behavior from tool metadata without requiring Core to reference the Tasks extension. + /// + public Func, McpTaskExecutionMode> ExecutionModeSelector { get; set; } = + static _ => McpTaskExecutionMode.Optional; +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs new file mode 100644 index 0000000..74b227f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs @@ -0,0 +1,105 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware server operations. +/// +public static class McpTasksServerExtensions +{ + /// + /// Sends a task status notification to the connected client. + /// + /// The server sending the notification. + /// The notification payload. + /// The cancellation token. + /// A task representing the send operation. + public static Task SendTaskStatusNotificationAsync( + this McpServer server, + TaskStatusNotificationParams notificationParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(notificationParams); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams)); +#endif + + return server.SendNotificationAsync( + TasksProtocol.NotificationTaskStatus, + notificationParams, + McpTasksJsonContext.Default.Options, + cancellationToken); + } + + internal static McpServer WithMcpTaskOutgoingRequestInterceptor( + this McpServer server, + string taskId, + IMcpTaskStore store) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(taskId); + ArgumentNullException.ThrowIfNull(store); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#endif + + return server.WithOutgoingRequestInterceptor(async (method, paramsNode, cancellationToken) => + { + var requestId = Guid.NewGuid().ToString("N"); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsNode is null + ? default + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + +#if NET + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); +#else + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + var response = await tcs.Task.ConfigureAwait(false); + return JsonNode.Parse(response.RawValue.GetRawText()); + } +#endif + +#if NET + return JsonNode.Parse(response.RawValue.GetRawText()); +#endif + } + finally + { + store.InputResponseReceived -= handler; + } + }); + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs new file mode 100644 index 0000000..4417266 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs @@ -0,0 +1,37 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides constants for the MCP Tasks extension (SEP-2663). +/// +public static class TasksProtocol +{ + /// + /// The extension identifier for the MCP Tasks extension. + /// + public const string ExtensionId = "io.modelcontextprotocol/tasks"; + + /// + /// The name of the request method sent from the client to poll for task completion. + /// + public const string MethodTasksGet = "tasks/get"; + + /// + /// The name of the request method sent from the client to provide input responses to a task. + /// + public const string MethodTasksUpdate = "tasks/update"; + + /// + /// The name of the request method sent from the client to signal intent to cancel a task. + /// + public const string MethodTasksCancel = "tasks/cancel"; + + /// + /// The name of the notification sent by the server when a task's status changes. + /// + public const string NotificationTaskStatus = "notifications/tasks/status"; + + /// + /// The metadata key used to associate requests, responses, and notifications with a task. + /// + public const string MetaRelatedTask = "io.modelcontextprotocol/related-task"; +} diff --git a/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs b/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs index 2ed0635..db939c6 100644 --- a/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs @@ -42,6 +42,10 @@ public static class McpRequestFilterBuilderExtensions /// /// Adds a filter to the call tool handler pipeline. /// + /// + /// This ordinary call-tool filter runs inside all alternate-result call-tool filters. For a task-backed call, + /// it executes in the background after task creation and before the tool. + /// /// The request filter builder instance. /// The filter function that wraps the handler. /// The builder provided in . diff --git a/src/ModelContextProtocol/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/McpServerBuilderExtensions.cs index 7f07be6..7e24bc5 100644 --- a/src/ModelContextProtocol/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpServerBuilderExtensions.cs @@ -859,6 +859,51 @@ public static partial class McpServerBuilderExtensions return builder; } + /// + /// Configures a handler for subscriptions/listen requests (SEP-2575), taking over the long-lived + /// subscription stream introduced by the 2026-07-28 protocol revision. + /// + /// The server builder instance. + /// The handler that owns the subscription stream for the lifetime of the request. + /// The builder provided in . + /// is . + /// + /// + /// subscriptions/listen is a long-lived request whose held-open response is a solicited + /// server-to-client stream. Providing a handler here lets a server author own that stream to implement + /// custom subscription kinds, application-driven resources/updated delivery, or subscriptions backed + /// by their own event source. It is especially useful for stateless Streamable HTTP, where unsolicited + /// notifications are dropped but the listen request's response stream can still carry notifications for the + /// duration of the request. + /// + /// + /// This is a full replacement for the SDK's built-in subscriptions/listen handling. When set, the + /// handler alone is responsible for sending exactly one + /// before any events, tagging every + /// streamed notification with the listen request id under _meta[], + /// staying active until the supplied is cancelled, and returning + /// when it completes. See + /// for the full contract. + /// + /// + /// Unlike , this method intentionally does not advertise any + /// server capabilities on the author's behalf. The set of notifications a listen handler honors is decided + /// at runtime and can include custom kinds not represented by a capability flag, so the author must + /// configure only the capabilities their handler will actually deliver. Advertised capabilities must match + /// what the handler delivers. + /// + /// + public static IMcpServerBuilder WithSubscriptionsListenHandler(this IMcpServerBuilder builder, McpRequestHandler handler) + { + Throw.IfNull(builder); + + builder.Services.Configure(options => + { + options.Handlers.SubscriptionsListenHandler = handler; + }); + return builder; + } + /// /// Configures a handler for processing logging level change requests from clients. /// diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 749a21f..b3c1d91 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -1,22 +1,24 @@ # MCP C# SDK -[![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) +[![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) 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.0.0-preview.2) 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 The SDK packages are: -- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. +- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. -- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. +- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. -- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. -- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. + +- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 45ecdcd..98c225d 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -179,17 +179,35 @@ public static class NodeHelpers } /// - /// Checks whether the SEP-2243 conformance scenarios are available, by reading the - /// installed conformance package version from node_modules. - /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, - /// http-header-validation, and http-custom-header-server-validation scenarios were - /// introduced in conformance package 0.2.0. Reading the installed version (rather than - /// the pinned version in package.json) means this also returns - /// when a newer private build has been installed locally via - /// npm install --no-save <path-to-conformance>. + /// Checks whether the SEP-2243 conformance scenarios are available in the installed + /// conformance package. /// public static bool HasSep2243Scenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "http-standard-headers", + "http-invalid-tool-headers", + "http-header-validation", + "http-custom-header-server-validation"); + + /// + /// Checks whether the SEP-2575 request-metadata client conformance scenario is available + /// in the installed conformance package. + /// + public static bool HasRequestMetadataScenario() + => HasInstalledConformanceScenario("request-metadata"); + + /// + /// Checks whether the installed conformance package contains a spec-conformant + /// http-custom-headers scenario. Prereleases 0.2.0-alpha.5 through 0.2.0-alpha.7 + /// annotated a number-typed parameter with x-mcp-header, which SEP-2243 + /// forbids; a conformant client excludes that tool, so every positive check in the + /// scenario fails. Conformance PR #371 fixed the scenario and shipped it in 0.2.0-alpha.8, + /// so this gate requires at least that version. Unlike , + /// this comparison honors the semver prerelease so older 0.2.0 prereleases are skipped + /// rather than failing spuriously. + /// + public static bool HasConformantCustomHeadersScenario() + => IsInstalledConformanceVersionAtLeast("0.2.0-alpha.8"); /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance @@ -200,25 +218,70 @@ public static class NodeHelpers /// locally via npm install --no-save <path-to-conformance>. /// public static bool HasCachingScenario() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenario("caching"); /// - /// Returns when the conformance package installed in node_modules - /// has a version greater than or equal to . + /// Checks whether all named conformance scenarios are present in the installed + /// @modelcontextprotocol/conformance bundle. This is intentionally based on the + /// installed scenario list rather than the package version so prerelease/private builds are + /// gated by the scenarios they actually contain. /// - private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) + private static bool HasInstalledConformanceScenarios(params string[] scenarioNames) + => ReadInstalledConformanceBundle() is { } bundle + && scenarioNames.All(scenarioName => HasInstalledConformanceScenario(bundle, scenarioName)); + + private static bool HasInstalledConformanceScenario(string scenarioName) + => ReadInstalledConformanceBundle() is { } bundle + && HasInstalledConformanceScenario(bundle, scenarioName); + + private static bool HasInstalledConformanceScenario(string bundle, string scenarioName) + => bundle.Contains($"`{scenarioName}`", StringComparison.Ordinal) || + bundle.Contains($"\"{scenarioName}\"", StringComparison.Ordinal) || + bundle.Contains($"'{scenarioName}'", StringComparison.Ordinal); + + private static string? ReadInstalledConformanceBundle() { - var version = GetInstalledConformanceVersion(); - return version is not null && version >= minimumVersion; + try + { + var repoRoot = FindRepoRoot(); + var bundlePath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); + + // This is a skip gate for scenario-conditional conformance tests, so it must stay + // side-effect-free. If the conformance package isn't installed, report no bundle (the + // scenario is simply gated off); the actual scenario run path restores npm dependencies + // separately via ConformanceTestStartInfo. + if (!File.Exists(bundlePath)) + { + return null; + } + + return File.ReadAllText(bundlePath); + } + catch + { + return null; + } } /// - /// Reads the version of the conformance package actually installed in node_modules, - /// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be - /// parsed as a . Returns if it cannot be + /// Returns when the conformance package installed in node_modules + /// has a semver precedence greater than or equal to , + /// honoring the prerelease component (e.g. "0.2.0-alpha.8"). Returns + /// when no version can be determined. + /// + private static bool IsInstalledConformanceVersionAtLeast(string minimumVersion) + { + var installed = GetInstalledConformanceVersionString(); + return installed is not null && CompareSemVer(installed, minimumVersion) >= 0; + } + + /// + /// Reads the raw version string of the conformance package installed in node_modules, + /// preserving any prerelease/build suffix. Returns if it cannot be /// determined. /// - private static Version? GetInstalledConformanceVersion() + private static string? GetInstalledConformanceVersionString() { try { @@ -226,25 +289,15 @@ public static class NodeHelpers var packageJsonPath = Path.Combine( repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); - // This is a skip gate for version-conditional conformance scenarios, so it must stay - // side-effect-free. If the conformance package isn't installed, report no version (the - // scenario is simply gated off); the actual scenario run path restores npm dependencies - // separately via ConformanceTestStartInfo. if (!File.Exists(packageJsonPath)) { return null; } using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("version", out var versionElement) && - versionElement.GetString() is { } versionStr) + if (json.RootElement.TryGetProperty("version", out var versionElement)) { - // Strip any prerelease/build suffix so System.Version can parse it. - var core = versionStr.Split('-', '+')[0]; - if (Version.TryParse(core, out var version)) - { - return version; - } + return versionElement.GetString(); } return null; @@ -256,7 +309,94 @@ public static class NodeHelpers } /// - /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// Compares two semantic version strings by precedence, honoring the prerelease component + /// per the SemVer 2.0.0 rules used here (numeric identifiers compare numerically, a version + /// with a prerelease has lower precedence than the same version without one, and a shorter + /// set of prerelease identifiers has lower precedence when all preceding ones are equal). + /// Build metadata (after '+') is ignored. Returns a negative value when + /// precedes , zero when equal, and a positive value otherwise. + /// + private static int CompareSemVer(string a, string b) + { + var (coreA, preA) = SplitSemVer(a); + var (coreB, preB) = SplitSemVer(b); + + var coreCompare = coreA.CompareTo(coreB); + if (coreCompare != 0) + { + return coreCompare; + } + + // A version without a prerelease outranks one with a prerelease. + if (preA.Length == 0 && preB.Length == 0) + { + return 0; + } + if (preA.Length == 0) + { + return 1; + } + if (preB.Length == 0) + { + return -1; + } + + var count = Math.Min(preA.Length, preB.Length); + for (var i = 0; i < count; i++) + { + var idA = preA[i]; + var idB = preB[i]; + var numA = int.TryParse(idA, out var na); + var numB = int.TryParse(idB, out var nb); + + int cmp; + if (numA && numB) + { + cmp = na.CompareTo(nb); + } + else if (numA) + { + // Numeric identifiers always have lower precedence than alphanumeric ones. + cmp = -1; + } + else if (numB) + { + cmp = 1; + } + else + { + cmp = string.CompareOrdinal(idA, idB); + } + + if (cmp != 0) + { + return cmp; + } + } + + return preA.Length.CompareTo(preB.Length); + } + + /// + /// Splits a semver string into its numeric core (major.minor.patch) and its prerelease + /// identifiers, ignoring any build metadata after '+'. Missing core components default to 0. + /// + private static (Version Core, string[] Prerelease) SplitSemVer(string version) + { + var withoutBuild = version.Split(new[] { '+' }, 2)[0]; + var parts = withoutBuild.Split(new[] { '-' }, 2); + var prerelease = parts.Length > 1 && parts[1].Length > 0 + ? parts[1].Split('.') + : Array.Empty(); + + var coreParts = parts[0].Split('.'); + int Part(int index) => index < coreParts.Length && int.TryParse(coreParts[index], out var v) ? v : 0; + var core = new Version(Part(0), Part(1), Part(2)); + + return (core, prerelease); + } + + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) /// shared by the server-side conformance tests. @@ -315,7 +455,7 @@ public static class NodeHelpers await process.WaitForExitAsync(cts.Token); #else // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill - // extension and surface a timeout the same way the modern path does. + // extension and surface a timeout the same way the current target-framework path does. await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); if (!process.HasExited) { @@ -376,16 +516,34 @@ public static class NodeHelpers /// /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) - /// conformance scenarios are available, by reading the installed conformance - /// package version from node_modules. The incomplete-result-* scenarios were - /// introduced in conformance package 0.2.0 (see - /// https://github.com/modelcontextprotocol/conformance/pull/188). - /// Reading the installed version (rather than the pinned version in package.json) means - /// this also returns when a newer private build has been installed - /// locally via npm install --no-save <path-to-conformance>. + /// conformance scenarios are available in the installed conformance package. /// public static bool HasMrtrScenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "input-required-result-basic-elicitation", + "input-required-result-basic-sampling", + "input-required-result-basic-list-roots", + "input-required-result-request-state", + "input-required-result-multiple-input-requests", + "input-required-result-multi-round", + "input-required-result-missing-input-response", + "input-required-result-non-tool-request", + "input-required-result-result-type", + "input-required-result-unsupported-methods", + "input-required-result-tampered-state", + "input-required-result-capability-check", + "input-required-result-ignore-extra-params", + "input-required-result-validate-input"); + + /// + /// Checks whether the SEP-2663 Tasks extension server scenarios are available in the + /// installed conformance package. + /// + public static bool HasTasksExtensionScenarios() + => HasInstalledConformanceScenarios( + "tasks-wire-fields", + "tasks-request-state-removal", + "tasks-mrtr-input"); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index 2b505f9..a8ab66e 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -7,18 +7,20 @@ true false - - $(NoWarn);MCPEXP001 + + $(NoWarn);MCPEXP001;MCPEXP003 + + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 687d78e..708cd93 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,17 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.IO.Pipelines; Pipe clientToServerPipe = new(), serverToClientPipe = new(); -// Create a server using a stream-based transport over an in-memory pipe. -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions() - { - ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })] - }); +var services = new ServiceCollection(); +services.AddMcpServer() + .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) + .WithTools() + .WithMcpApps(); + +await using var serviceProvider = services.BuildServiceProvider(); +var server = serviceProvider.GetRequiredService(); _ = server.RunAsync(); // Connect a client using a stream-based transport over the same in-memory pipe. @@ -20,13 +23,18 @@ await using McpClient client = await McpClient.CreateAsync( // List all tools. var tools = await client.ListToolsAsync(); -if (tools.Count == 0) +var echo = tools.FirstOrDefault(t => t.Name == "Echo"); +if (echo is null) { - throw new Exception("Expected at least one tool."); + throw new Exception("Expected the Echo tool."); +} + +var ui = echo.ProtocolTool.Meta?["ui"]?.AsObject(); +if (ui?["resourceUri"]?.GetValue() != "ui://aot/echo") +{ + throw new Exception($"Unexpected app UI metadata: {ui}"); } -// Invoke a tool. -var echo = tools.First(t => t.Name == "Echo"); var result = await echo.InvokeAsync(new() { ["arg"] = "Hello World" }); if (result is null || !result.ToString()!.Contains("Echo: Hello World")) { @@ -34,3 +42,11 @@ if (result is null || !result.ToString()!.Contains("Echo: Hello World")) } Console.WriteLine("Success!"); + +[McpServerToolType] +internal sealed class AotTools +{ + [McpServerTool(Name = "Echo")] + [McpAppUi(ResourceUri = "ui://aot/echo")] + public static string Echo(string arg) => $"Echo: {arg}"; +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 76a7201..6ad643e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -93,6 +93,91 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr Assert.Equal("Authorized: test", content.Text); } + [Fact] + public async Task Authorize_Tool_ReauthorizesPrimitiveChangedByInterveningFilter() + { + await using var app = await StartServerWithAuth(builder => + { + builder.WithTools(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool)) + { + throw new InvalidOperationException("The replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + }); + + var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "anonymous_tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + } + + [Fact] + public async Task Authorize_Tool_ReauthorizesAfterEachInterveningFilter() + { + await using var app = await StartServerWithAuth(builder => + { + builder.WithTools(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool)) + { + throw new InvalidOperationException("The first replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("admin_tool", out var adminTool)) + { + throw new InvalidOperationException("The second replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = adminTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + }, "TestUser"); + + var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "anonymous_tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + } + [Fact] public async Task AuthorizeWithRoles_Tool_RequiresAdminRole() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 38e5032..f39f817 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -1,104 +1,7 @@ -using System.Diagnostics; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; -/// -/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the -/// SEP-2549 "caching" conformance scenario (new in the 2026-07-28 protocol revision) -/// requires. Started on demand (so it is not bound -/// when the caching test is skipped) and torn down via . Uses a -/// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so -/// the two can run in parallel without TCP conflicts. -/// -internal sealed class StatelessConformanceServer : IAsyncDisposable -{ - // Use different ports for each target framework to allow parallel execution across the - // multi-targeted test processes, offset from a caller-supplied base port so independent - // stateless servers (e.g. caching vs. SEP-2243) do not collide. net10.0 -> +0, - // net9.0 -> +1, net8.0 -> +2. - private static int GetPortForTargetFramework(int basePort) - { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); - - var offset = targetFramework switch - { - "net10.0" => 0, - "net9.0" => 1, - "net8.0" => 2, - _ => 0 // Default fallback - }; - - return basePort + offset; - } - - private readonly Task _serverTask; - private readonly CancellationTokenSource _serverCts; - - public string ServerUrl { get; } - - private StatelessConformanceServer(string serverUrl, Task serverTask, CancellationTokenSource serverCts) - { - ServerUrl = serverUrl; - _serverTask = serverTask; - _serverCts = serverCts; - } - - public static async Task StartAsync(CancellationToken cancellationToken, int basePort = 3011) - { - var serverUrl = $"http://localhost:{GetPortForTargetFramework(basePort)}"; - var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - // "--stateless true" opts this server instance into the SEP-2575 stateless lifecycle - // (see ConformanceServer.Program), without mutating process-wide environment state. - var serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", serverUrl, "--stateless", "true"], cancellationToken: serverCts.Token)); - - // Wait for the server to be ready (retry for up to 30 seconds). - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; - - while (stopwatch.Elapsed < timeout) - { - try - { - await httpClient.GetAsync($"{serverUrl}/health", cancellationToken); - return new StatelessConformanceServer(serverUrl, serverTask, serverCts); - } - catch (HttpRequestException) - { - // Connection refused means the server is not ready yet. - } - catch (TaskCanceledException) - { - // Timeout means the server might be processing; give it more time. - } - - await Task.Delay(500, cancellationToken); - } - - serverCts.Cancel(); - serverCts.Dispose(); - throw new InvalidOperationException("Stateless ConformanceServer failed to start within the timeout period"); - } - - public async ValueTask DisposeAsync() - { - _serverCts.Cancel(); - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch - { - // Ignore exceptions during shutdown. - } - _serverCts.Dispose(); - } -} - /// /// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results, /// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK @@ -106,12 +9,13 @@ internal sealed class StatelessConformanceServer : IAsyncDisposable /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// /// -/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. -/// It is gated on the installed conformance -/// package version (>= 0.2.0). The stateless server is -/// started only after the gates pass, so a skipped run binds no port. +/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle, +/// so it runs against the shared server's stateless endpoint +/// (). It is gated on the installed +/// conformance package's scenario list. /// -public class CachingConformanceTests(ITestOutputHelper output) +[Collection(nameof(ConformanceServerCollection))] +public class CachingConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output) { [Fact] public async Task RunCachingConformanceTest() @@ -119,15 +23,13 @@ public class CachingConformanceTests(ITestOutputHelper output) Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasCachingScenario(), - "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); - - await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); + "SEP-2549 caching conformance scenario is not available in the installed conformance package."); // The caching scenario only exists in the 2026-07-28 protocol revision, so pin the spec version // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a // conflicting duplicate --spec-version flag). var result = await NodeHelpers.RunServerConformanceAsync( - $"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28", + $"server --url {fixture.StatelessServerUrl} --scenario caching --spec-version 2026-07-28", line => { try { output.WriteLine(line); } catch { } }, appendProtocolVersionFromEnv: false, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 2990b3d..1b8be84 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -17,6 +17,8 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); + public static bool HasRequestMetadataScenario => NodeHelpers.HasRequestMetadataScenario(); + public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario(); public ClientConformanceTests(ITestOutputHelper output) { @@ -44,13 +46,29 @@ public class ClientConformanceTests [InlineData("auth/resource-mismatch")] [InlineData("auth/pre-registration")] - // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata). + // Offline access scope negotiation. + [InlineData("auth/offline-access-scope")] + [InlineData("auth/offline-access-not-supported")] + + // RFC 9207 authorization server issuer validation. + [InlineData("auth/iss-supported")] + [InlineData("auth/iss-not-advertised")] + [InlineData("auth/iss-supported-missing")] + [InlineData("auth/iss-wrong-issuer")] + [InlineData("auth/iss-unexpected")] + [InlineData("auth/iss-normalized")] + [InlineData("auth/metadata-issuer-mismatch")] + + // Backcompat: 2025-03-26 OAuth flows (no per-request metadata, root-location metadata). [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] - // Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support. - // [InlineData("auth/client-credentials-jwt")] - // [InlineData("auth/client-credentials-basic")] + [InlineData("auth/authorization-server-migration")] + [InlineData("auth/client-credentials-jwt")] + [InlineData("auth/client-credentials-basic")] + [InlineData("auth/enterprise-managed-authorization")] + [InlineData("sep-2322-client-request-state")] + [InlineData("json-schema-ref-no-deref")] public async Task RunConformanceTest(string scenario) { @@ -62,14 +80,20 @@ public class ClientConformanceTests $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // Per-request metadata (SEP-2575) + [Fact(Skip = "SEP-2575 request-metadata conformance scenario is not available in the installed conformance package.", SkipUnless = nameof(HasRequestMetadataScenario))] + public async Task RunConformanceTest_RequestMetadata() + { + var result = await RunClientConformanceScenario("request-metadata"); + + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios are not available in the installed conformance package.", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-invalid-tool-headers")] - // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, - // which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers, - // failing every positive check. Re-enable once a conformant conformance package ships (#1655). - // [InlineData("http-custom-headers")] public async Task RunConformanceTest_Sep2243(string scenario) { // Run the conformance test suite @@ -80,6 +104,23 @@ public class ClientConformanceTests $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // The http-custom-headers scenario needs a tighter gate than the other SEP-2243 scenarios: + // conformance 0.2.0-alpha.5 through 0.2.0-alpha.7 shipped it with an x-mcp-header on a + // number-typed parameter (forbidden by SEP-2243), which a conformant client excludes, + // failing every positive check. It was fixed upstream in 0.2.0-alpha.8 (conformance #371), + // so require at least that version to avoid spurious failures on older 0.2.0 prereleases. + [Theory(Skip = "Conformant http-custom-headers scenario not available (requires conformance package >= 0.2.0-alpha.8).", SkipUnless = nameof(HasConformantCustomHeadersScenario))] + [InlineData("http-custom-headers")] + public async Task RunConformanceTest_Sep2243_CustomHeaders(string scenario) + { + // Run the conformance test suite + var result = await RunClientConformanceScenario(scenario); + + // Report the results + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario) { // Construct an absolute path to the conformance client executable diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs new file mode 100644 index 0000000..21fb98c --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP +/// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at +/// , "/stateless") on one port. A single long-lived server avoids +/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and +/// centralizes the port-binding logic that the stateful and stateless conformance tests previously +/// duplicated. Shared by and +/// via . +/// +public sealed class ConformanceServerFixture : IAsyncLifetime +{ + // Use different ports for each target framework to allow parallel execution across the + // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003. + private static int GetPortForTargetFramework() + { + var testBinaryDir = AppContext.BaseDirectory; + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + + return targetFramework switch + { + "net10.0" => 3001, + "net9.0" => 3002, + "net8.0" => 3003, + _ => 3001 // Default fallback + }; + } + + private Task? _serverTask; + private CancellationTokenSource? _serverCts; + + /// Base URL of the stateful MCP endpoint (mapped at "/"). + public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; + + /// + /// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28 + /// scenarios (caching, MRTR, SEP-2243) that negotiate the stateless lifecycle. + /// + public string StatelessServerUrl => $"{ServerUrl}/stateless"; + + public async ValueTask InitializeAsync() + { + _serverCts = new CancellationTokenSource(); + _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( + ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + + // Wait for server to be ready (retry for up to 30 seconds) + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + + while (stopwatch.Elapsed < timeout) + { + try + { + await httpClient.GetAsync($"{ServerUrl}/health"); + return; + } + catch (HttpRequestException) + { + // Connection refused means server not ready yet + } + catch (TaskCanceledException) + { + // Timeout means server might be processing, give it more time + } + + await Task.Delay(500); + } + + throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); + } + + public async ValueTask DisposeAsync() + { + if (_serverCts != null) + { + _serverCts.Cancel(); + if (_serverTask != null) + { + try + { + await _serverTask.WaitAsync(TestConstants.DefaultTimeout); + } + catch + { + // Ignore exceptions during shutdown + } + } + _serverCts.Dispose(); + } + } +} + +/// +/// xUnit collection that shares one across the conformance +/// test classes so they run against a single server instance (and a single bound port). +/// +[CollectionDefinition(nameof(ConformanceServerCollection))] +public sealed class ConformanceServerCollection : ICollectionFixture; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 4da16a3..914b98d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -8,6 +8,7 @@ using System.Net; using System.Net.ServerSentEvents; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.AspNetCore.Tests; @@ -32,6 +33,18 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre Name = nameof(HttpHeaderConformanceTests), Version = "1.0", }; +#pragma warning disable MCPEXP002 + options.RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "extension/get", + RoutingNameParameter = "itemId", + Handler = static (_, _) => new ValueTask( + new JsonObject { ["resultType"] = "complete" }), + }, + ]; +#pragma warning restore MCPEXP002 }).WithTools(Tools).WithHttpTransport(); _app = Builder.Build(); @@ -110,11 +123,28 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre #region Server-side validation tests + [Fact] + public async Task Server_UsesCustomHandlerRoutingNameMetadata() + { + await StartAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent( + """{"jsonrpc":"2.0","id":2,"method":"extension/get","params":{"itemId":"item-42","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}"""); + request.Headers.Add(McpHttpHeaders.ProtocolVersion, "2026-07-28"); + request.Headers.Add(McpHttpHeaders.Method, "extension/get"); + request.Headers.Add(McpHttpHeaders.Name, "item-42"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task Server_AcceptsUnionIntegerCanonicalForm() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body // carries the decimal form 42.0. The server must treat the union type as integer and match. @@ -135,7 +165,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_RejectsUnionIntegerOutsideSafeRange() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); var callJson = CallTool("union_test", """{"priority":9007199254740993}"""); @@ -154,7 +184,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100". var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}"""); @@ -177,7 +207,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values // and compare the trimmed value to the request body. @@ -201,7 +231,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); @@ -224,7 +254,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call with an empty string param that has an x-mcp-header. // The header should be present with an empty value, matching the body's empty string. @@ -248,7 +278,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call where the body has a non-empty value but the header is empty var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}"""); @@ -271,7 +301,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Encode a value with a newline control character using Base64 var valueWithNewline = "line1\nline2"; @@ -297,7 +327,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without // losing precision through a double conversion. @@ -326,7 +356,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243 integer values MUST be within the JavaScript safe integer range. // A matching header and body that are both outside the range must still be rejected. @@ -355,7 +385,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and // "4.2e1" are preserved in the body exactly as another SDK might serialize them. @@ -382,7 +412,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected // even when the header and body strings are byte-for-byte identical (it must not slip through @@ -407,7 +437,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Header says "99" but body says priority:42 — must reject even with numeric comparison var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}"""); @@ -427,17 +457,17 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre } [Fact] - public async Task Server_SkipsHeaderValidation_ForLegacyVersion() + public async Task Server_SkipsHeaderValidation_ForInitializeHandshakeVersion() { await StartAsync(); - await InitializeWithLegacyVersionAsync(); + await InitializeWithInitializeHandshakeVersionAsync(); - // With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched - var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + // With the initialize-handshake version, Mcp-Param-* headers are NOT validated even if mismatched. + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includePerRequestMetadata: false); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - // Send the WRONG header value. This should still succeed because the version is legacy. + // Send the WRONG header value. This should still succeed because the version uses initialize. request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); @@ -451,7 +481,7 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers // instead of properly base64-encoding non-ASCII values. @@ -561,40 +591,39 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) + public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } #endregion #region Helpers - private async Task InitializeWithJuly2026ProtocolVersionAsync() + private async Task ProbeWithJuly2026ProtocolVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a - // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 - // so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private async Task InitializeWithLegacyVersionAsync() + private async Task InitializeWithInitializeHandshakeVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return + // Server is stateless by default (SEP-2567), so initializing with an initialize-handshake protocol does not return // a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision. } @@ -602,22 +631,24 @@ public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : Kestre private long _lastRequestId = 1; - private string CallTool(string toolName, string arguments = "{}") + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$$""" - {"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}} - """; + var meta = includePerRequestMetadata + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"tools/call\",\"params\":{\"name\":\"" + + toolName + "\",\"arguments\":" + arguments + meta + "}}"; } private static string InitializeRequest => """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs new file mode 100644 index 0000000..ae5e19f --- /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/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs new file mode 100644 index 0000000..d64a559 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs @@ -0,0 +1,243 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Moq; +using System.Security.Claims; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class HttpTaskIntegrationTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) +{ + [Fact] + public async Task WithTasks_CanCallToolOverHttp() + { + Builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("Hello World!", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task WithTasks_AfterOrdinaryFilter_RunsFilter() + { + var filterInvocationCount = 0; + Builder.Services + .AddMcpServer(options => + { + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + Interlocked.Increment(ref filterInvocationCount); + return await next(context, cancellationToken); + }); + }) + .WithHttpTransport() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) + .WithTools(); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("Hello World!", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal(1, filterInvocationCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithTasks_AuthorizedTool_Completes(bool registerTasksBeforeAuthorization) + { + var serverBuilder = Builder.Services + .AddMcpServer() + .WithHttpTransport(); + + if (registerTasksBeforeAuthorization) + { + serverBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) + .AddAuthorizationFilters(); + } + else + { + serverBuilder + .AddAuthorizationFilters() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }); + } + + serverBuilder.WithTools(); + Builder.Services.AddAuthorization(); + + await using var app = Builder.Build(); + app.Use(next => async context => + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, "test-user")], + "TestAuthType")); + await next(context); + }); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "authorized-test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("Authorized", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTasksBeforeAuthorization) + { + var taskStore = new Mock(MockBehavior.Strict); + var serverBuilder = Builder.Services + .AddMcpServer() + .WithHttpTransport(); + + if (registerTasksBeforeAuthorization) + { + serverBuilder + .WithTasks(taskStore.Object) + .AddAuthorizationFilters(); + } + else + { + serverBuilder + .AddAuthorizationFilters() + .WithTasks(taskStore.Object); + } + + serverBuilder.WithTools(); + Builder.Services.AddAuthorization(); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync(() => + client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "authorized-test" }, + TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + taskStore.Verify( + store => store.CreateTaskAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WithTasks_ReauthorizesToolChangedByOrdinaryFilter() + { + var serverBuilder = Builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) + .WithTools() + .AddAuthorizationFilters(); + + serverBuilder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized-test", out var authorizedTool)) + { + throw new InvalidOperationException("The replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + serverBuilder.AddAuthorizationFilters(); + Builder.Services.AddAuthorization(); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync(() => + client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("Access forbidden: This tool requires authorization.", exception.Message); + } + + [McpServerToolType] + private sealed class TestTools + { + [McpServerTool(Name = "test")] + public static string Test() => "Hello World!"; + + [McpServerTool(Name = "authorized-test")] + [Authorize] + public static string AuthorizedTest() => "Authorized"; + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 02bfba2..9126331 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -14,7 +14,8 @@ using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These +/// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize +/// handshake over Streamable HTTP. These /// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's /// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body /// on a 2026-07-28 probe; vanilla Go does the same on POST /) so the client's HTTP-fallback @@ -29,15 +30,15 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// only surfaced the three error codes /// introduced by the 2026-07-28 revision (-32022, -32021, -32020) as ; -/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from an initialize-handshake server /// that doesn't understand the 2026-07-28 _meta envelope) threw /// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger -/// on ANY non-modern JSON-RPC error in a 400 body. +/// on ANY non-SEP-2575 JSON-RPC error in a 400 body. /// /// /// treated any non-2xx HTTP response as a /// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked -/// application-level errors (including the three modern codes) because the SSE GET would +/// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would /// either fail with "session id required" or succeed against a different endpoint and lose /// the actual signal. /// @@ -56,7 +57,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : base.Dispose(); } - private async Task StartServerAsync(RequestDelegate handler) + private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false) { Builder.Services.Configure(options => { @@ -64,7 +65,14 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : }); _app = Builder.Build(); - _app.MapPost("/mcp", handler); + if (acceptGet) + { + _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler); + } + else + { + _app.MapPost("/mcp", handler); + } await _app.StartAsync(TestContext.Current.CancellationToken); } @@ -86,11 +94,11 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : /// /// Mimics Python's simple-streamablehttp-stateless on a 2026-07-28 probe: returns /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the - /// initial server/discover, then performs a normal legacy initialize handshake + /// initial server/discover, then performs a normal initialize handshake /// when the client falls back. /// [Fact] - public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() { var ct = TestContext.Current.CancellationToken; @@ -107,7 +115,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : return; } - // 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with + // 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). if (request.Method == RequestMethods.ServerDiscover) { @@ -115,7 +123,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : return; } - // Legacy initialize: respond with the highest version the legacy server speaks. + // Initialize handshake: respond with the highest version this server speaks. if (request.Method == RequestMethods.Initialize) { var response = new JsonRpcResponse @@ -123,9 +131,9 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-06-18", + ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion, Capabilities = new() { Tools = new() }, - ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; @@ -157,11 +165,11 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); // Sanity: subsequent traffic still works post-fallback. var tools = await client.ListToolsAsync(cancellationToken: ct); @@ -170,7 +178,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : /// /// Mimics vanilla Go: returns 400 + JSON-RPC -32022 with - /// data.supported[] on a 2026-07-28 probe so the client retries legacy + /// data.supported[] on a 2026-07-28 probe so the client retries /// initialize with one of the advertised versions. /// [Fact] @@ -197,8 +205,8 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : // Use the typed payload type so the source-generated serializer can handle it. var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData { - Supported = new List { "2025-11-25" }, - Requested = "2026-07-28", + Supported = new List { McpProtocolVersions.November2025ProtocolVersion }, + Requested = McpProtocolVersions.July2026ProtocolVersion, }, GetJsonTypeInfo()); var rpcError = new JsonRpcError @@ -225,7 +233,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), @@ -244,16 +252,16 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } /// /// A 400 with a JSON-RPC -32020 HeaderMismatch envelope must be surfaced to the - /// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope. + /// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope. /// [Fact] public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() @@ -293,11 +301,196 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); Assert.False(initializeReceived); } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode) + { + var ct = TestContext.Current.CancellationToken; + var initializeRequests = 0; + var sseGetRequests = 0; + + await StartServerAsync(async context => + { + if (HttpMethods.IsGet(context.Request.Method)) + { + sseGetRequests++; + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeRequests++; + } + + await WriteJsonRpcErrorAsync( + context, + statusCode, + code: (int)McpErrorCode.InvalidRequest, + message: "non-modern structured error"); + }, acceptGet: true); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.AutoDetect, + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions(), + loggerFactory: LoggerFactory, + cancellationToken: ct); + }); + + Assert.Equal(statusCode, exception.StatusCode); + Assert.Contains("non-modern structured error", exception.Message); + Assert.Equal(0, initializeRequests); + Assert.Equal(0, sseGetRequests); + } + + [Fact] + public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() + { + var ct = TestContext.Current.CancellationToken; + string? toolsListSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session"; + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest) + { + toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = toolsListRequest.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + await client.ListToolsAsync(cancellationToken: ct); + + Assert.Null(client.SessionId); + Assert.Equal("", toolsListSessionId); + } + + [Fact] + public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest() + { + var ct = TestContext.Current.CancellationToken; + string? discoverSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + KnownSessionId = "legacy-session", + OwnsSession = false, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("", discoverSessionId); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 9cce9b0..365cf28 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server -/// suppresses the Mcp-Session-Id header for those requests and returns structured +/// does not issue Mcp-Session-Id for those requests and returns structured /// errors instead of plain 400s. /// public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable @@ -24,10 +24,10 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : 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(); @@ -52,12 +52,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : { await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); // On a stateless server, server/discover succeeds without creating a session. var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -69,16 +69,16 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : 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 - // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back - // to the legacy initialize handshake. + // 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); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -93,22 +93,22 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : var dataElement = (JsonElement)rpcError.Error.Data!; var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); Assert.NotNull(errorData); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested); - // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version. + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, errorData.Requested); + // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version. Assert.NotEmpty(errorData.Supported); - Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported); + Assert.DoesNotContain(McpProtocolVersions.July2026ProtocolVersion, errorData.Supported); } [Fact] public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() { - await StartAsync(); + await StartAsync(stateless: true); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -128,23 +128,23 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : } [Fact] - public async Task Request_WithMcpSessionIdHeader_IsRejected() + public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSessionId() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the - // Stateless setting. - await StartAsync(); + // a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs. + await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); } [Fact] @@ -152,11 +152,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -164,12 +165,13 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -177,11 +179,12 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -189,11 +192,16 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } + + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"July2026HttpHandlerTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs new file mode 100644 index 0000000..01a4b26 --- /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 ef2d3f6..142536b 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 legacy 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 legacy 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,8 +38,8 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe [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 - // the legacy fallback re-establishes for the 2026-07-28-first client. + // 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 { Message = "What is your name?", @@ -52,15 +53,19 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe 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(); @@ -77,21 +82,34 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe }, HttpClient, LoggerFactory); // Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes - // with server/discover before falling back to a legacy initialize handshake. + // with server/discover before falling back to an initialize handshake. var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } [Fact] - public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_AndToolsWork() { 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 legacy. - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet", new Dictionary { ["name"] = "Alice" }, @@ -118,7 +136,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe }); }); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet_via_elicit", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs index ac19953..fbf1a5f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using OpenTelemetry.Trace; +using System.Diagnostics; namespace ModelContextProtocol.AspNetCore.Tests; @@ -10,6 +12,57 @@ public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStream protected override bool UseStreamableHttp => true; protected override bool Stateless => true; + // In stateless mode each HTTP request is served by a fresh, un-negotiated McpServer, so the + // session-level NegotiatedProtocolVersion is null when the server span is tagged. The tag must + // therefore come from the per-request MCP-Protocol-Version header / _meta value. Exercising more + // than one version proves the tag tracks the per-request value rather than coincidentally matching + // a single hard-coded version (and would regress to an absent tag if the per-request fallback were + // removed, since the negotiated value is null at tagging time). + [Theory] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ServerActivity_TagsPerRequestProtocolVersion_InStatelessMode(string protocolVersion) + { + var activities = new List(); + string? capturedNegotiatedProtocolVersion = null; + + var protocolVersionTool = McpServerTool.Create( + (RequestContext context) => + { + capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; + return "ok"; + }, + new() { Name = "stateless-capture-version" }); + + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.ModelContextProtocol") + .AddInMemoryExporter(activities) + .Build()) + { + Builder.Services.AddMcpServer() + .WithHttpTransport(ConfigureStateless) + .WithTools([protocolVersionTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(configureClient: options => + options.ProtocolVersion = protocolVersion); + + await client.CallToolAsync("stateless-capture-version", cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Contains(activities, activity => + activity.DisplayName == "tools/call stateless-capture-version" && + activity.Kind == ActivityKind.Server && + activity.GetTagItem("mcp.protocol.version") as string == protocolVersion); + + // Once the per-request version is applied, the request-scoped server settles on that same version, + // so the value tagged on the span and the version the session ends up negotiating agree. + Assert.Equal(protocolVersion, capturedNegotiatedProtocolVersion); + } + [Fact] public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 889a7da..0a0a6bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -6,7 +6,9 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using OpenTelemetry.Trace; using System.Collections.Concurrent; +using System.Diagnostics; using System.Net; using System.Security.Claims; using System.Threading; @@ -46,6 +48,60 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name); } + // 2025-11-25 is negotiated through the initialize handshake, so in the (default) stateful configuration + // the session-level NegotiatedProtocolVersion is populated and the client sends a matching + // MCP-Protocol-Version header on every follow-up request. This asserts the span carries the negotiated + // version and that the tagged value agrees with the session's NegotiatedProtocolVersion. + // + // A scenario where the per-request and negotiated versions differ is intentionally not asserted here: + // a single MCP session must not change protocol versions, so the server rejects a follow-up request + // whose header/_meta version differs from the negotiated one, and a conformant client never sends a + // differing header. Reaching that divergence would require a hand-crafted request on an invalid + // (failing) code path and would pin the current ordering of tagging relative to version-change + // validation. Instead the two inputs to the tag are covered independently: the per-request path in + // stateless mode (MapMcpStatelessTests, where NegotiatedProtocolVersion is null at tagging time) and the + // negotiated-only fallback over a header-less transport (DiagnosticTests). + [Fact] + public async Task ServerActivity_TagsNegotiatedProtocolVersion() + { + var activities = new List(); + string? capturedNegotiatedProtocolVersion = null; + + var protocolVersionTool = McpServerTool.Create( + (RequestContext context) => + { + capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; + return "ok"; + }, + new() { Name = "negotiated-capture-version" }); + + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.ModelContextProtocol") + .AddInMemoryExporter(activities) + .Build()) + { + Builder.Services.AddMcpServer() + .WithHttpTransport(ConfigureStateless) + .WithTools([protocolVersionTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(configureClient: options => + options.ProtocolVersion = "2025-11-25"); + + await client.CallToolAsync("negotiated-capture-version", cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Contains(activities, activity => + activity.DisplayName == "tools/call negotiated-capture-version" && + activity.Kind == ActivityKind.Server && + activity.GetTagItem("mcp.protocol.version") as string == "2025-11-25"); + + Assert.Equal("2025-11-25", capturedNegotiatedProtocolVersion); + } + [Fact] public async Task StreamableHttpMode_Works_WithRootEndpoint() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 3d8abb0..03af131 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/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index ef68321..43a3c12 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -239,10 +239,9 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : [Fact] public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() { - // Regression test for: Tool calls that last over HttpClient timeout without producing - // intermediate notifications will timeout because HttpClient doesn't see the 200 response - // until the first message is written. When primingItem is null (no ISseEventStreamStore), - // we should flush the response stream so HttpClient sees the 200 immediately. + // Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing + // intermediate notifications need an eager response flush. Per-request-metadata protocol + // revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status. Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -274,7 +273,11 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : TransportMode = transportMode, }, shortTimeoutClient, LoggerFactory); - await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var mcpClient = await McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = "2025-11-25" }, + LoggerFactory, + TestContext.Current.CancellationToken); // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout // because the response stream is flushed immediately after receiving the request diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index eb036ad..781aa71 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -26,6 +26,7 @@ + @@ -59,6 +60,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 68076e2..df5d4bd 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -147,7 +147,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); } public async ValueTask DisposeAsync() @@ -261,7 +261,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { // Configure a server that does NOT pin 2026-07-28 so it can negotiate the current - // protocol with a legacy client. The backcompat resolver path only runs when the + // initialize-handshake protocol. The backcompat resolver path only runs when the // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { @@ -293,7 +293,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory 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(); @@ -302,7 +302,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - // Initialize with the current legacy protocol so the server's backcompat resolver runs. + // Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs. var initJson = """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} """; @@ -337,7 +337,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory // the response headers arrive instead of waiting for the SSE stream to close. var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) { - Content = JsonContent(CallTool("backcompat-roots-tool")), + Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)), }; callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); @@ -456,16 +456,49 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory private long _lastRequestId = 1; - private string Request(string method, string parameters = "{}") + private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$""" - {"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}} - """; + var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject(); + if (includePerRequestMetadata) + { + AddJuly2026ProtocolMeta(paramsObj); + } + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = paramsObj, + }; + + return request.ToJsonString(); } - private string CallTool(string toolName, string arguments = "{}") => + private static void AddJuly2026ProtocolMeta(JsonObject paramsObj) + { + if (paramsObj["_meta"] is not JsonObject meta) + { + meta = []; + paramsObj["_meta"] = meta; + } + + meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion; + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "MrtrTestClient", + ["version"] = "1.0", + }; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } + } + + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} - """); + """, includePerRequestMetadata); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs index 7aafd31..1866dfe 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs @@ -48,7 +48,7 @@ public class AuthEventTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, @@ -76,7 +76,7 @@ public class AuthEventTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, Scopes = ["mcp:tools"], DynamicClientRegistration = new() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index e49cb5b..693c779 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -11,6 +11,7 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; using System.Net; using System.Net.Http.Json; using System.Security.Claims; @@ -41,7 +42,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -49,6 +50,203 @@ public class AuthTests : OAuthTestBase transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task AuthorizationCallbackHandler_ReceivesConfiguredRedirectUri() + { + await using var app = await StartMcpServerAsync(); + + var redirectUri = new Uri("http://localhost:1179/callback"); + AuthorizationCallbackContext? callbackContext = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = redirectUri, + AuthorizationCallbackHandler = (context, cancellationToken) => + { + callbackContext = context; + return HandleAuthorizationUrlAsync(context, cancellationToken); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(callbackContext); + Assert.Equal(redirectUri, callbackContext.RedirectUri); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, "https://localhost:7029")] + [InlineData(true, "https://attacker.example")] + public async Task AuthorizationRedirectDelegate_ReceivesConfiguredUrisAndSkipsResponseIssuerValidation( + bool authorizationResponseIssParameterSupported, + string? authorizationResponseIssuer) + { + TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported; + TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer; + await using var app = await StartMcpServerAsync(); + + var redirectUri = new Uri("http://localhost:1179/callback"); + Uri? receivedAuthorizationUri = null; + Uri? receivedRedirectUri = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = redirectUri, +#pragma warning disable MCP9007 // Verify the obsolete callback remains functional during its compatibility window. + AuthorizationRedirectDelegate = (authorizationUri, callbackRedirectUri, cancellationToken) => + { + receivedAuthorizationUri = authorizationUri; + receivedRedirectUri = callbackRedirectUri; + return HandleAuthorizationUrlAsync(authorizationUri, callbackRedirectUri, cancellationToken); + }, +#pragma warning restore MCP9007 + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(receivedAuthorizationUri); + Assert.Equal(redirectUri, receivedRedirectUri); + } + + [Fact] + public async Task AuthorizationRedirectDelegate_DoesNotSkipMetadataIssuerValidation() + { + TestOAuthServer.MetadataIssuerOverride = "https://attacker.example"; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), +#pragma warning disable MCP9007 // Verify the obsolete callback retains metadata issuer validation. + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, +#pragma warning restore MCP9007 + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("does not match the expected issuer", ex.Message); + } + + [Fact] + public void HttpClientTransport_RejectsBothAuthorizationCallbacks() + { +#pragma warning disable MCP9007 // Verify ambiguous legacy and current callback configuration is rejected. + var options = new ClientOAuthOptions + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (_, _) => Task.FromResult(new()), + AuthorizationRedirectDelegate = (_, _, _) => Task.FromResult("code"), + }; +#pragma warning restore MCP9007 + + var ex = Assert.Throws(() => new HttpClientTransport( + new() + { + Endpoint = new(McpServerUrl), + OAuth = options, + }, + HttpClient, + LoggerFactory)); + + Assert.Contains(nameof(ClientOAuthOptions.AuthorizationCallbackHandler), ex.Message); +#pragma warning disable MCP9007 // The obsolete property name should be included in the diagnostic. + Assert.Contains(nameof(ClientOAuthOptions.AuthorizationRedirectDelegate), ex.Message); +#pragma warning restore MCP9007 + } + + [Fact] + public async Task CanAuthenticate_WhenAuthorizationResponseStateMatches() + { + await using var app = await StartMcpServerAsync(); + + string? requestedState = null; + await using var transport = CreateOAuthTransport((context, cancellationToken) => + { + requestedState = QueryHelpers.ParseQuery(context.AuthorizationUri.Query)["state"]; + return HandleAuthorizationUrlAsync(context, cancellationToken); + }); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedState); + Assert.True(requestedState.Length >= 43); + Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationResponseStateIsMissing() + { + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport( + (_, _) => Task.FromResult(new() { Code = "unused-code" })); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("did not include the required state parameter", ex.Message); + Assert.Equal(0, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationResponseStateMismatches() + { + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport( + (_, _) => Task.FromResult( + new() { Code = "unused-code", State = "unexpected-state" })); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("state did not match", ex.Message); + Assert.Equal(0, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task AuthorizationRequests_UseUniqueStateValues() + { + await using var app = await StartMcpServerAsync(); + List requestedStates = []; + + for (var i = 0; i < 2; i++) + { + await using var transport = CreateOAuthTransport((context, cancellationToken) => + { + requestedStates.Add(QueryHelpers.ParseQuery(context.AuthorizationUri.Query)["state"].ToString()); + return HandleAuthorizationUrlAsync(context, cancellationToken); + }); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Equal(2, requestedStates.Count); + Assert.Equal(2, requestedStates.Distinct(StringComparer.Ordinal).Count()); + } + [Fact] public async Task CannotAuthenticate_WithoutOAuthConfiguration() { @@ -78,7 +276,7 @@ public class AuthTests : OAuthTestBase ClientId = "unregistered-demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -98,7 +296,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -109,6 +307,33 @@ public class AuthTests : OAuthTestBase await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("native", TestOAuthServer.LastApplicationType); + } + + [Fact] + public async Task DynamicClientRegistration_UsesExplicitApplicationType() + { + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("web", TestOAuthServer.LastApplicationType); } [Fact] @@ -122,8 +347,91 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, - ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl) + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl), + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CannotAuthenticate_WhenMetadataOmitsPkceMethods() + { + TestOAuthServer.CodeChallengeMethodsSupported = null; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + // No discovery endpoint advertises PKCE, so metadata discovery is exhausted. The precise PKCE reason + // is logged as each endpoint is skipped. + Assert.Contains( + MockLoggerProvider.LogMessages, + m => m.Exception?.Message.Contains("code_challenge_methods_supported") == true); + } + + [Fact] + public async Task CannotAuthenticate_WhenMetadataLacksS256PkceMethod() + { + TestOAuthServer.CodeChallengeMethodsSupported = ["plain"]; + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains( + MockLoggerProvider.LogMessages, + m => m.Exception?.Message.Contains("required PKCE method 'S256'") == true); + } + + [Fact] + public async Task CanAuthenticate_WhenFirstMetadataEndpointOmitsPkce_ButAnotherAdvertisesIt() + { + // The OAuth 2.0 authorization server metadata endpoint is tried before the OpenID Connect one. + // Simulate a server where only the OpenID Connect document advertises PKCE support, and verify the + // client falls through to it rather than failing on the first PKCE-less document. + TestOAuthServer.MetadataPathsWithoutPkceSupport.Add("/.well-known/oauth-authorization-server"); + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -147,7 +455,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), DynamicClientRegistration = new() { @@ -176,8 +484,12 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), + DynamicClientRegistration = new() + { + ApplicationType = "web", + }, }, }, HttpClient, LoggerFactory); @@ -198,7 +510,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(uri), }, }, HttpClient, LoggerFactory); @@ -263,7 +575,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -290,10 +602,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - lastAuthorizationUri = uri; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + lastAuthorizationUri = context.AuthorizationUri; + return HandleAuthorizationUrlAsync(context, ct); }, AdditionalAuthorizationParameters = new Dictionary { @@ -309,8 +621,10 @@ public class AuthTests : OAuthTestBase Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query); } - [Fact] - public async Task CannotOverrideExistingParameters_WithExtraParams() + [Theory] + [InlineData("redirect_uri")] + [InlineData("state")] + public async Task CannotOverrideExistingParameters_WithExtraParams(string parameterName) { await using var app = await StartMcpServerAsync(); @@ -322,10 +636,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, AdditionalAuthorizationParameters = new Dictionary { - ["redirect_uri"] = "custom_value", + [parameterName] = "custom_value", } }, }, HttpClient, LoggerFactory); @@ -347,7 +661,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -369,7 +683,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -397,11 +711,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -450,11 +764,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -545,11 +859,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -650,11 +964,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScopes.Add(query["scope"].ToString()); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -684,6 +998,122 @@ public class AuthTests : OAuthTestBase Assert.Contains("files:write", thirdScopeSet); } + [Fact] + public async Task AuthorizationFlow_ConcurrentStepUps_ReuseSteppedUpToken_WhenChallengeAddsNoNewScope() + { + // Two concurrent calls to the same tool both receive the same insufficient_scope challenge + // before either has stepped up. They serialize on the provider's token acquisition lock: the + // first runs the step-up and caches the broader token, and the second must reuse that token + // instead of failing as a "repeated" challenge. Only one interactive step-up should run. + TestOAuthServer.AuthorizationResponseIssParameterSupported = true; + TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl; + + Builder.Services.AddMcpServer() + .WithTools([ + McpServerTool.Create([McpServerTool(Name = "read-tool")] + (ClaimsPrincipal user) => + { + return "Read tool executed."; + }), + ]); + + List requestedScopes = []; + var scopeLock = new object(); + + // Release both initial challenges only after both concurrent calls have reached the server, so + // the second caller is guaranteed to be waiting on the token lock while the first steps up. + var bothChallengesReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int challengesReached = 0; + + await using var app = await StartMcpServerAsync(configureMiddleware: app => + { + app.Use(async (context, next) => + { + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/") + { + context.Request.EnableBuffering(); + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), + context.RequestAborted) as JsonRpcMessage; + + context.Request.Body.Position = 0; + + if (message is JsonRpcRequest request && request.Method == "tools/call") + { + var toolCallParams = JsonSerializer.Deserialize( + request.Params, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; + + var user = context.User; + var scopeClaim = user.FindFirst("scope")?.Value ?? ""; + var scopeSet = new HashSet(scopeClaim.Split(' ')); + + if (toolCallParams?.Name == "read-tool" && !scopeSet.Contains("files:read")) + { + if (Interlocked.Increment(ref challengesReached) == 2) + { + bothChallengesReached.TrySetResult(); + } + + await bothChallengesReached.Task.WaitAsync(TestConstants.DefaultTimeout, context.RequestAborted); + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:read\""; + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + return; + } + } + } + + await next(context); + }); + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, cancellationToken) => + { + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + lock (scopeLock) + { + requestedScopes.Add(query["scope"].ToString()); + } + return HandleAuthorizationUrlAsync(context, cancellationToken); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + // Initial connect requests "mcp:tools" from protected resource metadata. + Assert.Single(requestedScopes); + Assert.Equal("mcp:tools", requestedScopes[0]); + + var firstCall = client.CallToolAsync("read-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + var secondCall = client.CallToolAsync("read-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + + var results = await Task.WhenAll(firstCall, secondCall); + + Assert.Equal("Read tool executed.", results[0].Content[0].ToString()); + Assert.Equal("Read tool executed.", results[1].Content[0].ToString()); + + // Only one interactive step-up should have run; the second caller reused the token from the first. + Assert.Equal(2, requestedScopes.Count); + var stepUpScopes = new HashSet(requestedScopes[1]!.Split(' ')); + Assert.Contains("mcp:tools", stepUpScopes); + Assert.Contains("files:read", stepUpScopes); + } + [Fact] public async Task AuthorizationFlow_StopsSteppingUpWhenChallengeAddsNoNewScope() { @@ -748,11 +1178,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScopes.Add(query["scope"].ToString()); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -843,11 +1273,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScopes.Add(query["scope"].ToString()); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -891,7 +1321,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -930,7 +1360,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -958,7 +1388,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -991,7 +1421,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1008,6 +1438,69 @@ public class AuthTests : OAuthTestBase TestOAuthServer.MetadataRequests); } + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationServerMetadataIssuerMismatches() + { + TestOAuthServer.MetadataIssuerOverride = "https://attacker.example"; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("does not match the expected issuer", ex.Message); + Assert.Single(TestOAuthServer.MetadataRequests); + } + + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationServerMetadataOmitsIssuer() + { + TestOAuthServer.IncludeIssuerInMetadata = false; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("did not provide the required issuer", ex.Message); + Assert.Single(TestOAuthServer.MetadataRequests); + } + + [Fact] + public async Task CanAuthenticate_WhenAuthorizationResponseIssuerMatches() + { + TestOAuthServer.AuthorizationResponseIssParameterSupported = true; + TestOAuthServer.AuthorizationResponseIssuer = OAuthServerUrl; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(true, "https://attacker.example", "does not match expected issuer")] + [InlineData(true, null, "advertises RFC 9207 iss parameter support but none was received")] + [InlineData(false, "https://attacker.example", "does not match expected issuer")] + public async Task CannotAuthenticate_WhenAuthorizationResponseIssuerIsInvalid( + bool authorizationResponseIssParameterSupported, + string? authorizationResponseIssuer, + string expectedMessage) + { + TestOAuthServer.AuthorizationResponseIssParameterSupported = authorizationResponseIssParameterSupported; + TestOAuthServer.AuthorizationResponseIssuer = authorizationResponseIssuer; + + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport(); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains(expectedMessage, ex.Message); + } + [Fact] public async Task CanAuthenticate_WithResourceMetadataPathFallbacks() { @@ -1055,7 +1548,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1110,7 +1603,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1157,7 +1650,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1199,7 +1692,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1245,7 +1738,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1292,7 +1785,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1444,7 +1937,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1561,7 +2054,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1663,7 +2156,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1671,6 +2164,81 @@ public class AuthTests : OAuthTestBase transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + [Fact] + public async Task CannotAuthenticate_WithLegacyServerWhoseMetadataOmitsPkceMethods() + { + // 2025-03-26 backcompat regression guard: PRM is unavailable (resourceUri is null), but the server + // DOES serve an auth server metadata document that omits 'code_challenge_methods_supported'. + // The client must refuse to proceed rather than falling back to synthesized S256 defaults, since a + // discovered metadata document that fails PKCE validation disqualifies the legacy default fallback. + TestOAuthServer.ExpectResource = false; + + // Use JwtBearer as the challenge scheme so the 401 response does NOT include resource_metadata. + Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); + + Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters.ValidateAudience = false; + }); + + await using var app = Builder.Build(); + + app.Use(async (context, next) => + { + // Return 404 for PRM to simulate a legacy server that doesn't support RFC 9728. + if (context.Request.Path.StartsWithSegments("/.well-known/oauth-protected-resource")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Serve auth server metadata that omits 'code_challenge_methods_supported' entirely. + if (context.Request.Path.StartsWithSegments("/.well-known/oauth-authorization-server") || + context.Request.Path.StartsWithSegments("/.well-known/openid-configuration")) + { + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync($$""" + { + "issuer": "{{OAuthServerUrl}}", + "authorization_endpoint": "{{McpServerUrl}}/authorize", + "token_endpoint": "{{McpServerUrl}}/token", + "registration_endpoint": "{{McpServerUrl}}/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_post"] + } + """); + return; + } + + await next(); + }); + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcp().RequireAuthorization(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + // The specific PKCE failure reason is surfaced rather than a generic discovery failure or a + // silently-synthesized S256 fallback. + Assert.Contains("code_challenge_methods_supported", ex.Message); + } + [Fact] public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt() { @@ -1687,11 +2255,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -1719,11 +2287,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -1758,11 +2326,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, }, }, HttpClient, LoggerFactory); @@ -1795,11 +2363,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), }, @@ -1826,11 +2394,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - var query = QueryHelpers.ParseQuery(uri.Query); + var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"], }, @@ -1864,7 +2432,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, ScopeSelector = scopes => { capturedInput = scopes; @@ -1894,10 +2462,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope"); + return HandleAuthorizationUrlAsync(context, ct); }, ScopeSelector = _ => null, }, @@ -1924,10 +2492,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { - scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); - return HandleAuthorizationUrlAsync(uri, redirect, ct); + scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope"); + return HandleAuthorizationUrlAsync(context, ct); }, ScopeSelector = _ => [], }, @@ -1939,6 +2507,21 @@ public class AuthTests : OAuthTestBase Assert.False(scopePresent); } + private HttpClientTransport CreateOAuthTransport( + Func>? + authorizationCallbackHandler = null) => + new(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = authorizationCallbackHandler ?? HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + [Fact] public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope() { @@ -1955,7 +2538,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), }, diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs new file mode 100644 index 0000000..43a69de --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs @@ -0,0 +1,92 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +// SEP-837: the SDK doesn't surface or retry DCR failures itself, but a consumer built on the SDK +// must be able to. These tests prove that surface: a rejected registration propagates with enough +// context to build a meaningful error, and a consumer can retry with an adjusted redirect URI. +public class DcrFailureTests : OAuthTestBase +{ + public DcrFailureTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public async Task DcrRejection_PropagatesToConsumer_WithStatusBodyAndSentParameters() + { + await using var app = await StartMcpServerAsync(); + + // A custom-scheme redirect URI infers application_type "native"; the OIDC AS rejects it + // with 400 invalid_redirect_uri because it only registers http/https redirect URIs. + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("myapp://callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + }, + }, HttpClient, LoggerFactory); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + // The consumer needs enough to produce a meaningful error: the HTTP status, the AS error + // body (which echoes the redirect URI), and the application_type the SDK actually sent. + Assert.Contains("BadRequest", ex.Message); + Assert.Contains("invalid_redirect_uri", ex.Message); + Assert.Contains("native", ex.Message); + } + + [Fact] + public async Task ConsumerCanRetryRegistration_WithAdjustedRedirectUri_AfterRejection() + { + await using var app = await StartMcpServerAsync(); + + // First attempt: a custom-scheme redirect (native) is rejected by the AS. ApplicationType + // is held constant at "native" so only the redirect URI changes between the two attempts. + await using var firstTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("myapp://callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ApplicationType = "native", + }, + }, + }, HttpClient, LoggerFactory); + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + firstTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + // Second attempt: a new provider on the SAME HttpClient with an adjusted (loopback) redirect + // URI that the AS accepts. The retry must succeed, proving the SEP-837 MAY-retry surface works + // and that the rejected attempt left no client state behind. + await using var secondTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ApplicationType = "native", + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + secondTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("native", TestOAuthServer.LastApplicationType); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs new file mode 100644 index 0000000..825ebe2 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs @@ -0,0 +1,46 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +[CollectionDefinition(nameof(DisableConsoleParallelization), DisableParallelization = true)] +public sealed class DisableConsoleParallelization; + +[Collection(nameof(DisableConsoleParallelization))] +public class DefaultAuthorizationUrlHandlerTests(ITestOutputHelper outputHelper) : OAuthTestBase(outputHelper) +{ + [Fact] + public async Task RejectsAuthorizationCodeWithoutAbsoluteRedirectUrl() + { + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + }, + }, HttpClient, LoggerFactory); + + var originalInput = Console.In; + using var consoleInput = new StringReader("authorization-code"); + Console.SetIn(consoleInput); + + try + { + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("not a valid absolute URL", ex.Message); + } + finally + { + Console.SetIn(originalInput); + } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index f9a4b64..80167b0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -106,18 +106,41 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable return app; } - protected async Task HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken) + protected async Task HandleAuthorizationUrlAsync( + ModelContextProtocol.Authentication.AuthorizationCallbackContext authorizationContext, + CancellationToken cancellationToken) { - using var redirectResponse = await HttpClient.GetAsync(authorizationUri, cancellationToken); + using var redirectResponse = await HttpClient.GetAsync(authorizationContext.AuthorizationUri, cancellationToken); Assert.Equal(HttpStatusCode.Redirect, redirectResponse.StatusCode); var location = redirectResponse.Headers.Location; if (location is not null && !string.IsNullOrEmpty(location.Query)) { var queryParams = QueryHelpers.ParseQuery(location.Query); - return queryParams["code"]; + return new ModelContextProtocol.Authentication.AuthorizationResult + { + Code = queryParams["code"], + State = queryParams["state"], + Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null, + }; } return null; } + + protected async Task HandleAuthorizationUrlAsync( + Uri authorizationUri, + Uri redirectUri, + CancellationToken cancellationToken) + { + var result = await HandleAuthorizationUrlAsync( + new ModelContextProtocol.Authentication.AuthorizationCallbackContext + { + AuthorizationUri = authorizationUri, + RedirectUri = redirectUri, + }, + cancellationToken); + + return result?.Code; + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs index fb9e2bf..5b6f054 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs @@ -1,5 +1,6 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.AspNetCore.Tests.OAuth; @@ -26,10 +27,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledInitially = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache, }, @@ -40,7 +41,7 @@ public class TokenCacheTests : OAuthTestBase // Just connecting should trigger auth and storage. } - Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token"); + Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token"); Assert.NotNull(tokenCache.LastStoredToken); var authDelegateCalledAgain = false; @@ -53,10 +54,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledAgain = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache }, @@ -64,7 +65,7 @@ public class TokenCacheTests : OAuthTestBase await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when token is valid"); + Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when token is valid"); } [Fact] @@ -82,7 +83,7 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, TokenCache = tokenCache }, }, HttpClient, LoggerFactory); @@ -109,10 +110,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalled = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache, }, @@ -120,7 +121,7 @@ public class TokenCacheTests : OAuthTestBase await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(authDelegateCalled, "AuthorizationRedirectDelegate should be called when cached token is invalid"); + Assert.True(authDelegateCalled, "AuthorizationCallbackHandler should be called when cached token is invalid"); Assert.NotNull(tokenCache.LastStoredToken); Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } @@ -141,10 +142,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledInitially = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache, }, @@ -155,7 +156,7 @@ public class TokenCacheTests : OAuthTestBase // Just connecting should trigger auth and storage. } - Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token"); + Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token"); Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet"); Assert.NotNull(tokenCache.LastStoredToken); @@ -171,10 +172,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationRedirectDelegate = (uri, redirect, ct) => + AuthorizationCallbackHandler = (context, ct) => { authDelegateCalledAgain = true; - return HandleAuthorizationUrlAsync(uri, redirect, ct); + return HandleAuthorizationUrlAsync(context, ct); }, TokenCache = tokenCache }, @@ -182,11 +183,447 @@ public class TokenCacheTests : OAuthTestBase await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when refresh token is valid"); + Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when refresh token is valid"); Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed"); Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } + [Fact] + public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenFromDifferentAuthorizationServer() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com"; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("explicitly configured client credentials", exception.Message); + Assert.False(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal("https://different-authorization-server.example.com", tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenIssuedToDifferentClient() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.ClientId = "different-client"; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal("demo-client", tokenCache.LastStoredToken.ClientId); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ExplicitClientWithLegacyUnboundCache_Reauthorizes() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = null; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingPersistedCredentials() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + var authDelegateCalledInitially = false; + + // First "process": no ClientId is configured, so the client registers dynamically. + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ClientUri = new Uri("https://example.com"), + }, + AuthorizationCallbackHandler = (context, ct) => + { + authDelegateCalledInitially = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync(setupTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + { + // Just connecting should trigger dynamic registration, authorization, and storage. + } + + Assert.True(authDelegateCalledInitially, "Authorization callback should be called for the initial authorization"); + Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet"); + Assert.NotNull(tokenCache.LastStoredToken); + Assert.False( + string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId), + "The dynamically registered client ID should be persisted alongside the tokens"); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + + // Simulate a cold start: the access token is no longer valid, but the refresh token persists. + // The new provider has no client ID configured and must restore it from the cache to refresh + // instead of throwing "Client ID is not available". + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + var authDelegateCalledAgain = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ClientUri = new Uri("https://example.com"), + }, + AuthorizationCallbackHandler = (context, ct) => + { + authDelegateCalledAgain = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.False(authDelegateCalledAgain, "Authorization callback should not be called when the persisted refresh token can be used"); + Assert.True(TestOAuthServer.HasRefreshedToken, "Token should have been refreshed using the persisted client credentials"); + Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); + } + + [Fact] + public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizationServer_Reregisters() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com"; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ColdStartWithLegacyUnboundCredentials_Reregisters() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + Assert.False(string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId)); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = null; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ColdStartWithoutPersistedClientId_FallsBackToReauthorization() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + var authDelegateCalledInitially = false; + + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ClientUri = new Uri("https://example.com"), + }, + AuthorizationCallbackHandler = (context, ct) => + { + authDelegateCalledInitially = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync(setupTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.True(authDelegateCalledInitially, "Authorization callback should be called for the initial authorization"); + Assert.NotNull(tokenCache.LastStoredToken); + + // Simulate a durable cache that persisted tokens but not the client registration (for example + // an entry written by an older version). On a cold start the provider cannot refresh without a + // client ID and must fall back to a fresh authorization rather than throwing. + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.ClientId = null; + tokenCache.LastStoredToken.ClientSecret = null; + tokenCache.LastStoredToken.TokenEndpointAuthMethod = null; + var authDelegateCalledAgain = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() + { + ClientName = "Test MCP Client", + ClientUri = new Uri("https://example.com"), + }, + AuthorizationCallbackHandler = (context, ct) => + { + authDelegateCalledAgain = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + // Should not throw "Client ID is not available"; instead re-authorizes via dynamic + // registration and the authorization-code flow. + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authDelegateCalledAgain, "Authorization callback should be called to re-authorize when no client ID is available to refresh"); + Assert.False(TestOAuthServer.HasRefreshedToken, "A refresh should not be attempted without a client ID"); + } + private TokenContainer CreateInvalidToken() { return new TokenContainer diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 366e427..d3848e0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -23,15 +23,17 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn private WebApplication? _app; - private async Task StartAsync() + private async Task StartAsync(string? protocolVersion = null) { Builder.Services .AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() - .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); + .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]) + .WithTools(); _app = Builder.Build(); _app.MapMcp(); @@ -82,7 +84,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn return JsonNode.Parse(body)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -97,7 +99,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -105,6 +107,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); + Assert.Equal( + nameof(RawHttpConformanceTests), + json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); // Per SEP-2567, starting with the 2026-07-28 protocol revision Streamable HTTP no longer // supports sessions: the server MUST NOT issue a Mcp-Session-Id. @@ -119,20 +124,129 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + var capabilities = json["result"]!["capabilities"]!.AsObject(); + Assert.False(capabilities.ContainsKey("logging")); + Assert.True(capabilities.ContainsKey("tools")); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind()); Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue()); Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); + Assert.Null(json["result"]!["serverInfo"]); + Assert.Equal( + nameof(RawHttpConformanceTests), + json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_UnknownMethod_Returns404_WithMethodNotFound() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":17,""method"":""unknown/method"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "unknown/method"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(17, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue()); + } + + [Theory] + [InlineData("initialize")] + [InlineData("ping")] + [InlineData("logging/setLevel")] + [InlineData("resources/subscribe")] + [InlineData("resources/unsubscribe")] + public async Task July2026Post_RemovedMethod_Returns404_WithMethodNotFound(string method) + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":18,""method"":""" + method + + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", method); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(18, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_MissingRequiredCapability_Returns400() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":19,""method"":""tools/call"",""params"":{""name"":""requires_sampling"",""arguments"":{}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "requires_sampling"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(19, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_SlowHandler_MissingRequiredCapability_Returns400() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":20,""method"":""tools/call"",""params"":{""name"":""slow_requires_sampling"",""arguments"":{}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "slow_requires_sampling"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(20, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] @@ -151,7 +265,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload - // listing the supported versions. The dual-era client uses this to switch versions without fallback. + // listing the supported versions. The dual-path client uses this to switch versions without fallback. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); @@ -160,7 +274,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] @@ -171,17 +285,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn // The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a // different (still individually supported) version. Per SEP-2575 the server MUST reject the // disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body - // checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a - // legacy one and falling back to the initialize handshake. + // checks) so a conformant client on this revision surfaces the error instead of mistaking the + // per-request-metadata server for an initialize-handshake one and falling back to initialize. var body = @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); @@ -203,7 +318,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -214,11 +329,166 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn } [Fact] - public async Task LegacyInitialize_StillSucceeds_OnDefaultServer() + public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion() + { + await StartAsync(McpProtocolVersions.November2025ProtocolVersion); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); + + var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson) { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"; + var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ","; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // A missing (rather than mismatched) _meta protocol version is an Invalid params rejection + // per SEP-2575; -32020 HeaderMismatch is reserved for values present on both sides that + // disagree. + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("{}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":{}}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":""2025-11-25""}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task ModernOnlyServer_HeaderlessRequest_StillRequiresModernMetadata(string metaJson, McpErrorCode expectedError) + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":" + metaJson + "}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)expectedError, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // The 2025-03-26 header default only applies when the server has "no other way to identify the + // version". The body metadata is another way, so the transport does not reject the request on the + // strength of a reserved _meta value. stdio, which has no header at all, behaves identically. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue()); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored() + { + await StartAsync(); + + // No header and no established era means legacy handling, where the reserved namespace is opaque. + // A value we cannot read is not a version claim, so it must not turn a legacy request into an error. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored() + { + await StartAsync(); + + // A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); + } + + [Fact] + public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", RequestMethods.Initialize); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -226,6 +496,63 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); + + // resultType is a 2026-07-28 result field, and the initialize handshake only ever negotiates + // 2025-11-25 or earlier. It must not appear on the InitializeResult, or strict 2025-11-25 clients + // reject the handshake (issue #1721). + Assert.False(json["result"]!.AsObject().ContainsKey("resultType"), + "InitializeResult must not carry resultType on a 2025-11-25 handshake."); + } + + [Fact] + public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints() + { + await StartAsync(); + + // A 2025-11-25 client completes the initialize handshake and then sends subsequent requests with + // the MCP-Protocol-Version header pinned to the negotiated revision. The server must not decorate + // the result with the 2026-07-28-exclusive resultType/ttlMs/cacheScope fields (issue #1721). + var initBody = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + using (var initRequest = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(initBody) }) + using (var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); + } + + var body = @"{""jsonrpc"":""2.0"",""id"":2,""method"":""tools/list"",""params"":{}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var result = json["result"]!.AsObject(); + Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); + } + + [Theory] + [InlineData(@"""2025-11-25""")] + [InlineData(@"""2026-07-28""")] + [InlineData(@"""9999-99-99""")] + [InlineData("{}")] + public async Task Legacy2025Post_IgnoresFuturePerRequestMetadata(string protocolVersionJson) + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + "," + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("future-metadata-ignored", json["result"]!["content"]![0]!["text"]!.GetValue()); } [Fact] @@ -237,10 +564,92 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn 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); } -} + [Fact] + public async Task July2026Post_MissingClientCapabilities_Returns400_WithInvalidParams() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":20,""method"":""server/discover"",""params"":{""_meta"":{" + + @"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(20, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("5")] + [InlineData("\"caps\"")] + [InlineData("[]")] + [InlineData("true")] + public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvalidParams(string clientCapabilitiesJson) + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":21,""method"":""server/discover"",""params"":{""_meta"":{" + + @"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""," + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":" + clientCapabilitiesJson + "}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // A present-but-wrong-shape clientCapabilities must be rejected up front with -32602 / 400, + // not surface later as a generic internal error on a 200 response. + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(21, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [McpServerToolType] + private sealed class CapabilityTools + { + private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1); + + [McpServerTool(Name = "legacy_meta_probe")] + public static string LegacyMetaProbe(RequestContext context) + { + var requestContext = context.JsonRpcRequest.Context; + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, requestContext?.ProtocolVersion); + Assert.Null(requestContext?.ClientInfo); + Assert.Null(requestContext?.ClientCapabilities); + Assert.Null(context.Server.ClientInfo); + Assert.Null(context.Server.ClientCapabilities); + return "future-metadata-ignored"; + } + + [McpServerTool(Name = "requires_sampling")] + public static string RequiresSampling() => + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + + [McpServerTool(Name = "slow_requires_sampling")] + public static async Task SlowRequiresSampling(CancellationToken cancellationToken) + { + await Task.Delay(SlowHandlerDelay, cancellationToken); + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 807daae..166622d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -35,10 +35,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes { 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 class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes 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); @@ -112,7 +109,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes var body = july2026Protocol ? """ {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} - """.Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion) + """.Replace("PROTOCOL_VERSION", McpProtocolVersions.July2026ProtocolVersion) : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; var request = new HttpRequestMessage(HttpMethod.Post, "") @@ -125,7 +122,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes if (july2026Protocol) { - request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "blockingTool"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 29e6948..8721820 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,8 +490,8 @@ public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOut 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/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 82fb9c0..2cb1d73 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,133 +1,19 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; /// -/// Shared fixture that starts a single ConformanceServer instance for all tests in -/// . This avoids TCP port TIME_WAIT conflicts -/// that occur when each test starts and stops its own server on the same port. -/// -public class ConformanceServerFixture : IAsyncLifetime -{ - // Use different ports for each target framework to allow parallel execution - // net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003 - private static int GetPortForTargetFramework() - { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); - - return targetFramework switch - { - "net10.0" => 3001, - "net9.0" => 3002, - "net8.0" => 3003, - _ => 3001 // Default fallback - }; - } - - private Task? _serverTask; - private CancellationTokenSource? _serverCts; - - public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; - - public async ValueTask InitializeAsync() - { - _serverCts = new CancellationTokenSource(); - // Explicitly pass "--stateless false" so this stateful fixture is immune to a globally - // set MCP_CONFORMANCE_STATELESS environment variable (the command-line switch wins). - _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token)); - - // Wait for server to be ready (retry for up to 30 seconds) - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; - - while (stopwatch.Elapsed < timeout) - { - try - { - await httpClient.GetAsync($"{ServerUrl}/health"); - return; - } - catch (HttpRequestException) - { - // Connection refused means server not ready yet - } - catch (TaskCanceledException) - { - // Timeout means server might be processing, give it more time - } - - await Task.Delay(500); - } - - throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); - } - - public async ValueTask DisposeAsync() - { - if (_serverCts != null) - { - _serverCts.Cancel(); - if (_serverTask != null) - { - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch - { - // Ignore exceptions during shutdown - } - } - _serverCts.Dispose(); - } - } -} - -/// -/// Shared fixture that starts a single stateless ConformanceServer for the -/// SEP-2322 MRTR scenarios in . Those scenarios negotiate the -/// 2026-07-28 revision, which is served only on a stateless server, so they -/// cannot reuse the stateful . Reusing one server across all -/// the MRTR theory rows avoids the TCP TIME_WAIT conflicts that per-test restarts on a single port -/// cause on Windows. Uses a dedicated port range (303x) so it runs in parallel with the stateful -/// fixture (300x), the caching server (301x), and the SEP-2243 servers (302x) without colliding. -/// -public sealed class StatelessMrtrConformanceServerFixture : IAsyncLifetime -{ - private StatelessConformanceServer? _server; - - public string ServerUrl => _server?.ServerUrl - ?? throw new InvalidOperationException("The stateless conformance server has not been started."); - - public async ValueTask InitializeAsync() - { - _server = await StatelessConformanceServer.StartAsync(CancellationToken.None, basePort: 3031); - } - - public async ValueTask DisposeAsync() - { - if (_server is not null) - { - await _server.DisposeAsync(); - } - } -} - -/// -/// Runs the official MCP conformance tests against the ConformanceServer. -/// Uses a shared so the server is started once -/// and reused across all tests, avoiding TCP port conflicts on Windows. +/// Runs the official MCP conformance tests against the ConformanceServer. Uses a shared +/// so the server is started once and reused across all +/// tests, avoiding TCP port conflicts on Windows. Stateful scenarios use the server's "/" endpoint +/// (); 2026-07-28 scenarios that negotiate the +/// stateless lifecycle use its "/stateless" endpoint +/// (). /// +[Collection(nameof(ConformanceServerCollection))] public class ServerConformanceTests( ConformanceServerFixture fixture, - StatelessMrtrConformanceServerFixture statelessFixture, ITestOutputHelper output) - : IClassFixture, IClassFixture { [Fact] public async Task RunConformanceTests() @@ -141,12 +27,9 @@ public class ServerConformanceTests( } [Fact] - public async Task RunPendingConformanceTest_JsonSchema202012() + public async Task RunConformanceTest_JsonSchema202012() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen( - RuntimeInformation.IsOSPlatform(OSPlatform.Windows), - "Pending Node-based conformance scenario is unstable on Windows due to a libuv shutdown assertion."); var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario json-schema-2020-12"); @@ -155,12 +38,9 @@ public class ServerConformanceTests( } [Fact] - public async Task RunPendingConformanceTest_ServerSsePolling() + public async Task RunConformanceTest_ServerSsePolling() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen( - RuntimeInformation.IsOSPlatform(OSPlatform.Windows), - "Pending Node-based conformance scenario is unstable on Windows due to a libuv shutdown assertion."); var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario server-sse-polling"); @@ -174,17 +54,13 @@ public class ServerConformanceTests( Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - - // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless - // lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call - // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with - // the stateful class fixture (300x) or the caching stateless server (301x). - await using var server = await StatelessConformanceServer.StartAsync( - TestContext.Current.CancellationToken, basePort: 3021); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); + // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless lifecycle, + // so it runs against the shared server's stateless endpoint (a stateful server rejects the + // un-initialized list/call requests with JSON-RPC -32000). var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); + $"server --url {fixture.StatelessServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -196,13 +72,10 @@ public class ServerConformanceTests( Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - - await using var server = await StatelessConformanceServer.StartAsync( - TestContext.Current.CancellationToken, basePort: 3024); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); + $"server --url {fixture.StatelessServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -215,8 +88,8 @@ public class ServerConformanceTests( // "input-required-result-*"; the wire-level tool names now match the new convention). // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28, // so the csharp-sdk emits InputRequiredResult on the wire. Because the 2026-07-28 revision is - // served only on a stateless server, the scenarios run against a dedicated stateless server - // (StatelessMrtrConformanceServerFixture); a stateful server refuses these requests. + // served only on a stateless server, the scenarios run against the shared server's stateless + // endpoint (ConformanceServerFixture.StatelessServerUrl); a stateful server refuses these requests. // These tests skip until the installed conformance package ships SEP-2322 scenarios // (see ). // @@ -245,15 +118,40 @@ public class ServerConformanceTests( public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package."); var result = await RunStatelessConformanceTestAsync( - $"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28"); + $"server --url {fixture.StatelessServerUrl} --scenario {scenario} --spec-version 2026-07-28"); Assert.True(result.Success, $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + [Theory] + [InlineData("tasks-wire-fields")] + [InlineData("tasks-lifecycle")] + [InlineData("tasks-capability-negotiation")] + [InlineData("tasks-request-headers")] + [InlineData("tasks-dispatch-and-envelope")] + [InlineData("tasks-status-notifications")] + [InlineData("tasks-required-task-error")] + [InlineData("tasks-request-state-removal")] + [InlineData("tasks-mrtr-input")] + [InlineData("tasks-mrtr-composition")] + public async Task RunTasksExtensionConformanceTest(string scenario) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen( + !NodeHelpers.HasTasksExtensionScenarios(), + "SEP-2663 Tasks extension scenarios are not available in the installed conformance package."); + + var result = await RunStatelessConformanceTestAsync( + $"server --url {fixture.StatelessServerUrl} --scenario {scenario}"); + + Assert.True(result.Success, + $"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { return await NodeHelpers.RunServerConformanceAsync( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index 7609e82..56fe09b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ public class SessionMigrationTests(ITestOutputHelper outputHelper) : KestrelInMe 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.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 52b0d36..a95d989 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -9,6 +10,7 @@ using System.Diagnostics; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -154,6 +156,43 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType(toolContent).Text); } + [Fact] + public async Task ClientCapabilities_AreAvailableFromInjectedServer() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "getClientCapabilities", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "sampling|roots|elicitation", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + + [Fact] + public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "testInterceptedRequests", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "intercepted-sample|Seattle", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + [Fact] public async Task UnsolicitedNotification_Fails_WithInvalidOperationException() { @@ -380,13 +419,203 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem Assert.Null(grantedNotifications["resourceSubscriptions"]); } + [Fact] + public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_StreamsNotificationOverHeldOpenPost() + { + // The built-in stateless handler grants nothing and returns immediately because there is no + // session-wide channel. A custom SubscriptionsListenHandler can instead stream notifications over the + // held-open POST response (the listen request's RelatedTransport), which is the solicited + // server-to-client stream. This is the core scenario of issue #1662. + const string subscribedUri = "resource://test"; + + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithSubscriptionsListenHandler(async (request, cancellationToken) => + { + var subscriptionId = request.JsonRpcRequest.Id; + + var ack = new JsonRpcNotification + { + Method = NotificationMethods.SubscriptionsAcknowledgedNotification, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ResourceSubscriptions = request.Params.Notifications.ResourceSubscriptions, + }, + }, + McpJsonUtilities.DefaultOptions), + }; + TagWithSubscriptionId(ack, subscriptionId); + await request.Server.SendMessageAsync(ack, cancellationToken); + + var updated = new JsonRpcNotification + { + Method = NotificationMethods.ResourceUpdatedNotification, + Params = new JsonObject { ["uri"] = subscribedUri }, + }; + TagWithSubscriptionId(updated, subscriptionId); + await request.Server.SendMessageAsync(updated, cancellationToken); + + // Complete the stream so the POST response finishes; the buffered notifications flush to the client. + return new EmptyResult(); + }); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + await using var client = await ConnectMcpClientAsync(); + + var ackChannel = Channel.CreateUnbounded(); + var updatedChannel = Channel.CreateUnbounded(); + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification, + (notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; }); + + var listenRequest = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { ResourceSubscriptions = [subscribedUri] }, + }, + McpJsonUtilities.DefaultOptions), + }; + + await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var subscriptionId = GetSubscriptionId(ack); + Assert.NotNull(subscriptionId); + + var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(subscriptionId, GetSubscriptionId(updated)); + Assert.Equal(subscribedUri, Assert.IsType(updated.Params)["uri"]?.GetValue()); + } + + [Fact] + public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_AdvertisesAndStreamsListChanged() + { + // resources/updated rides on resources.subscribe, which is never suppressed, so it cannot prove the + // listChanged capability is advertised. A custom SubscriptionsListenHandler gives a stateless server a + // way to deliver */list_changed over the held-open POST, so server/discover (the only path a + // 2026-07-28+ client uses) must advertise tools.listChanged rather than dropping it (issue #1662). + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) + .WithSubscriptionsListenHandler(async (request, cancellationToken) => + { + var subscriptionId = request.JsonRpcRequest.Id; + + var ack = new JsonRpcNotification + { + Method = NotificationMethods.SubscriptionsAcknowledgedNotification, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = request.Params.Notifications.ToolsListChanged, + }, + }, + McpJsonUtilities.DefaultOptions), + }; + TagWithSubscriptionId(ack, subscriptionId); + await request.Server.SendMessageAsync(ack, cancellationToken); + + var listChanged = new JsonRpcNotification { Method = NotificationMethods.ToolListChangedNotification }; + TagWithSubscriptionId(listChanged, subscriptionId); + await request.Server.SendMessageAsync(listChanged, cancellationToken); + + return new EmptyResult(); + }); + + // Advertise tools.listChanged so the per-response capability decision has something to preserve. + Builder.Services.Configure(options => + { + options.Capabilities ??= new(); + options.Capabilities.Tools ??= new(); + options.Capabilities.Tools.ListChanged = true; + }); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + await using var client = await ConnectMcpClientAsync(); + + // The stateless server can now deliver tools/list_changed over the custom listen stream, so the + // capability must survive on the server/discover response instead of being cleared. + Assert.True(client.ServerCapabilities.Tools?.ListChanged); + + var listChangedChannel = Channel.CreateUnbounded(); + await using var listChangedReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { listChangedChannel.Writer.TryWrite(notification); return default; }); + + var listenRequest = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { ToolsListChanged = true }, + }, + McpJsonUtilities.DefaultOptions), + }; + + await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + var listChangedNotification = await listChangedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.NotNull(GetSubscriptionId(listChangedNotification)); + } + + private static string? GetSubscriptionId(JsonRpcNotification notification) + => ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString(); + + private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId) + { + var paramsObject = notification.Params as JsonObject ?? new JsonObject(); + if (paramsObject["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + paramsObject["_meta"] = meta; + } + + meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch + { + string stringId => JsonValue.Create(stringId), + long longId => JsonValue.Create(longId), + _ => null, + }; + + notification.Params = paramsObject; + } + [McpServerTool(Name = "testSamplingErrors")] public static async Task TestSamplingErrors(McpServer server) { const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode."; - // Even when the client has sampling support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Sampling); var asSamplingChatClientEx = Assert.Throws(() => server.AsSamplingChatClient()); Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message); @@ -406,8 +635,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedRootsErrorMessage = "Roots are not supported in stateless mode."; - // Even when the client has roots support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Roots); var requestRootsEx = Assert.Throws(() => server.RequestRootsAsync(new())); Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message); @@ -424,8 +653,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode."; - // Even when the client has elicitation support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Elicitation); var requestElicitationEx = await Assert.ThrowsAsync(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask()); Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message); @@ -437,6 +666,39 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem return ex.Message; } + [McpServerTool(Name = "getClientCapabilities")] + public static string GetClientCapabilities(McpServer server) => + string.Join( + '|', + server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling", + server.ClientCapabilities?.Roots is null ? "no-roots" : "roots", + server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation"); + + [McpServerTool(Name = "testInterceptedRequests")] + public static async Task TestInterceptedRequests(McpServer server) + { + // Background task execution replaces the server-to-client channel with an interceptor that parks the + // request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not + // depend on session affinity, which is why these requests are allowed through in stateless mode. +#pragma warning disable MCPEXP002 + var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch + { + RequestMethods.SamplingCreateMessage => + """{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""", + RequestMethods.ElicitationCreate => + """{"action":"accept","content":{"city":"Seattle"}}""", + _ => throw new UnreachableException(), + }))); +#pragma warning restore MCPEXP002 + + var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?"); + var elicitResult = await intercepted.ElicitAsync( + "Which city?", + options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options }); + + return $"{samplingResponse.Text}|{elicitResult.Content?.City}"; + } + [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -450,3 +712,12 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem public void Report(T value) => handler(value); } } + +public class CityForm +{ + public string? City { get; set; } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CityForm))] +internal partial class StatelessInterceptorJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 57b12d2..649286b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; @@ -11,6 +12,7 @@ using System.Net; using System.Threading; using System.Threading.Tasks; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore.Tests; @@ -606,6 +608,31 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper Assert.Equal("us-west-2", capturedHeaders["Mcp-Param-Region"]); } + [Fact] + public async Task TasksClient_SendsRoutingNameHeader_EndToEnd() + { + var capturedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + await StartHeaderCapturingServer(capturedHeaders, supportsTasks: true); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = "2026-07-28" }, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + capturedHeaders.Clear(); + await client.GetTaskAsync("task-42", TestContext.Current.CancellationToken); + + Assert.Equal("tasks/get", capturedHeaders[McpHttpHeaders.Method]); + Assert.Equal("task-42", capturedHeaders[McpHttpHeaders.Name]); + } + private async Task StartHeaderToolServer() { Builder.Services.Configure(options => @@ -683,7 +710,9 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper await _app.StartAsync(TestContext.Current.CancellationToken); } - private async Task StartHeaderCapturingServer(Dictionary capturedHeaders) + private async Task StartHeaderCapturingServer( + Dictionary capturedHeaders, + bool supportsTasks = false) { Builder.Services.Configure(options => { @@ -706,12 +735,48 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper Result = JsonSerializer.SerializeToNode(new InitializeResult { ProtocolVersion = "2025-11-25", - Capabilities = new() { Tools = new() }, + Capabilities = new() + { + Tools = new(), + Extensions = supportsTasks + ? new Dictionary + { + ["io.modelcontextprotocol/tasks"] = new JsonObject(), + } + : null, + }, ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) }); } + if (request.Method == "server/discover" && supportsTasks) + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = ["2026-07-28"], + Capabilities = new() + { + Tools = new(), + Extensions = new Dictionary + { + ["io.modelcontextprotocol/tasks"] = new JsonObject(), + }, + }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "header-capture", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + ResultType = "complete", + }, McpJsonUtilities.DefaultOptions), + }); + } + if (request.Method == "tools/list") { return Results.Json(new JsonRpcResponse @@ -732,7 +797,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper }); } - if (request.Method == "tools/call") + if (request.Method is "tools/call" or "tasks/get") { // Capture all MCP headers for verification foreach (var header in context.Request.Headers) @@ -743,6 +808,16 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper } } + if (request.Method == "tasks/get") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonNode.Parse( + """{"taskId":"task-42","status":"working","createdAt":"2026-01-01T00:00:00Z","lastUpdatedAt":"2026-01-01T00:00:00Z"}"""), + }); + } + var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo()); return Results.Json(new JsonRpcResponse { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 04ebb64..dd051e4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -204,6 +204,31 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + [Theory] + [InlineData(false, McpProtocolVersions.March2025ProtocolVersion, McpProtocolVersions.November2025ProtocolVersion)] + [InlineData(false, McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.March2025ProtocolVersion)] + [InlineData(true, McpProtocolVersions.March2025ProtocolVersion, McpProtocolVersions.November2025ProtocolVersion)] + [InlineData(true, McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.March2025ProtocolVersion)] + public async Task InitializeRequest_IsBadRequest_WhenProtocolVersionHeaderDoesNotMatchBody( + bool stateless, + string protocolVersionHeader, + string protocolVersionBody) + { + await StartAsync(stateless); + + var body = $$$$""" + {"jsonrpc":"2.0","id":4242,"method":"initialize","params":{"protocolVersion":"{{{{protocolVersionBody}}}}","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + """; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("MCP-Protocol-Version", protocolVersionHeader); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(4242, json!["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + [Fact] public async Task GetRequest_IsBadRequest_WithInvalidProtocolVersionHeader() { @@ -951,12 +976,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server. await StartAsync(stateless: true); - // Initialize with the 2026-07-28 protocol version to enable header validation - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + // Probe with the 2026-07-28 protocol version to enable header validation. + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header @@ -968,11 +993,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method @@ -984,11 +1009,11 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -998,12 +1023,12 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper } [Fact] - public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() + public async Task InitializeHandshakeVersion_DoesNotRequireMcpMethodHeader() { await StartAsync(); await CallInitializeAndValidateAsync(); - // With the legacy version, Mcp-Method header is not required + // With the initialize-handshake version, Mcp-Method header is not required. using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); request.Headers.Add("MCP-Protocol-Version", "2025-03-26"); @@ -1013,25 +1038,25 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync() + private async Task CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); var rpcResponse = await AssertSingleSseResponseAsync(response); - AssertServerInfo(rpcResponse); + AssertDiscoverServerInfo(rpcResponse); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id. - // Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion @@ -1103,10 +1128,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper """; } - private string CallTool(string toolName, string arguments = "{}") => - Request("tools/call", $$""" - {"name":"{{toolName}}","arguments":{{arguments}}} - """); + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = false) + { + var meta = includePerRequestMetadata + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return Request("tools/call", "{\"name\":\"" + toolName + "\",\"arguments\":" + arguments + meta + "}"); + } private string CallToolWithProgressToken(string toolName, string arguments = "{}") => Request("tools/call", $$$""" @@ -1126,6 +1155,20 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper return initializeResult; } + private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse) + { + var discoverResult = AssertType(rpcResponse.Result); + + // Server identity is carried in the result _meta, not the discover body. + var serverInfoNode = discoverResult.Meta?[MetaKeys.ServerInfo]; + Assert.NotNull(serverInfoNode); + var serverInfo = JsonSerializer.Deserialize(serverInfoNode, McpJsonUtilities.DefaultOptions); + Assert.NotNull(serverInfo); + Assert.Equal(nameof(StreamableHttpServerConformanceTests), serverInfo.Name); + Assert.Equal("73", serverInfo.Version); + return discoverResult; + } + private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse) { var callToolResponse = AssertType(rpcResponse.Result); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs index c632630..59cce6b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs @@ -92,7 +92,7 @@ public sealed class KestrelInMemoryConnection : ConnectionContext 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(); diff --git a/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs b/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs new file mode 100644 index 0000000..d9c148d --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs @@ -0,0 +1,298 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; + +internal sealed class ConformanceContext +{ + private readonly JsonElement _root; + + private ConformanceContext(JsonElement root) + { + _root = root; + } + + public static ConformanceContext? Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + using var document = JsonDocument.Parse(json); + return new ConformanceContext(document.RootElement.Clone()); + } + + public string GetRequiredString(string propertyName) + { + if (!_root.TryGetProperty(propertyName, out var value) || + value.ValueKind != JsonValueKind.String || + string.IsNullOrEmpty(value.GetString())) + { + throw new InvalidOperationException( + $"MCP_CONFORMANCE_CONTEXT is missing required string property '{propertyName}'."); + } + + return value.GetString()!; + } + + public string? GetString(string propertyName) => + _root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} + +internal static class ConformanceOAuthHelpers +{ + private const string ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + public static async Task AcquireClientCredentialsTokenAsync( + Uri resourceUri, + ConformanceContext context, + bool usePrivateKeyJwt, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false); + var clientId = context.GetRequiredString("client_id"); + + Dictionary formFields = new() + { + ["grant_type"] = "client_credentials", + ["resource"] = discovery.Resource, + }; + + if (discovery.Scopes.Count > 0) + { + formFields["scope"] = string.Join(" ", discovery.Scopes); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, discovery.TokenEndpoint); + if (usePrivateKeyJwt) + { + var algorithm = context.GetString("signing_algorithm") ?? "ES256"; + if (!string.Equals(algorithm, "ES256", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unsupported client assertion signing algorithm '{algorithm}'."); + } + + formFields["client_assertion_type"] = ClientAssertionType; + formFields["client_assertion"] = CreateEs256ClientAssertion( + clientId, + discovery.AuthorizationServer, + context.GetRequiredString("private_key_pem")); + } + else + { + var credentials = $"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(context.GetRequiredString("client_secret"))}"; + request.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))); + } + + request.Content = new FormUrlEncodedContent(formFields); + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await ReadAccessTokenAsync(response, cancellationToken).ConfigureAwait(false); + } + + public static async Task AcquireEnterpriseTokenAsync( + Uri resourceUri, + ConformanceContext context, + ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false); + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = context.GetRequiredString("client_id"), + ClientSecret = context.GetRequiredString("client_secret"), + TokenEndpointAuthMethod = "client_secret_basic", + IdpClientId = context.GetRequiredString("idp_client_id"), + IdpTokenEndpoint = context.GetRequiredString("idp_token_endpoint"), + IdTokenCallback = (_, _) => Task.FromResult(context.GetRequiredString("idp_id_token")), + }, + httpClient, + loggerFactory); + + var tokens = await provider.GetAccessTokenAsync( + resourceUri, + new Uri(discovery.AuthorizationServer), + cancellationToken).ConfigureAwait(false); + return tokens.AccessToken; + } + + public static HttpClient CreateBearerHttpClient(string accessToken) + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return httpClient; + } + + private static async Task DiscoverAsync( + HttpClient httpClient, + Uri resourceUri, + CancellationToken cancellationToken) + { + JsonElement? protectedResourceMetadata = null; + foreach (var metadataUri in GetProtectedResourceMetadataUris(resourceUri)) + { + using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + continue; + } + + protectedResourceMetadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false); + break; + } + + if (protectedResourceMetadata is not { } prm || + !prm.TryGetProperty("authorization_servers", out var authorizationServers) || + authorizationServers.ValueKind != JsonValueKind.Array || + authorizationServers.GetArrayLength() == 0) + { + throw new InvalidOperationException($"No authorization server was discovered for MCP resource '{resourceUri}'."); + } + + var authorizationServer = authorizationServers[0].GetString() + ?? throw new InvalidOperationException("The discovered authorization server URI was null."); + var resource = prm.TryGetProperty("resource", out var resourceProperty) + ? resourceProperty.GetString() ?? resourceUri.ToString() + : resourceUri.ToString(); + List scopes = []; + if (prm.TryGetProperty("scopes_supported", out var scopesProperty) && + scopesProperty.ValueKind == JsonValueKind.Array) + { + foreach (var scope in scopesProperty.EnumerateArray()) + { + if (scope.GetString() is { Length: > 0 } value) + { + scopes.Add(value); + } + } + } + + var authorizationServerUri = new Uri(authorizationServer); + foreach (var metadataUri in GetAuthorizationServerMetadataUris(authorizationServerUri)) + { + using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + continue; + } + + var metadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false); + if (metadata.TryGetProperty("token_endpoint", out var tokenEndpointProperty) && + tokenEndpointProperty.GetString() is { Length: > 0 } tokenEndpoint) + { + var issuer = metadata.TryGetProperty("issuer", out var issuerProperty) + ? issuerProperty.GetString() ?? authorizationServer + : authorizationServer; + return new OAuthDiscovery(resource, issuer, new Uri(tokenEndpoint), scopes); + } + } + + throw new InvalidOperationException( + $"No authorization server metadata with a token endpoint was discovered for '{authorizationServer}'."); + } + + private static IEnumerable GetProtectedResourceMetadataUris(Uri resourceUri) + { + var authority = resourceUri.GetLeftPart(UriPartial.Authority); + var path = resourceUri.AbsolutePath.Trim('/'); + if (path.Length > 0) + { + yield return new Uri($"{authority}/.well-known/oauth-protected-resource/{path}"); + } + yield return new Uri($"{authority}/.well-known/oauth-protected-resource"); + } + + private static IEnumerable GetAuthorizationServerMetadataUris(Uri authorizationServer) + { + var authority = authorizationServer.GetLeftPart(UriPartial.Authority); + var path = authorizationServer.AbsolutePath.Trim('/'); + if (path.Length == 0) + { + yield return new Uri($"{authority}/.well-known/oauth-authorization-server"); + yield return new Uri($"{authority}/.well-known/openid-configuration"); + } + else + { + yield return new Uri($"{authority}/.well-known/oauth-authorization-server/{path}"); + yield return new Uri($"{authority}/.well-known/openid-configuration/{path}"); + yield return new Uri($"{authority}/{path}/.well-known/openid-configuration"); + } + } + + private static string CreateEs256ClientAssertion(string clientId, string audience, string privateKeyPem) + { + var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"ES256","typ":"JWT"}""")); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + using var payloadStream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(payloadStream)) + { + writer.WriteStartObject(); + writer.WriteString("iss", clientId); + writer.WriteString("sub", clientId); + writer.WriteString("aud", audience); + writer.WriteNumber("iat", now); + writer.WriteNumber("exp", now + 300); + writer.WriteString("jti", Guid.NewGuid()); + writer.WriteEndObject(); + } + + var payload = Base64UrlEncode(payloadStream.ToArray()); + var signingInput = Encoding.ASCII.GetBytes($"{header}.{payload}"); + using var key = ECDsa.Create(); + key.ImportFromPem(privateKeyPem); + var signature = key.SignData( + signingInput, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return $"{header}.{payload}.{Base64UrlEncode(signature)}"; + } + + private static async Task ReadJsonAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + return document.RootElement.Clone(); + } + + private static async Task ReadAccessTokenAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Client credentials token request failed with status {(int)response.StatusCode}: {body}"); + } + + using var document = JsonDocument.Parse(body); + if (!document.RootElement.TryGetProperty("access_token", out var accessToken) || + accessToken.GetString() is not { Length: > 0 } value) + { + throw new InvalidOperationException("The token response did not contain an access_token."); + } + + return value; + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed record OAuthDiscovery( + string Resource, + string AuthorizationServer, + Uri TokenEndpoint, + IReadOnlyList Scopes); +} diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 13af5d1..718c782 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using System.Text.Json; using System.Web; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -37,12 +38,12 @@ McpClientOptions options = new() }; // The default client now prefers the 2026-07-28 protocol (probing with server/discover and -// falling back to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios -// specifically exercise the legacy initialize handshake and SSE resumability (removed in the -// 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the -// latest stable version. Other scenarios run on the 2026-07-28 default and exercise the -// server/discover probe plus the transparent legacy fallback. -if (scenario is "initialize" or "sse-retry") +// falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios +// specifically exercise the initialize handshake and SSE resumability (removed in the +// 2026-07-28 protocol) and strictly expect initialize as the first message. The alpha.9 +// json-schema-ref-no-deref server also uses a bundled Node transport that rejects the draft +// protocol header before tools/list. Pin these scenarios to the latest stable version. +if (scenario is "initialize" or "sse-retry" or "json-schema-ref-no-deref") { options.ProtocolVersion = "2025-11-25"; } @@ -71,28 +72,17 @@ if (callbackPort == 0) var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback"); // Read conformance context for scenarios that provide additional data (e.g., pre-registered credentials). -string? preRegisteredClientId = null; -string? preRegisteredClientSecret = null; var conformanceContext = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_CONTEXT"); -if (!string.IsNullOrEmpty(conformanceContext)) -{ - using var doc = JsonDocument.Parse(conformanceContext); - if (doc.RootElement.TryGetProperty("client_id", out var clientIdEl)) - { - preRegisteredClientId = clientIdEl.GetString(); - } - if (doc.RootElement.TryGetProperty("client_secret", out var clientSecretEl)) - { - preRegisteredClientSecret = clientSecretEl.GetString(); - } -} +var parsedConformanceContext = ConformanceContext.Parse(conformanceContext); +var preRegisteredClientId = parsedConformanceContext?.GetString("client_id"); +var preRegisteredClientSecret = parsedConformanceContext?.GetString("client_secret"); var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions { RedirectUri = clientRedirectUri, // Configure the metadata document URI for CIMD. ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"), - AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, }; if (preRegisteredClientId is not null) @@ -109,12 +99,55 @@ else }; } -var clientTransport = new HttpClientTransport(new() +var endpointUri = new Uri(endpoint); +HttpClientTransport clientTransport; +if (scenario is "auth/client-credentials-basic" or "auth/client-credentials-jwt") { - Endpoint = new Uri(endpoint), - TransportMode = HttpTransportMode.StreamableHttp, - OAuth = oauthOptions, -}, loggerFactory: consoleLoggerFactory); + var context = parsedConformanceContext + ?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT."); + var accessToken = await ConformanceOAuthHelpers.AcquireClientCredentialsTokenAsync( + endpointUri, + context, + usePrivateKeyJwt: scenario == "auth/client-credentials-jwt", + CancellationToken.None); + clientTransport = new HttpClientTransport( + new() + { + Endpoint = endpointUri, + TransportMode = HttpTransportMode.StreamableHttp, + }, + ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken), + consoleLoggerFactory, + ownsHttpClient: true); +} +else if (scenario == "auth/enterprise-managed-authorization") +{ + var context = parsedConformanceContext + ?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT."); + var accessToken = await ConformanceOAuthHelpers.AcquireEnterpriseTokenAsync( + endpointUri, + context, + consoleLoggerFactory, + CancellationToken.None); + clientTransport = new HttpClientTransport( + new() + { + Endpoint = endpointUri, + TransportMode = HttpTransportMode.StreamableHttp, + }, + ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken), + consoleLoggerFactory, + ownsHttpClient: true); +} +else +{ + clientTransport = new HttpClientTransport(new() + { + Endpoint = endpointUri, + TransportMode = HttpTransportMode.StreamableHttp, + OAuth = oauthOptions, + }, loggerFactory: consoleLoggerFactory); +} try { @@ -190,6 +223,19 @@ try } break; } + case "auth/authorization-server-migration": + { + await mcpClient.ListToolsAsync(); + await mcpClient.ListToolsAsync(); + break; + } + case "auth/client-credentials-basic": + case "auth/client-credentials-jwt": + case "auth/enterprise-managed-authorization": + { + await mcpClient.ListToolsAsync(); + break; + } case "http-standard-headers": { // List and call tools to test Mcp-Method and Mcp-Name headers @@ -320,6 +366,27 @@ try } break; } + case "json-schema-ref-no-deref": + { + // SEP-2106: listing tools must not dereference network $refs in a tool's + // inputSchema — the scenario's canary endpoint observes any such fetch. + await mcpClient.ListToolsAsync(); + break; + } + case "sep-2322-client-request-state": + { + // SEP-2322 (MRTR): drive the client's input-required auto-loop. The mock + // inspects the raw tools/call params: requestState echoed byte-exact (and + // omitted when the server sent none), a fresh JSON-RPC id per retry, no + // MRTR params bleeding into the unrelated call, and a missing resultType + // parsing as a terminal (complete) result. + await mcpClient.ListToolsAsync(); + await mcpClient.CallToolAsync(toolName: "test_mrtr_echo_state", arguments: new Dictionary()); + await mcpClient.CallToolAsync(toolName: "test_mrtr_unrelated", arguments: new Dictionary()); + await mcpClient.CallToolAsync(toolName: "test_mrtr_no_state", arguments: new Dictionary()); + await mcpClient.CallToolAsync(toolName: "test_mrtr_no_result_type", arguments: new Dictionary()); + break; + } default: // No extra processing for other scenarios break; @@ -340,8 +407,12 @@ catch (Exception ex) // Copied from ProtectedMcpClient sample // Simulate a user opening the browser and logging in // Copied from OAuthTestBase -static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) +static async Task HandleAuthorizationUrlAsync( + AuthorizationCallbackContext authorizationContext, + CancellationToken cancellationToken) { + var authorizationUrl = authorizationContext.AuthorizationUri; + Console.WriteLine("Starting OAuth authorization flow..."); Console.WriteLine($"Simulating opening browser to: {authorizationUrl}"); @@ -355,16 +426,35 @@ static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri if (location is not null && !string.IsNullOrEmpty(location.Query)) { - // Parse query string to extract "code" parameter + // Parse query string to extract "code", "state", and "iss" parameters var query = location.Query.TrimStart('?'); + string? code = null; + string? state = null; + string? iss = null; foreach (var pair in query.Split('&')) { var parts = pair.Split('=', 2); - if (parts.Length == 2 && parts[0] == "code") + if (parts.Length == 2) { - return HttpUtility.UrlDecode(parts[1]); + if (parts[0] == "code") + { + code = HttpUtility.UrlDecode(parts[1]); + } + else if (parts[0] == "state") + { + state = HttpUtility.UrlDecode(parts[1]); + } + else if (parts[0] == "iss") + { + iss = HttpUtility.UrlDecode(parts[1]); + } } } + + if (code is not null) + { + return new AuthorizationResult { Code = code, State = state, Iss = iss }; + } } return null; diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index c81d8d2..dffffa9 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index b1de1aa..73f6382 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -3,7 +3,9 @@ using ConformanceServer.Resources; using ConformanceServer.Tools; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Extensions.Tasks; using System.Collections.Concurrent; +using System.Diagnostics; using System.Text.Json; namespace ModelContextProtocol.ConformanceServer; @@ -20,33 +22,58 @@ public class Program builder.Logging.AddProvider(loggerProvider); } - // Dictionary of session IDs to a set of resource URIs they are subscribed to - // The value is a ConcurrentDictionary used as a thread-safe HashSet - // because .NET does not have a built-in concurrent HashSet - ConcurrentDictionary> subscriptions = new(); + // Configure the default, stateful MCP server (served at "/"). + ConfigureConformanceMcpServer(builder.Services, stateless: false); - // Allow running the server in the SEP-2575 stateless lifecycle, which the 2026-07-28 - // protocol's "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" - // command-line switch (read via configuration) takes precedence so an in-process test - // fixture can opt in or out per-instance deterministically; when it is not supplied, - // fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs. - // The default (no switch, no env var) remains the stateful server that serves the - // active conformance suite unchanged. - var statelessConfig = builder.Configuration["stateless"]; - var stateless = statelessConfig is not null - ? string.Equals(statelessConfig, "true", StringComparison.OrdinalIgnoreCase) - : string.Equals( - Environment.GetEnvironmentVariable("MCP_CONFORMANCE_STATELESS"), - "true", - StringComparison.OrdinalIgnoreCase); + var app = builder.Build(); - builder.Services.AddDistributedMemoryCache(); - builder.Services + // Also expose a stateless MCP server at "/stateless" so a single conformance server can + // serve both the legacy stateful lifecycle (at "/") and the SEP-2575 stateless lifecycle + // (at "/stateless", which the 2026-07-28 "caching" (SEP-2549) and MRTR (SEP-2322) + // scenarios require) from one Kestrel port. + HandleStatelessMcp(app); + + app.MapMcp(); + + app.MapGet("/health", () => "Healthy"); + + await app.RunAsync(cancellationToken); + } + + // Registers the conformance MCP server (tools, prompts, resources, filters, and handlers) + // into the given service collection. Shared by the stateful ("/") and stateless ("/stateless") + // servers, which expose identical behavior except that only the stateful server registers the + // resource-subscription handlers (see below). + private static void ConfigureConformanceMcpServer( + IServiceCollection services, + bool stateless) + { + services.AddDistributedMemoryCache(); + var mcpServerBuilder = services .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() + .WithTasks( + new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + DefaultTimeToLive = TimeSpan.FromMinutes(5), + }, + options => options.ExecutionModeSelector = request => + request.Params?.Name switch + { + "slow_compute" or "protocol_error_job" or "confirm_delete" or "multi_input" + => McpTaskExecutionMode.Optional, + "failing_job" + => McpTaskExecutionMode.Required, + "test_tool_with_task" when request.Params.InputResponses is { Count: > 0 } + => McpTaskExecutionMode.Required, + _ => McpTaskExecutionMode.Synchronous, + }) .WithTools() + .WithTools() .WithTools() + .WithTools() .WithTools([ConformanceTools.CreateJsonSchema202012Tool()]) .WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) => { @@ -103,33 +130,6 @@ public class Program .WithPrompts() .WithPrompts() .WithResources() - .WithSubscribeToResourcesHandler(async (ctx, ct) => - { - if (ctx.Server.SessionId == null) - { - throw new McpException("Cannot add subscription for server with null SessionId"); - } - if (ctx.Params.Uri is { } uri) - { - var sessionSubscriptions = subscriptions.GetOrAdd(ctx.Server.SessionId, _ => new()); - sessionSubscriptions.TryAdd(uri, 0); - } - - return new EmptyResult(); - }) - .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => - { - if (ctx.Server.SessionId == null) - { - throw new McpException("Cannot remove subscription for server with null SessionId"); - } - if (ctx.Params.Uri is { } uri) - { - subscriptions[ctx.Server.SessionId].TryRemove(uri, out _); - } - - return new EmptyResult(); - }) .WithCompleteHandler(async (ctx, ct) => { // Basic completion support - returns empty array for conformance @@ -158,13 +158,70 @@ public class Program return new EmptyResult(); }); - var app = builder.Build(); + // Resource subscriptions require a stable SessionId to key the subscription table and a + // persistent SSE stream to deliver notifications/resources/updated, neither of which + // exists in the stateless lifecycle. Only the stateful server registers these handlers, + // so only it advertises the resources.subscribe capability. + if (!stateless) + { + // Dictionary of session IDs to a set of resource URIs they are subscribed to. The + // value is a ConcurrentDictionary used as a thread-safe HashSet because .NET does not + // have a built-in concurrent HashSet. + ConcurrentDictionary> subscriptions = new(); - app.MapMcp(); + mcpServerBuilder + .WithSubscribeToResourcesHandler(async (ctx, ct) => + { + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot add subscription for server with null SessionId"); + } + if (ctx.Params.Uri is { } uri) + { + var sessionSubscriptions = subscriptions.GetOrAdd(ctx.Server.SessionId, _ => new()); + sessionSubscriptions.TryAdd(uri, 0); + } - app.MapGet("/health", () => "Healthy"); + return new EmptyResult(); + }) + .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => + { + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot remove subscription for server with null SessionId"); + } + if (ctx.Params.Uri is { } uri) + { + subscriptions[ctx.Server.SessionId].TryRemove(uri, out _); + } - await app.RunAsync(cancellationToken); + return new EmptyResult(); + }); + } + } + + // Maps a second MCP server, configured for the stateless lifecycle, at "/stateless". It is + // built in its own ServiceCollection so its DI (and HttpServerTransportOptions) stays isolated + // from the stateful server registered on the main host. Adapted from + // ModelContextProtocol.TestSseServer.Program.HandleStatelessMcp. + private static void HandleStatelessMcp(WebApplication app) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(app.Services.GetRequiredService()); + services.AddSingleton(app.Services.GetRequiredService()); + services.AddSingleton(app.Services.GetRequiredService()); + services.AddRoutingCore(); + + ConfigureConformanceMcpServer(services, stateless: true); + + var statelessApp = new ApplicationBuilder(services.BuildServiceProvider()); + statelessApp.UseRouting(); + statelessApp.UseEndpoints(endpoints => endpoints.MapMcp("/stateless")); + + // Terminal middleware that serves "/stateless" requests the main host's routing did not + // match. Registered before app.MapMcp() so the stateful endpoints still win for "/". + app.Run(statelessApp.Build()); } public static async Task Main(string[] args) diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs new file mode 100644 index 0000000..4441c1d --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs @@ -0,0 +1,102 @@ +#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental. + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ConformanceServer.Tools; + +[McpServerToolType] +public sealed class ConformanceTaskTools +{ + [McpServerTool(Name = "greet")] + [Description("Returns a synchronous greeting.")] + public static string Greet(string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "slow_compute")] + [Description("Completes after the requested number of seconds.")] + public static async Task SlowCompute(int seconds, string? label, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); + return $"Computed {label ?? "result"}"; + } + + [McpServerTool(Name = "failing_job")] + [Description("Produces a tool execution error.")] + public static async Task FailingJob(CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + throw new Exception("The conformance task failed."); + } + + [McpServerTool(Name = "protocol_error_job")] + [Description("Produces a protocol-level error.")] + public static string ProtocolErrorJob() => + throw new McpProtocolException("The conformance task encountered a protocol error.", McpErrorCode.InternalError); + + [McpServerTool(Name = "confirm_delete")] + [Description("Waits for elicitation before confirming deletion.")] + public static async Task ConfirmDelete( + McpServer server, + string filename, + CancellationToken cancellationToken) + { + var result = await server.ElicitAsync(CreateConfirmationRequest($"Delete {filename}?"), cancellationToken); + return result.Action == "accept" ? $"Deleted {filename}" : $"Did not delete {filename}"; + } + + [McpServerTool(Name = "multi_input")] + [Description("Waits for two independent elicitation responses.")] + public static async Task MultiInput(McpServer server, CancellationToken cancellationToken) + { + await Task.WhenAll( + server.ElicitAsync(CreateConfirmationRequest("Confirm the first operation."), cancellationToken).AsTask(), + server.ElicitAsync(CreateConfirmationRequest("Confirm the second operation."), cancellationToken).AsTask()); + return "Both inputs received."; + } + + [McpServerTool(Name = "test_tool_with_task")] + [Description("Collects input synchronously, then completes through a task.")] + public static string ToolWithTask(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_name", out var response)) + { + var elicitation = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + var name = elicitation?.Content?["name"].GetString() ?? "world"; + return $"Hello, {name}!"; + } + + throw new InputRequiredException( + new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }); + } + + private static ElicitRequestParams CreateConfirmationRequest(string message) => + new() + { + Message = message, + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["confirm"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["confirm"], + }, + }; +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs new file mode 100644 index 0000000..b95ecdd --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs @@ -0,0 +1,62 @@ +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ConformanceServer.Tools; + +/// +/// Diagnostic tools exercised by the SEP-2575 server-stateless conformance scenario. Each +/// tool exists so the harness can observe a framework behavior (capability enforcement, response +/// stream discipline, per-request log gating) that plain application tools never trigger. +/// +[McpServerToolType] +public class Sep2575DiagnosticTools +{ + /// + /// Requires the client to have declared the sampling capability in the per-request + /// _meta/io.modelcontextprotocol/clientCapabilities. Used to verify the server rejects + /// undeclared-capability calls with MissingRequiredClientCapabilityError (-32021). + /// + [McpServerTool(Name = "test_missing_capability")] + [Description("Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)")] + public static string MissingCapability(RequestContext context) + { + if (context.Server.ClientCapabilities?.Sampling is not null) + { + return "Client declared the sampling capability; tool executed."; + } + + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } + + /// + /// Returns a plain result. Per SEP-2575 the response stream must carry no independent top-level + /// JSON-RPC requests; a plain response trivially satisfies this. The scenario declares no + /// elicitation capability, so this tool must not initiate an elicitation. + /// + [McpServerTool(Name = "test_streaming_elicitation")] + [Description("Streams only result frames; used to verify response streams carry no independent JSON-RPC requests (SEP-2575)")] + public static string StreamingElicitation() + { + return "stream observed: result frames only, no top-level requests"; + } + + /// + /// Attempts to emit a log message through the client-logger pipeline, which gates on the + /// per-request _meta/io.modelcontextprotocol/logLevel. When the client did not opt in, + /// no notifications/message may be sent for the request. + /// + [McpServerTool(Name = "test_logging_tool")] + [Description("Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)")] + public static string LoggingTool(RequestContext context) + { +#pragma warning disable MCP9004 // AsClientLoggerProvider is deprecated with the legacy logging/setLevel flow but remains the gated client-log pipeline. + ILogger logger = context.Server.AsClientLoggerProvider().CreateLogger(nameof(Sep2575DiagnosticTools)); +#pragma warning restore MCP9004 + logger.LogInformation("test_logging_tool executed"); + return "Log attempted; framework gates on _meta.../logLevel."; + } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs b/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs index 50592bb..1e9c9fc 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs @@ -55,6 +55,12 @@ internal sealed class ClientRegistrationRequest [JsonPropertyName("scope")] public string? Scope { get; init; } + /// + /// Gets or sets the OIDC application type ("web" or "native"). + /// + [JsonPropertyName("application_type")] + public string? ApplicationType { get; init; } + /// /// Gets or sets the contacts for the client. /// diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs index c05a45b..744ab0e 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs @@ -12,7 +12,8 @@ internal sealed class OAuthServerMetadata /// REQUIRED. The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. /// [JsonPropertyName("issuer")] - public required string Issuer { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Issuer { get; init; } /// /// Gets or sets the authorization endpoint URL. @@ -178,4 +179,11 @@ internal sealed class OAuthServerMetadata [JsonPropertyName("client_id_metadata_document_supported")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? ClientIdMetadataDocumentSupported { get; init; } + + /// + /// Gets or sets a value indicating whether authorization responses contain an issuer parameter. + /// + [JsonPropertyName("authorization_response_iss_parameter_supported")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AuthorizationResponseIssParameterSupported { get; init; } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 69eb606..73663dc 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -28,6 +28,7 @@ public sealed class Program private readonly ConcurrentDictionary _clients = new(); private readonly ConcurrentQueue _metadataRequests = new(); + private int _authorizationCodeTokenRequestCount; private readonly RSA _rsa; private readonly string _keyId; @@ -100,12 +101,52 @@ public sealed class Program /// public bool IncludeOfflineAccessInMetadata { get; set; } + /// + /// Gets or sets a value indicating whether authorization server metadata includes an issuer. + /// + public bool IncludeIssuerInMetadata { get; set; } = true; + + /// + /// Gets or sets an issuer value that overrides the authorization server's metadata issuer. + /// + public string? MetadataIssuerOverride { get; set; } + + /// + /// Gets or sets a value indicating whether the authorization server advertises RFC 9207 support. + /// + public bool AuthorizationResponseIssParameterSupported { get; set; } + + /// + /// Gets or sets the issuer included in authorization responses, or to omit it. + /// + public string? AuthorizationResponseIssuer { get; set; } + + /// + /// Gets or sets the code challenge methods advertised by metadata endpoints. + /// + /// + /// The default value is ["S256"]. + /// + public List? CodeChallengeMethodsSupported { get; set; } = ["S256"]; + + /// + /// Gets the set of metadata paths that should omit code_challenge_methods_supported from their + /// response, simulating a server whose discovery endpoints advertise differing PKCE support. + /// + public HashSet MetadataPathsWithoutPkceSupport { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); + /// Gets the number of authorization-code token exchange requests received. + public int AuthorizationCodeTokenRequestCount => Volatile.Read(ref _authorizationCodeTokenRequestCount); + /// Gets the scope field from the most recent Dynamic Client Registration request. public string? LastRegistrationScope { get; private set; } + /// Gets the application_type field from the most recent Dynamic Client Registration request. + public string? LastApplicationType { get; private set; } + /// /// Entry point for the application. /// @@ -225,7 +266,7 @@ public sealed class Program var metadata = new OAuthServerMetadata { - Issuer = $"{_url}{issuerPath}", + Issuer = IncludeIssuerInMetadata ? MetadataIssuerOverride ?? $"{_url}{issuerPath}" : null, AuthorizationEndpoint = $"{_url}/authorize", TokenEndpoint = $"{_url}/token", JwksUri = $"{_url}/.well-known/jwks.json", @@ -237,11 +278,14 @@ public sealed class Program : ["openid", "profile", "email", "mcp:tools"], TokenEndpointAuthMethodsSupported = ["client_secret_post"], ClaimsSupported = ["sub", "iss", "name", "email", "aud"], - CodeChallengeMethodsSupported = ["S256"], + CodeChallengeMethodsSupported = MetadataPathsWithoutPkceSupport.Contains(context.Request.Path) + ? null + : CodeChallengeMethodsSupported, GrantTypesSupported = ["authorization_code", "refresh_token"], IntrospectionEndpoint = $"{_url}/introspect", RegistrationEndpoint = $"{_url}/register", ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported, + AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null, }; return Results.Ok(metadata); @@ -373,6 +417,10 @@ public sealed class Program { redirectUrl += $"&state={Uri.EscapeDataString(state)}"; } + if (!string.IsNullOrEmpty(AuthorizationResponseIssuer)) + { + redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}"; + } return Results.Redirect(redirectUrl); }); @@ -416,6 +464,7 @@ public sealed class Program if (grant_type == "authorization_code") { + Interlocked.Increment(ref _authorizationCodeTokenRequestCount); var code = form["code"].ToString(); var code_verifier = form["code_verifier"].ToString(); var redirect_uri = form["redirect_uri"].ToString(); @@ -665,6 +714,7 @@ public sealed class Program } LastRegistrationScope = registrationRequest.Scope; + LastApplicationType = registrationRequest.ApplicationType; // Validate redirect URIs are provided if (registrationRequest.RedirectUris.Count == 0) diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index d04f216..6812fe5 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -34,6 +34,13 @@ internal static class Program private static async Task Main(string[] args) { + if (args.Contains("--echo-cli-arg-and-exit")) + { + Console.Error.WriteLine($"CLI_ARG:{JsonSerializer.Serialize(ParseCliArgument(args))}"); + Console.Error.Flush(); + return; + } + Log.Logger.Information("Starting server..."); string? cliArg = ParseCliArgument(args); diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index f93b6ab..030f056 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 @@ public class Program 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 class Program .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; }); diff --git a/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs b/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs new file mode 100644 index 0000000..9c38963 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.Tests.Authentication; + +// ClientOAuthProvider is internal; construct it indirectly via HttpClientTransport +// to verify DCR application_type validation is deferred until DCR is selected. +public class ClientOAuthProviderApplicationTypeTests +{ + private static readonly Uri ServerEndpoint = new("https://server.example.com/mcp"); + + private static HttpClientTransportOptions BuildOptions(string redirectUri, string? explicitApplicationType = null) + { + return new HttpClientTransportOptions + { + Endpoint = ServerEndpoint, + OAuth = new ClientOAuthOptions + { + RedirectUri = new Uri(redirectUri), + DynamicClientRegistration = new DynamicClientRegistrationOptions + { + ApplicationType = explicitApplicationType, + }, + }, + }; + } + + [Theory] + [InlineData("http://localhost:8080/callback", "web")] + [InlineData("https://example.com/callback", "native")] + public void Constructor_Defers_ApplicationTypeValidation_UntilDynamicRegistration( + string redirectUri, string explicitType) + { + var options = BuildOptions(redirectUri, explicitType); + + using var httpClient = new HttpClient(); + _ = new HttpClientTransport(options, httpClient); + + Assert.Equal(explicitType, options.OAuth!.DynamicClientRegistration!.ApplicationType); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index a06548a..e681757 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -3,18 +3,19 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; /// /// Connection-flow tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) /// on . A client that requests -/// calls server/discover rather than +/// calls server/discover rather than /// initialize. /// public class July2026ProtocolConnectionTests : ClientServerTestBase { - private const string LatestStableVersion = "2025-11-25"; + private const string LatestStableVersion = McpProtocolVersions.November2025ProtocolVersion; public July2026ProtocolConnectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) @@ -34,42 +35,73 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase { StartServer(); - var options = new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }; + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; await using var client = await CreateMcpClientForServer(options); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); Assert.NotNull(client.ServerCapabilities); Assert.Equal(nameof(July2026ProtocolConnectionTests), client.ServerInfo.Name); } [Fact] - public async Task Client_RequestingLegacyVersion_NegotiatesLegacy() + public async Task Client_RequestingInitializeHandshakeVersion_NegotiatesIt() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - Assert.NotEqual(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] - public async Task LegacyClient_CanCallServerDiscover() + public async Task Client_AllowsDiscoverResultWithoutServerInfo() { - // server/discover is registered unconditionally, so a legacy client can probe it - // (e.g., to learn capabilities without doing a second initialize). + ConfigureDiscoverServerInfo(meta => meta.Remove(MetaKeys.ServerInfo)); + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; + await using var client = await CreateMcpClientForServer(options); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Throws(() => _ = client.ServerInfo); + } + + [Fact] + public async Task Client_RejectsMalformedDiscoverServerInfo() + { + ConfigureDiscoverServerInfo(meta => meta[MetaKeys.ServerInfo] = "invalid"); + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; + await Assert.ThrowsAsync(() => CreateMcpClientForServer(options)); + } + + [Fact] + public async Task Client_RejectsNullDiscoverServerInfo() + { + ConfigureDiscoverServerInfo(meta => meta[MetaKeys.ServerInfo] = null); + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; + await Assert.ThrowsAsync(() => CreateMcpClientForServer(options)); + } + + [Fact] + public async Task InitializeHandshakeClient_CannotCallServerDiscover() + { + // server/discover is registered unconditionally so the protocol boundary filter can return a structured + // error, but initialize-handshake clients cannot use it after negotiating an older protocol version. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - var response = await client.SendRequestAsync( - new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, - TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync(async () => + await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken)); - var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); - Assert.NotNull(discoverResult); - Assert.NotEmpty(discoverResult.SupportedVersions); - Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); - Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name); + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + Assert.Contains(RequestMethods.ServerDiscover, exception.Message, StringComparison.Ordinal); } [Fact] @@ -85,6 +117,22 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); + Assert.Equal("complete", discoverResult.ResultType); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions); + } + + private void ConfigureDiscoverServerInfo(Action configure) + { + McpServerBuilder.WithMessageFilters(filters => filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse { Result: JsonObject result } && + result["supportedVersions"] is not null && + result["_meta"] is JsonObject meta) + { + configure(meta); + } + + await next(context, cancellationToken); + })); } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index f2bc24c..557dc56 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -2,23 +2,26 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.Diagnostics; +using System.Net; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Channels; namespace ModelContextProtocol.Tests.Client; /// -/// Regression tests for the fallback from the 2026-07-28 protocol revision to a legacy protocol in +/// Regression tests for the fallback from the 2026-07-28 protocol revision to an initialize-handshake protocol in /// . With default options (ProtocolVersion = null) the client prefers -/// 2026-07-28 but probes with server/discover, falls back to the legacy initialize -/// handshake when the server is legacy, and accepts whatever supported protocol version the legacy +/// 2026-07-28 but probes with server/discover, falls back to the initialize +/// handshake when the server only supports that path, and accepts whatever supported protocol version the /// server negotiates. Pinning ProtocolVersion to 2026-07-28 instead makes it the /// minimum too, so the client refuses to fall back. /// /// -/// The originally shipped logic in PerformLegacyInitializeAsync compared the server's response -/// against the requested version and threw when a legacy server downgraded to (say) "2025-06-18", -/// even though the legacy negotiation succeeded. These tests guard against that regression. +/// The originally shipped initialize-handshake fallback logic compared the server's response +/// against the requested version and threw when an initialize-handshake server downgraded to (say) +/// "2025-06-18", even though negotiation succeeded. These tests guard against that regression. /// public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { @@ -26,45 +29,66 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] public async Task Client_OnInvalidParams_FallsBackTo_Initialize() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.InvalidParams); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] - public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer() + public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializeResponse() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.July2026ProtocolVersion); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToInitializeHandshakeServer() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { // Pinning the version makes it the minimum too, so the client refuses to fall back. - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -73,17 +97,17 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : } [Fact] - public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() + public async Task InitializeHandshakeClient_WithExplicitPin_StillRequires_ExactVersionMatch() { var ct = TestContext.Current.CancellationToken; // Server responds with a DIFFERENT version than the one the user pinned. - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.March2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -94,36 +118,50 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : [Fact] public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() { - // The peer is modern (returns the spec-defined -32020 HeaderMismatch on the probe). - // Falling back to legacy initialize would just produce another malformed envelope. + // The peer uses per-request metadata (returns the spec-defined -32020 HeaderMismatch on the probe). + // Falling back to initialize would just produce another malformed envelope. // Verify the connect-time logic surfaces the error to the caller instead of falling back. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.HeaderMismatch); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.True(transport.ServerDiscoverProbed); - Assert.False(transport.LegacyInitializeReceived); + Assert.False(transport.InitializeReceived); Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); } + [Fact] + public async Task Client_OnUnsupportedProtocolVersion_WithPerRequestMetadataVersion_RetriesDiscover() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new PerRequestMetadataRetryTestTransport(); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal(2, transport.ServerDiscoverRequests); + Assert.False(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Fact] public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() { - // Simulate a legacy server that silently drops the unknown server/discover method (it never - // responds to the probe). The client must fall back to legacy initialize once the configured + // Simulate an initialize-handshake server that silently drops the unknown server/discover method (it never + // responds to the probe). The client must fall back to initialize once the configured // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, silentDiscoverProbe: true); var stopwatch = Stopwatch.StartNew(); @@ -136,8 +174,9 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : stopwatch.Stop(); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. Assert.True( @@ -170,24 +209,200 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout); } + [Theory] + [InlineData(HttpStatusCode.NotFound, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.NotFound, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.AutoDetect)] + public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize( + HttpStatusCode status, HttpTransportMode transportMode) + { + // A server predating SEP-2575 can reject the session-less server/discover probe at the HTTP layer + // rather than with a JSON-RPC error: 404 when it requires Mcp-Session-Id on every non-initialize + // POST, or a plain/empty 400 when it cannot parse the request. Both are initialize-handshake + // servers, so the connect must fall back instead of failing. + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateProbeRejectingServer( + status, "Invalid session ID", () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + + // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(initializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData(HttpTransportMode.StreamableHttp)] + [InlineData(HttpTransportMode.AutoDetect)] + public async Task Client_OnStructuredInvalidRequestFromHttpProbe_FallsBackTo_Initialize( + HttpTransportMode transportMode) + { + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateStructuredInvalidRequestProbeServer( + () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(initializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.AutoDetect)] + public async Task Client_OnOtherHttpErrorFromProbe_Surfaces_NoFallback( + HttpStatusCode status, HttpTransportMode transportMode) + { + // Only 400 and 404 are read as "this server needs the initialize handshake". Any other HTTP failure + // is a genuine transport error and must surface, so callers are not handed a misleading downstream + // error. Guards the deliberate narrowing of the status filter. + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateProbeRejectingServer( + status, "nope", () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + + await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.False(initializeReceived); + } + + private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransportMode transportMode) + => new(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = transportMode, + Name = "HTTP discover probe test client", + }, httpClient, LoggerFactory); + /// - /// Minimal in-memory transport that simulates a legacy server: rejects + /// Mock Streamable HTTP server that rejects server/discover with + /// and, if the client falls back, completes an initialize handshake at 2025-11-25. + /// + private static Func> CreateProbeRejectingServer( + HttpStatusCode probeStatus, string probeBody, Action onInitialize) + => async request => + { + // The server offers no standalone SSE stream, which the spec permits. + // net472 does not populate a default Content, so every response sets one explicitly. + if (request.Method == HttpMethod.Get) + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("method", out var methodElement)) + return EmptyResponse(HttpStatusCode.Accepted); + + switch (methodElement.GetString()) + { + case RequestMethods.ServerDiscover: + return new HttpResponseMessage(probeStatus) { Content = new StringContent(probeBody) }; + + case RequestMethods.Initialize: + onInitialize(); + var id = doc.RootElement.GetProperty("id").GetRawText(); + var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + + "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result, Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "test-session"); + return response; + + default: + return EmptyResponse(HttpStatusCode.Accepted); + } + }; + + private static Func> CreateStructuredInvalidRequestProbeServer( + Action onInitialize) + => async request => + { + if (request.Method == HttpMethod.Get) + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("method", out var methodElement)) + return EmptyResponse(HttpStatusCode.Accepted); + + if (methodElement.GetString() == RequestMethods.ServerDiscover) + { + var id = doc.RootElement.GetProperty("id").GetRawText(); + var error = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"error\":{\"code\":-32600,\"message\":\"Mcp-Session-Id header is required\"}}"; + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(error, Encoding.UTF8, "application/json"), + }; + } + + if (methodElement.GetString() == RequestMethods.Initialize) + { + onInitialize(); + var id = doc.RootElement.GetProperty("id").GetRawText(); + var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + + "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result, Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "test-session"); + return response; + } + + return EmptyResponse(HttpStatusCode.Accepted); + }; + + private static HttpResponseMessage EmptyResponse(HttpStatusCode status) + => new(status) { Content = new StringContent(string.Empty) }; + + /// + /// Minimal in-memory transport that simulates an initialize-handshake server: rejects /// server/discover (with a configurable JSON-RPC error code, or by /// silently dropping the request) and responds to initialize with a /// configurable protocol version. /// - private sealed class LegacyServerTestTransport( + private sealed class InitializeHandshakeServerTestTransport( string serverNegotiatedVersion, int probeErrorCode = (int)McpErrorCode.MethodNotFound, bool silentDiscoverProbe = false) : IClientTransport { private readonly Channel _incomingToClient = Channel.CreateUnbounded(); - public string Name => "legacy-server-test-transport"; + public string Name => "initialize-handshake-server-test-transport"; public bool ServerDiscoverProbed { get; private set; } - public bool LegacyInitializeReceived { get; private set; } + public bool InitializeReceived { get; private set; } + + public string? InitializeProtocolVersion { get; private set; } public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -205,7 +420,7 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : ServerDiscoverProbed = true; if (silentDiscoverProbe) { - // Model a legacy server that drops the unknown method without replying. + // Model an initialize-handshake server that drops the unknown method without replying. break; } @@ -223,7 +438,9 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : break; case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: - LegacyInitializeReceived = true; + InitializeReceived = true; + var initializeRequest = JsonSerializer.Deserialize(initReq.Params, McpJsonUtilities.DefaultOptions); + InitializeProtocolVersion = initializeRequest?.ProtocolVersion; _ = WriteAsync(new JsonRpcResponse { Id = initReq.Id, @@ -231,7 +448,7 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : { ProtocolVersion = serverNegotiatedVersion, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake-test-server", Version = "1.0.0" }, }, McpJsonUtilities.DefaultOptions), }); break; @@ -243,7 +460,108 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : private sealed class TransportChannel( Channel incoming, - LegacyServerTestTransport parent) : ITransport + InitializeHandshakeServerTestTransport parent) : ITransport + { + public ChannelReader MessageReader => incoming.Reader; + public bool IsConnected { get; private set; } = true; + public string? SessionId => null; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + parent.HandleOutgoingMessage(message); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + incoming.Writer.TryComplete(); + IsConnected = false; + return default; + } + } + } + + private sealed class PerRequestMetadataRetryTestTransport : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "per-request-metadata-retry-test-transport"; + + public int ServerDiscoverRequests { get; private set; } + + public bool InitializeReceived { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + ITransport transport = new TransportChannel(_incomingToClient, this); + return Task.FromResult(transport); + } + + public ValueTask DisposeAsync() => default; + + private void HandleOutgoingMessage(JsonRpcMessage message) + { + switch (message) + { + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq: + ServerDiscoverRequests++; + + if (ServerDiscoverRequests == 1) + { + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = CreateUnsupportedProtocolVersionData(), + }, + }); + } + else + { + _ = WriteAsync(new JsonRpcResponse + { + Id = discoverReq.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, McpJsonUtilities.DefaultOptions), + }, + }, McpJsonUtilities.DefaultOptions), + }); + } + + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize }: + InitializeReceived = true; + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private static JsonElement CreateUnsupportedProtocolVersionData() + { + var json = JsonSerializer.Serialize(new UnsupportedProtocolVersionErrorData + { + Requested = McpProtocolVersions.July2026ProtocolVersion, + Supported = [McpProtocolVersions.July2026ProtocolVersion], + }, McpJsonUtilities.DefaultOptions); + + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class TransportChannel( + Channel incoming, + PerRequestMetadataRetryTestTransport parent) : ITransport { public ChannelReader MessageReader => incoming.Reader; public bool IsConnected { get; private set; } = true; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs index 19ff402..dd7ba7b 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs @@ -76,7 +76,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -87,7 +87,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -98,7 +98,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -109,7 +109,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase public async Task Client_ListResourceTemplates_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -122,7 +122,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, // which still flows through the client's per-request _meta injector. StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); // Hook the server-side handler invocation via a notification handler is awkward here; assert // instead by sending the request and parsing the wire-shape echo from the response context. @@ -136,7 +136,7 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase Assert.NotNull(response.Result); var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discover.SupportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, discover.SupportedVersions); // The server enforces the per-request envelope shape; if the client had omitted _meta, the // request would have failed with -32602 / -32021 rather than returning a DiscoverResult. The @@ -144,11 +144,11 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase } [Fact] - public async Task LegacyClient_ListTools_DoesNotEmitMeta() + public async Task InitializeHandshakeClient_ListTools_DoesNotEmitMeta() { - // Sanity guard: a client on the session-supporting (legacy) protocol must NOT emit the SEP-2575 + // Sanity guard: a client on the session-supporting initialize-handshake protocol must NOT emit the SEP-2575 // envelope. The injector is gated on the negotiated protocol version; if it ever started writing - // those keys on a legacy request, every legacy server would reject it. + // those keys on an initialize-handshake request, every initialize-handshake server would reject it. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); @@ -175,6 +175,6 @@ public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase $"Missing clientCapabilities key on {method} _meta envelope"); // The protocolVersion value must match the negotiated 2026-07-28 protocol version. - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 42af028..c6f8016 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.IO.Pipelines; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Channels; namespace ModelContextProtocol.Tests.Client; @@ -178,11 +179,14 @@ public class McpClientCreationTests(ITestOutputHelper testOutputHelper) : Logged Result = JsonSerializer.SerializeToNode(new DiscoverResult { Capabilities = new ServerCapabilities(), - SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], - ServerInfo = new Implementation + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Meta = new JsonObject { - Name = "NopTransport", - Version = "1.0.0" + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation + { + Name = "NopTransport", + Version = "1.0.0" + }, McpJsonUtilities.DefaultOptions), }, }, McpJsonUtilities.DefaultOptions), }); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e1e9a08..94558a6 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -15,6 +16,9 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); + private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) { @@ -39,6 +43,7 @@ public class McpClientMetaTests : ClientServerTestBase // Capture the _meta the server receives on the initialize request so tests can // assert that McpClientOptions.InitializeMeta is threaded through the handshake. mcpServerBuilder.WithMessageFilters(filters => + { filters.AddIncomingFilter(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request) @@ -47,7 +52,23 @@ public class McpClientMetaTests : ClientServerTestBase } await next(context, cancellationToken); - })); + }); + + // Capture the request-scoped client info/capabilities observed while an outgoing response flows + // through the outgoing filter pipeline. Gated on a unique client name so only the dedicated test + // triggers it. This exercises that DestinationBoundMcpServer resolves per-request _meta for + // responses (whose Context is the originating request's Context), not just requests. + filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse && + context.Server.ClientInfo is { Name: "outgoing-filter-client" } info) + { + _outgoingFilterObserved.TrySetResult((info, context.Server.ClientCapabilities)); + } + + await next(context, cancellationToken); + }); + }); } [Fact] @@ -116,6 +137,224 @@ public class McpClientMetaTests : ClientServerTestBase Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + Assert.Null(context.JsonRpcRequest.Context?.ClientInfo); + Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities); + + Assert.Equal("initialized-client", context.Server.ClientInfo?.Name); + Assert.NotNull(context.Server.ClientCapabilities?.Elicitation); + Assert.Null(context.Server.ClientCapabilities?.Sampling); + + return "ok"; + }, + new() { Name = "legacy_meta_tool" })); + + var clientOptions = new McpClientOptions + { + ProtocolVersion = LatestStableVersion, + ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "legacy_meta_tool", + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode( + new Implementation { Name = "request-client", Version = "2.0.0" }, + McpJsonUtilities.DefaultOptions), + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() + { + var withSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var withoutSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowSamplingChecks = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + async (string requestId, RequestContext context, CancellationToken cancellationToken) => + { + if (requestId == "with") + { + withSamplingReady.TrySetResult(true); + } + else if (requestId == "without") + { + withoutSamplingReady.TrySetResult(true); + } + else + { + throw new ArgumentException($"Unexpected request id '{requestId}'."); + } + + await allowSamplingChecks.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + + return context.Server.ClientCapabilities?.Sampling is null ? + $"{requestId}:sampling-absent" : + $"{requestId}:sampling-present"; + }, + new() { Name = "meta_sampling_tool" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var withSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"with\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }; + + var withoutSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities(), + McpJsonUtilities.DefaultOptions), + }, + }; + + Task withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + Task withoutSamplingTask = client.CallToolAsync(withoutSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + + await Task.WhenAll(withSamplingReady.Task, withoutSamplingReady.Task).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + allowSamplingChecks.TrySetResult(true); + + CallToolResult withSamplingResult = await withSamplingTask; + CallToolResult withoutSamplingResult = await withoutSamplingTask; + + var withSamplingText = Assert.IsType(Assert.Single(withSamplingResult.Content)).Text; + var withoutSamplingText = Assert.IsType(Assert.Single(withoutSamplingResult.Content)).Text; + + Assert.Equal("with:sampling-present", withSamplingText); + Assert.Equal("without:sampling-absent", withoutSamplingText); + } + + [Fact] + public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + var clientInfo = context.Server.ClientInfo; + return clientInfo is null ? + "client-info-absent" : + $"{clientInfo.Name}:{clientInfo.Version}"; + }, + new() { Name = "client_info_tool" })); + + // The 2026-07-28+ client stamps its ClientInfo onto every request's _meta, so the tool must observe + // the per-request value resolved by DestinationBoundMcpServer rather than server-only session state. + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "request-scoped-client", Version = "9.9.9" }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("client_info_tool", cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("request-scoped-client:9.9.9", text); + } + + [Fact] + public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHandlerObservesThem() + { + ClientCapabilities? handlerObservedCapabilities = null; + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (McpServer server) => + { + handlerObservedCapabilities = server.ClientCapabilities; + return "ok"; + }, + new() { Name = "capability_probe_tool" })); + + var clientOptions = new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + // Under the 2026-07-28 revision capabilities are request-scoped, so the root server (outside any + // request) never exposes them, whereas a request handler observes the per-request _meta values. + Assert.Null(Server.ClientCapabilities); + + await client.CallToolAsync("capability_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(handlerObservedCapabilities); + Assert.NotNull(handlerObservedCapabilities!.Elicitation); + Assert.Null(Server.ClientCapabilities); + } + + [Fact] + public async Task OutgoingMessageFilter_UnderJuly2026Protocol_ObservesRequestScopedClientInfoAndCapabilities() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + () => "ok", + new() { Name = "outgoing_probe_tool" })); + + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "outgoing-filter-client", Version = "3.2.1" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("outgoing_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + var (info, capabilities) = await _outgoingFilterObserved.Task + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.NotNull(info); + Assert.Equal("outgoing-filter-client", info!.Name); + Assert.Equal("3.2.1", info.Version); + Assert.NotNull(capabilities); + Assert.NotNull(capabilities!.Elicitation); + } + [Fact] public async Task ResourceReadWithMetaFields() { diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs index 2ee3cec..82a3bd9 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs @@ -19,6 +19,12 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase mcpServerBuilder.WithResources(); } + private Task CreateLegacyMcpClientForServer() => + CreateMcpClientForServer(new() + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + [McpServerResourceType] private sealed class SubscribableResources { @@ -30,7 +36,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_ReceivesNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var notificationReceived = new TaskCompletionSource(); @@ -61,7 +67,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_FiltersNotificationsByUri() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string subscribedUri = "test://resource/1"; const string otherUri = "test://resource/2"; var notificationCount = 0; @@ -107,7 +113,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_DisposalUnsubscribes() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var notificationCount = 0; @@ -148,7 +154,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_UriOverload_ReceivesNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); var resourceUri = new Uri("test://resource/1"); var notificationReceived = new TaskCompletionSource(); @@ -179,7 +185,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithNullHandler_ThrowsArgumentNullException() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -193,7 +199,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithNullUri_ThrowsArgumentNullException() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -207,7 +213,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithEmptyUri_ThrowsArgumentException() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -221,7 +227,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_MultipleSubscriptions_BothReceiveNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string uri1 = "test://resource/1"; const string uri2 = "test://resource/2"; var notification1Received = new TaskCompletionSource(); @@ -278,7 +284,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_DisposalIsIdempotent() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var subscription = await client.SubscribeToResourceAsync( @@ -299,7 +305,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_MultipleHandlersSameUri_BothReceiveNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var handler1Called = new TaskCompletionSource(); var handler2Called = new TaskCompletionSource(); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index 8791738..e3da699 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client; /// /// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync, -/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync. +/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync. /// public class McpClientTaskMethodsTests : ClientServerTestBase { @@ -25,15 +26,12 @@ public class McpClientTaskMethodsTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -60,7 +58,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -97,12 +95,12 @@ public class McpClientTaskMethodsTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask() + public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask() { await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -122,12 +120,12 @@ public class McpClientTaskMethodsTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "hello"), - }, ct); + }, cancellationToken: ct); Assert.NotNull(result); Assert.NotEmpty(result.Content); @@ -141,7 +139,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -156,9 +154,10 @@ public class McpClientTaskMethodsTests : ClientServerTestBase { await client.CancelTaskAsync(taskId, ct); - // If cancel succeeded, verify the task is cancelled + // Cancellation is eventually consistent. The task may complete before the cancellation + // request wins the race, but the terminal-task cancellation acknowledgement is idempotent. var taskResult = await client.GetTaskAsync(taskId, ct); - Assert.IsType(taskResult); + Assert.True(taskResult is CancelledTaskResult or CompletedTaskResult); } catch (McpProtocolException) { @@ -172,7 +171,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); await Assert.ThrowsAsync(async () => - await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken)); + await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -194,7 +193,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -235,7 +234,7 @@ public class McpClientTaskMethodsTests : ClientServerTestBase for (int i = 0; i < 5; i++) { - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 4dda7bc..aefe6a9 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -483,7 +483,7 @@ public class McpClientTests : ClientServerTestBase [Fact] public async Task AsClientLoggerProvider_MessagesSentToClient() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); ILoggerProvider loggerProvider = Server.AsClientLoggerProvider(); Assert.Throws("categoryName", () => loggerProvider.CreateLogger(null!)); @@ -765,7 +765,7 @@ public class McpClientTests : ClientServerTestBase [Fact] public async Task SetLoggingLevelAsync_WithRequestParams_SetsLevel() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); // Should not throw await client.SetLoggingLevelAsync( @@ -795,9 +795,9 @@ public class McpClientTests : ClientServerTestBase [Fact] public async Task ServerCanPingClient() { - // ping is a legacy-only RPC (removed in the 2026-07-28 protocol per SEP-2575), so pin the client - // to a legacy protocol version to exercise the server-initiated ping round-trip. - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); + // ping is available only on initialize-handshake revisions (removed in the 2026-07-28 protocol + // per SEP-2575), so pin the client to exercise the server-initiated ping round-trip. + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping }; var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs index 26de71b..1e4fc00 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs @@ -104,6 +104,16 @@ public class McpHeaderEncoderTests 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() { diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 5868c3c..be629ec 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -28,8 +28,9 @@ public class McpRequestHeadersTests [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected) + public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } + } diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 70a9d11..86a107f 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -195,7 +195,10 @@ public class MrtrIntegrationTests : ClientServerTestBase { SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MockMrtrServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }; await WriteJsonRpcAsync(serverWriter, discoverResponse); @@ -445,7 +448,10 @@ public class MrtrIntegrationTests : ClientServerTestBase { SupportedVersions = ["2026-07-28"], Capabilities = new ServerCapabilities { Tools = new() }, - ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MrtrServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }; await WriteJsonRpcAsync(serverWriter, discoverResponse); diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs index 6f0e8e4..726a3fb 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs @@ -16,6 +16,8 @@ public class ClientIntegrationTestFixture public ClientIntegrationTestFixture() { const string ServerEverythingVersion = "2026.1.26"; + string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); + string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); EverythingServerTransportOptions = new() { @@ -27,14 +29,14 @@ public class ClientIntegrationTestFixture TestServerTransportOptions = new() { - Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "TestServer.exe" : PlatformDetection.IsMonoRuntime ? "mono" : "dotnet", + Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? testServerExecutable : PlatformDetection.IsMonoRuntime ? "mono" : "dotnet", Name = "TestServer", }; if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Change to Arguments to "mcp-server-everything" if you want to run the server locally after creating a symlink - TestServerTransportOptions.Arguments = [PlatformDetection.IsMonoRuntime ? "TestServer.exe" : "TestServer.dll"]; + TestServerTransportOptions.Arguments = [PlatformDetection.IsMonoRuntime ? testServerExecutable : testServerDll]; } } diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 7690a75..d01f40a 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,10 +33,10 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture tcs = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -307,6 +308,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -558,6 +560,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -726,6 +729,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture tcs = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -755,6 +759,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = diff --git a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs index 564225a..4e500b5 100644 --- a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs +++ b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs @@ -86,6 +86,12 @@ public abstract class ClientServerTestBase : LoggedTest, IAsyncDisposable protected async Task CreateMcpClientForServer(McpClientOptions? clientOptions = null) { + clientOptions ??= new McpClientOptions(); + + // Disable the server/discover probe timeout to avoid CI slowness spuriously tripping it (issue #1701). + // Tests that need a specific probe timeout should create their own client instead of using this helper. + clientOptions.DiscoverProbeTimeout = TestConstants.DefaultTimeout; + return await McpClient.CreateAsync( new StreamClientTransport( serverInput: _clientToServerPipe.Writer.AsStream(), diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs index e61c442..be965e7 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs @@ -147,4 +147,17 @@ public class McpServerBuilderExtensionsHandlerTests Assert.Equal(handler, options.Handlers.UnsubscribeFromResourcesHandler); } + + [Fact] + public void WithSubscriptionsListenHandler_Sets_Handler() + { + McpRequestHandler handler = async (context, token) => new EmptyResult(); + + _builder.Object.WithSubscriptionsListenHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.Handlers.SubscriptionsListenHandler); + } } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 03234f6..96647fa 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -130,10 +130,10 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); @@ -180,7 +180,7 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var serverOptions = ServiceProvider.GetRequiredService>().Value; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index 0c4783b..3f6a4a3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -256,7 +256,10 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddSubscribeToResourcesFilter_Logs_When_SubscribeToResources_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new() + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); await client.SubscribeToResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken); @@ -268,7 +271,10 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromResources_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new() + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); await client.UnsubscribeFromResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken); @@ -280,7 +286,7 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddSetLoggingLevelFilter_Logs_When_SetLoggingLevel_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 663c06a..ff3b911 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -164,10 +164,10 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); @@ -214,7 +214,7 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var serverOptions = ServiceProvider.GetRequiredService>().Value; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 154c40f..a05084a 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -190,10 +190,10 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -239,7 +239,7 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var serverOptions = ServiceProvider.GetRequiredService>().Value; diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs index 44afcce..92e81c9 100644 --- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -37,6 +37,7 @@ public sealed class IdentityAssertionGrantTests : IDisposable ["issuer"] = "https://auth.mcp-server.example.com", ["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize", ["token_endpoint"] = "https://auth.mcp-server.example.com/token", + ["token_endpoint_auth_methods_supported"] = new JsonArray("client_secret_basic"), }); } @@ -52,6 +53,10 @@ public sealed class IdentityAssertionGrantTests : IDisposable if (url.Contains("auth.mcp-server.example.com/token")) { + Assert.Equal("Basic", request.Headers.Authorization?.Scheme); + Assert.Equal( + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("mcp-client-id:mcp-client-secret")), + request.Headers.Authorization?.Parameter); return JsonResponse(HttpStatusCode.OK, new JsonObject { ["access_token"] = "final-access-token", @@ -67,6 +72,8 @@ public sealed class IdentityAssertionGrantTests : IDisposable new IdentityAssertionGrantProviderOptions { ClientId = "mcp-client-id", + ClientSecret = "mcp-client-secret", + TokenEndpointAuthMethod = "client_secret_basic", IdpTokenEndpoint = "https://idp.example.com/token", IdpClientId = "idp-client-id", IdTokenCallback = (context, ct) => @@ -88,6 +95,30 @@ public sealed class IdentityAssertionGrantTests : IDisposable Assert.Equal(3600, tokens.ExpiresIn); } + [Fact] + public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() => + AssertMcpTokenEndpointAuthenticationAsync( + new JsonArray("client_secret_basic", "client_secret_post"), + configuredMethod: null, + expectedAuthorizationScheme: null, + expectSecretInBody: true); + + [Fact] + public Task IdentityAssertionGrantProvider_FallsBackToBasicWhenPostIsUnavailable() => + AssertMcpTokenEndpointAuthenticationAsync( + new JsonArray("client_secret_basic"), + configuredMethod: null, + expectedAuthorizationScheme: "Basic", + expectSecretInBody: false); + + [Fact] + public Task IdentityAssertionGrantProvider_NoneDoesNotSendClientSecret() => + AssertMcpTokenEndpointAuthenticationAsync( + new JsonArray("none"), + configuredMethod: "none", + expectedAuthorizationScheme: null, + expectSecretInBody: false); + [Fact] public async Task IdentityAssertionGrantProvider_CachesTokens() { @@ -313,6 +344,121 @@ public sealed class IdentityAssertionGrantTests : IDisposable _httpClient)); } + [Theory] + [InlineData("client_secret_basic")] + [InlineData("client_secret_post")] + public void IdentityAssertionGrantProvider_TokenEndpointAuthMethodRequiresSecret_MissingClientSecret_ThrowsArgumentException(string tokenEndpointAuthMethod) + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("test"), + TokenEndpointAuthMethod = tokenEndpointAuthMethod, + ClientSecret = null, + }, + _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_UnsupportedTokenEndpointAuthMethod_ThrowsArgumentException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("test"), + TokenEndpointAuthMethod = "private_key_jwt", + }, + _httpClient)); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_ConcurrentCallers_RunExchangeOnce() + { + // Gate the first in-flight flow so multiple callers overlap while the first holds the + // acquisition lock. Without coalescing, each concurrent caller would run its own exchange. + var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var mcpTokenCallCount = 0; + _mockHandler.AsyncHandler = async request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + // MCP token endpoint: this is the exchange we expect to run exactly once. + if (Interlocked.Increment(ref mcpTokenCallCount) == 1) + { + firstEntered.TrySetResult(true); + await release.Task; + } + + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "final-access-token", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + }; + + var idTokenCallCount = 0; + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => + { + Interlocked.Increment(ref idTokenCallCount); + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var ct = TestContext.Current.CancellationToken; + var resourceUrl = new Uri("https://resource.example.com"); + var authUrl = new Uri("https://auth.example.com"); + + var tasks = Enumerable.Range(0, 8) + .Select(_ => provider.GetAccessTokenAsync(resourceUrl, authUrl, ct)) + .ToArray(); + + // Wait until the first flow is inside the exchange (holding the lock), then let it finish. + var entered = await Task.WhenAny(firstEntered.Task, Task.Delay(TimeSpan.FromSeconds(30), ct)); + Assert.Same(firstEntered.Task, entered); + release.SetResult(true); + + var results = await Task.WhenAll(tasks); + + Assert.Equal(1, mcpTokenCallCount); + Assert.Equal(1, idTokenCallCount); + Assert.All(results, r => Assert.Same(results[0], r)); + Assert.Equal("final-access-token", results[0].AccessToken); + } + #endregion #region IdentityAssertionGrantException Tests @@ -352,6 +498,67 @@ public sealed class IdentityAssertionGrantTests : IDisposable #region Helpers + private async Task AssertMcpTokenEndpointAuthenticationAsync( + JsonArray supportedMethods, + string? configuredMethod, + string? expectedAuthorizationScheme, + bool expectSecretInBody) + { + _mockHandler.AsyncHandler = async request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = "https://auth.example.com", + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + ["token_endpoint_auth_methods_supported"] = supportedMethods, + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + Assert.Equal(expectedAuthorizationScheme, request.Headers.Authorization?.Scheme); + var body = await request.Content!.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Equal(expectSecretInBody, body.Contains("client_secret=mcp-client-secret", StringComparison.Ordinal)); + + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "final-access-token", + ["token_type"] = "Bearer", + }); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + ClientSecret = "mcp-client-secret", + TokenEndpointAuthMethod = configuredMethod, + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("mock-id-token"), + }, + _httpClient); + + var tokens = await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), + TestContext.Current.CancellationToken); + + Assert.Equal("final-access-token", tokens.AccessToken); + } + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload) { return new HttpResponseMessage(statusCode) diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 4b782cd..677d773 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -36,6 +36,7 @@ + @@ -83,6 +84,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs index 14f0d49..20e55a7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -43,7 +43,7 @@ public class CacheableResultWarningTests : LoggedTest { var (call, result) = GetScenario(method, ttl: null, scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, method, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, method, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains(method) && m.Message.Contains("SEP-2549")); @@ -56,7 +56,7 @@ public class CacheableResultWarningTests : LoggedTest { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -69,7 +69,7 @@ public class CacheableResultWarningTests : LoggedTest { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: CacheScope.Public); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -81,7 +81,7 @@ public class CacheableResultWarningTests : LoggedTest // A server on an older protocol version may legitimately omit the fields; no warning should fire. var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: null, scope: null); - await RunScenarioAsync(OlderProtocolVersion, useModernLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(OlderProtocolVersion, usePerRequestMetadataLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -98,7 +98,7 @@ public class CacheableResultWarningTests : LoggedTest await RunScenarioAsync( July2026ProtocolVersion, - useModernLifecycle: true, + usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, (c, ct) => c.ListToolsAsync(cancellationToken: ct).AsTask(), @@ -130,7 +130,7 @@ public class CacheableResultWarningTests : LoggedTest var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, useModernLifecycle: true, TestContext.Current.CancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, TestContext.Current.CancellationToken); await using var client = await clientTask; @@ -208,7 +208,7 @@ public class CacheableResultWarningTests : LoggedTest private async Task RunScenarioAsync( string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, string method, JsonNode resultNode, Func clientCall, @@ -217,8 +217,8 @@ public class CacheableResultWarningTests : LoggedTest var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Pin the protocol version so the client deterministically takes the modern (server/discover) - // lifecycle for 2026-07-28 and the legacy (initialize) lifecycle for older versions. + // Pin the protocol version so the client deterministically takes the per-request metadata + // (server/discover) lifecycle for 2026-07-28 and the initialize lifecycle for older versions. var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -231,7 +231,7 @@ public class CacheableResultWarningTests : LoggedTest var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, useModernLifecycle, cancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, usePerRequestMetadataLifecycle, cancellationToken); await using var client = await clientTask; Assert.Equal(serverProtocolVersion, client.NegotiatedProtocolVersion); @@ -271,7 +271,7 @@ public class CacheableResultWarningTests : LoggedTest StreamReader serverReader, Stream serverWriter, string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, CancellationToken cancellationToken) { var requestLine = await serverReader.ReadLineAsync(cancellationToken); @@ -279,9 +279,9 @@ public class CacheableResultWarningTests : LoggedTest var request = JsonSerializer.Deserialize(requestLine, McpJsonUtilities.DefaultOptions); Assert.NotNull(request); - if (useModernLifecycle) + if (usePerRequestMetadataLifecycle) { - // Modern 2026-07-28 lifecycle (SEP-2575): no initialize handshake. The client probes + // Per-request metadata lifecycle (SEP-2575): no initialize handshake. The client probes // server/discover to learn capabilities, then sends normal RPCs carrying per-request _meta. Assert.Equal(RequestMethods.ServerDiscover, request.Method); @@ -292,13 +292,16 @@ public class CacheableResultWarningTests : LoggedTest { SupportedVersions = [serverProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MockServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }, cancellationToken); } else { - // Legacy initialize handshake for older protocol versions. + // Initialize handshake for older protocol versions. Assert.Equal(RequestMethods.Initialize, request.Method); await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse diff --git a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs index b1ac90c..7dca600 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -45,4 +46,42 @@ public static class CallToolResultTests Assert.Null(deserialized.IsError); Assert.Null(deserialized.Meta); } + + [Fact] + public static void CallToolResult_SerializationRoundTrip_PreservesEmbeddedPdfResource() + { + byte[] pdfBytes = Encoding.ASCII.GetBytes("%PDF-1.7\n"); + var original = new CallToolResult + { + Content = + [ + new EmbeddedResourceBlock + { + Resource = BlobResourceContents.FromBytes( + pdfBytes, + "file:///mypdf.pdf", + "application/pdf") + } + ] + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement resourceBlock = document.RootElement.GetProperty("content")[0]; + Assert.Equal("resource", resourceBlock.GetProperty("type").GetString()); + + JsonElement resource = resourceBlock.GetProperty("resource"); + Assert.Equal("file:///mypdf.pdf", resource.GetProperty("uri").GetString()); + Assert.Equal("application/pdf", resource.GetProperty("mimeType").GetString()); + Assert.Equal(Convert.ToBase64String(pdfBytes), resource.GetProperty("blob").GetString()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var embeddedResource = Assert.IsType(Assert.Single(deserialized.Content)); + var pdfResource = Assert.IsType(embeddedResource.Resource); + Assert.Equal("file:///mypdf.pdf", pdfResource.Uri); + Assert.Equal("application/pdf", pdfResource.MimeType); + Assert.Equal(pdfBytes, pdfResource.DecodedData.ToArray()); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs index 71afd89..b225dd6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -44,7 +44,6 @@ public static class DiscoverProtocolTests { Tools = new ToolsCapability { ListChanged = true }, }, - ServerInfo = new Implementation { Name = "test-server", Version = "2.0" }, Instructions = "Use this server for testing.", }; @@ -55,7 +54,6 @@ public static class DiscoverProtocolTests Assert.Equal(["2025-11-25", "2026-07-28"], deserialized.SupportedVersions); Assert.NotNull(deserialized.Capabilities.Tools); Assert.True(deserialized.Capabilities.Tools.ListChanged); - Assert.Equal("test-server", deserialized.ServerInfo.Name); Assert.Equal("Use this server for testing.", deserialized.Instructions); } @@ -66,7 +64,6 @@ public static class DiscoverProtocolTests { SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" }, }; var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs index 248bf77..0670f61 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Tests.Protocol; /// Targeted tests for the SEP-2549 caching hints (ttlMs and cacheScope) on /// . Spec PR #2855 promotes both fields to required on the discover /// response. has required CLR properties for -/// , , and -/// , which prevents reuse of the parameterized +/// and , +/// which prevents reuse of the parameterized /// helper (it instantiates via reflection). This file covers the /// same property-shape assertions for . /// @@ -17,9 +17,8 @@ public static class DiscoverResultCacheableTests { private static DiscoverResult NewDiscoverResult() => new() { - SupportedVersions = [McpHttpHeaders.November2025ProtocolVersion, McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, }; [Fact] diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 86acc57..f2e4472 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; @@ -27,8 +28,8 @@ public static class TaskSerializationTests Meta = new JsonObject { ["key"] = "value" } }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(deserialized); Assert.Equal("task-123", deserialized.TaskId); @@ -57,7 +58,7 @@ public static class TaskSerializationTests ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); // Must use camelCase wire names Assert.Contains("\"ttlMs\":", json); @@ -82,7 +83,7 @@ public static class TaskSerializationTests ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("task", (string)node["resultType"]!); @@ -90,6 +91,18 @@ public static class TaskSerializationTests #endregion + [Theory] + [InlineData(typeof(UpdateTaskResult))] + [InlineData(typeof(CancelTaskResult))] + public static void TaskAcknowledgement_SerializesExplicitCompleteResultType(Type resultType) + { + var result = (Result)Activator.CreateInstance(resultType)!; + + var json = JsonSerializer.SerializeToNode(result, resultType, McpTasksJsonContext.Default.Options)!; + + Assert.Equal("complete", (string)json["resultType"]!); + } + #region GetTaskResult Subtypes [Fact] @@ -104,8 +117,8 @@ public static class TaskSerializationTests PollIntervalMs = 2000, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("w1", working.TaskId); @@ -126,8 +139,8 @@ public static class TaskSerializationTests Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("c1", completed.TaskId); @@ -147,8 +160,8 @@ public static class TaskSerializationTests Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("f1", failed.TaskId); @@ -167,8 +180,8 @@ public static class TaskSerializationTests StatusMessage = "User cancelled", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var cancelled = Assert.IsType(deserialized); Assert.Equal("x1", cancelled.TaskId); @@ -195,8 +208,8 @@ public static class TaskSerializationTests InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.Equal("i1", inputRequired.TaskId); @@ -228,7 +241,7 @@ public static class TaskSerializationTests _ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""", }; - var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var result = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(result); Assert.IsType(expectedType, result); } @@ -238,42 +251,42 @@ public static class TaskSerializationTests public static void GetTaskResult_MissingTaskId_ThrowsJsonException() { var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_MissingStatus_ThrowsJsonException() { var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_UnknownStatus_ThrowsJsonException() { var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException() { var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_FailedMissingError_ThrowsJsonException() { var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException() { var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Theory] @@ -303,14 +316,14 @@ public static class TaskSerializationTests ["k"] = new InputRequest { Method = "test/method", - Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions), + Params = JsonSerializer.SerializeToElement("ask", McpTasksJsonContext.Default.Options), }, }, }, _ => throw new InvalidOperationException() }; - string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(value, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -328,10 +341,10 @@ public static class TaskSerializationTests [InlineData(McpTaskStatus.Failed, "failed")] public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue) { - string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(status, McpTasksJsonContext.Default.Options); Assert.Equal($"\"{expectedWireValue}\"", json); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.Equal(status, deserialized); } @@ -350,8 +363,8 @@ public static class TaskSerializationTests StatusMessage = "Working on it", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("n1", working.TaskId); @@ -370,8 +383,8 @@ public static class TaskSerializationTests Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("n2", completed.TaskId); @@ -390,8 +403,8 @@ public static class TaskSerializationTests Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("n3", failed.TaskId); @@ -408,8 +421,8 @@ public static class TaskSerializationTests LastUpdatedAt = DateTimeOffset.UtcNow, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.IsType(deserialized); } @@ -429,8 +442,8 @@ public static class TaskSerializationTests InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.NotNull(inputRequired.InputRequests); @@ -439,10 +452,10 @@ public static class TaskSerializationTests #endregion - #region ResultOrCreatedTask + #region ResultOrAlternate [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromResult() + public static void ResultOrAlternate_ImplicitConversion_FromResult() { CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] }; @@ -454,7 +467,7 @@ public static class TaskSerializationTests } [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() + public static void ResultOrAlternate_ImplicitConversion_FromCreateTaskResult() { CreateTaskResult taskCreated = new() { @@ -472,7 +485,7 @@ public static class TaskSerializationTests } [Fact] - public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask() + public static void ResultOrAlternate_IsTask_FalseForResult_TrueForTask() { var result = new ResultOrCreatedTask(new CallToolResult()); var task = new ResultOrCreatedTask(new CreateTaskResult @@ -496,7 +509,7 @@ public static class TaskSerializationTests { // SEP-2663: tasks/update responses use resultType="complete". var result = new UpdateTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -507,7 +520,7 @@ public static class TaskSerializationTests { // SEP-2663: tasks/cancel responses use resultType="complete". var result = new CancelTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs index bf4c67d..8d97eb5 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -190,6 +190,16 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C }); } + // These tests assert on the root server's ClientCapabilities (see AssertServerElicitationCapability), + // which is only session-scoped under the initialize-handshake revisions. Pin to the latest such revision + // so the capabilities negotiated during initialize are observable on the root McpServer. Request-scoped + // capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests. + private Task CreateLegacyClientForServer(McpClientOptions clientOptions) + { + clientOptions.ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; + return CreateMcpClientForServer(clientOptions); + } + [Fact] public async Task Can_Elicit_OutOfBand_With_Url() { @@ -198,7 +208,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C string? capturedMessage = null; var completionNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -283,7 +293,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task UrlElicitation_User_Can_Decline() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -322,7 +332,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task UrlElicitation_User_Can_Cancel() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -360,7 +370,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -385,7 +395,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -406,7 +416,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task UrlElicitation_BlankCapability_Allows_Only_Form() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -435,7 +445,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task FormElicitation_UrlOnlyCapability_NotSupported() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -474,7 +484,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -504,7 +514,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C [Fact] public async Task UrlElicitationRequired_Exception_Propagates_To_Client() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -532,7 +542,7 @@ public partial class UrlElicitationTests(ITestOutputHelper testOutputHelper) : C { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs new file mode 100644 index 0000000..e891c8c --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs @@ -0,0 +1,85 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies composition for the non-alternate +/// and alternate pipelines. +/// +public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateFilters seam + private static McpRequestFilter PassThroughCallToolFilter => + next => next; + + private static McpRequestInvocationFilter> PassThroughAlternateFilter => + static (context, next, cancellationToken) => next(context, cancellationToken); + + [Fact] + public async Task MixingCallToolFilters_WithAlternateFilters_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + + Assert.NotNull(server); + } + + [Fact] + public async Task AlternateFilters_AddedAfterOrdinaryFilters_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + + Assert.NotNull(server); + } + + [Fact] + public async Task CallToolFilters_WithExplicitAlternateHandler_ThrowsActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Handlers.CallToolWithAlternateHandler = static (_, _) => + new(new ResultOrAlternate(new CallToolResult())); + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), ex.Message); + Assert.Contains("replaces the ordinary tool-call pipeline", ex.Message); + } + + [Fact] + public async Task CallToolFiltersAlone_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } + + [Fact] + public async Task AlternateFiltersAlone_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } +#pragma warning restore MCPEXP002 +} diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs new file mode 100644 index 0000000..56e8fd1 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs @@ -0,0 +1,102 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that custom request handlers registered through +/// cannot silently replace a built-in method +/// or another custom handler. +/// +public class CustomRequestHandlerCollisionTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ +#pragma warning disable MCPEXP002 + private static McpServerRequestHandler CreateHandler(string method) => new() + { + Method = method, + Handler = (request, cancellationToken) => new ValueTask((JsonNode?)null), + }; + + [Fact] + public async Task CustomHandler_CollidingWithBuiltInMethod_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + Capabilities = new() { Tools = new() }, + RequestHandlers = [CreateHandler("tools/call")], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("tools/call", ex.Message); + } + + [Fact] + public async Task CustomHandler_CollidingWithInitialize_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("initialize")], + }; + + Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + } + + [Fact] + public async Task CustomHandler_DuplicateCustomMethod_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("custom/method"), CreateHandler("custom/method")], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("custom/method", ex.Message); + } + + [Fact] + public async Task CustomHandler_UniqueMethod_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("custom/method")], + }; + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } + + [Fact] + public async Task CustomHandler_EmptyRoutingNameParameter_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "custom/method", + RoutingNameParameter = " ", + Handler = (request, cancellationToken) => new ValueTask((JsonNode?)null), + }, + ], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpServerRequestHandler.RoutingNameParameter), ex.Message); + } +#pragma warning restore MCPEXP002 +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 83afbfe..236740f 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; @@ -87,6 +88,59 @@ public class InMemoryMcpTaskStoreTests Assert.Null(result); } + [Fact] + public async Task GetTaskAsync_WithinTimeToLive_ReturnsTask() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMinutes(10) }; + var created = await store.CreateTaskAsync(CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + + [Fact] + public async Task GetTaskAsync_AfterTimeToLiveElapsed_ReturnsNull() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMilliseconds(100) }; + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(500), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.Null(result); + } + + [Fact] + public async Task GetTaskAsync_WithoutTimeToLive_DoesNotExpire() + { + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(200), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + + [Fact] + public async Task GetTaskAsync_WithZeroTimeToLive_DoesNotExpire() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.Zero }; + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(200), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + [Fact] public async Task SetCompletedAsync_TransitionsToCompleted() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs new file mode 100644 index 0000000..aadb669 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs @@ -0,0 +1,100 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Round-trip integration tests for the MCP Apps _meta.ui metadata: tools registered +/// with and processed by WithMcpApps() are listed through +/// and the structured _meta.ui object is verified +/// after serializing across the client-server transport. +/// +public class McpAppsIntegrationTests : ClientServerTestBase +{ + public McpAppsIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithTools() + .WithMcpApps(); + } + + [Fact] + public async Task ListToolsAsync_RoundTripsMetaUi_ForToolWithAppUiAttribute() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var weatherTool = Assert.Single(tools, t => t.Name == "weather_tool"); + + JsonObject? uiNode = weatherTool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); + + // Visibility was not restricted, so the property must be absent entirely + // (not merely serialized as null). + Assert.False(uiNode.ContainsKey("visibility")); + } + + [Fact] + public async Task ListToolsAsync_RoundTripsMetaUiVisibility_ForRestrictedTool() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var modelOnlyTool = Assert.Single(tools, t => t.Name == "model_only_tool"); + + JsonObject? uiNode = modelOnlyTool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://model-only/view.html", uiNode["resourceUri"]?.GetValue()); + + JsonArray? visibility = uiNode["visibility"]?.AsArray(); + Assert.NotNull(visibility); + JsonNode? single = Assert.Single(visibility); + Assert.Equal(McpUiToolVisibility.Model, single?.GetValue()); + } + + [Fact] + public async Task ListToolsAsync_HasNoUiMeta_ForToolWithoutAppUiAttribute() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var plainTool = Assert.Single(tools, t => t.Name == "plain_tool"); + + // The tool must carry no "ui" metadata at all: either no _meta object, + // or a _meta without the "ui" key (not a "ui": null entry). + JsonObject? meta = plainTool.ProtocolTool.Meta; + Assert.False(meta is not null && meta.ContainsKey("ui")); + } + + public sealed class AppUiTools + { + [McpServerTool(Name = "weather_tool")] + [McpAppUi(ResourceUri = "ui://weather/view.html")] + [Description("Get weather")] + public static string WeatherTool(string location) => $"Weather for {location}"; + + [McpServerTool(Name = "model_only_tool")] + [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])] + [Description("Model only")] + public static string ModelOnlyTool(string location) => $"Model only for {location}"; + + [McpServerTool(Name = "plain_tool")] + [Description("Plain tool without app UI")] + public static string PlainTool(string input) => input; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9708722..669499c 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Microsoft.Extensions.DependencyInjection; @@ -5,6 +6,8 @@ using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; +#pragma warning disable MCPEXP001, MCPEXP002 + namespace ModelContextProtocol.Tests.Server; /// @@ -29,74 +32,97 @@ public class McpServerTaskTests : ClientServerTestBase mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); - options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { _capturedMeta = context.Params?.Meta; var store = context.Server.Services!.GetRequiredService(); var toolName = context.Params!.Name; - if (toolName == "immediate-tool") + ResultOrAlternate result = toolName switch { - return new CallToolResult() + "immediate-tool" => new(new CallToolResult { Content = [new TextContentBlock { Text = "immediate result" }], - }; - } + }), + "async-tool" => ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = store.CreateTask(), + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + "input-required-tool" => ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = store.CreateTask(McpTaskStatus.InputRequired), + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + _ => throw new McpException($"Unknown tool: {toolName}"), + }; - if (toolName == "async-tool") + return new ValueTask>(result); + }; + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - var taskId = store.CreateTask(); - return new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); - if (toolName == "input-required-tool") + GetTaskResult result; + try + { + result = _taskStore.GetTask(requestParams.TaskId); + } + catch (McpException ex) + { + throw new McpProtocolException(ex.Message, McpErrorCode.InvalidParams); + } + + return new ValueTask(JsonSerializer.SerializeToNode(result, McpTasksJsonContext.Default.Options)); + }, + }); + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => { - var taskId = store.CreateTask(McpTaskStatus.InputRequired); - return new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/update", McpErrorCode.InvalidParams); - throw new McpException($"Unknown tool: {toolName}"); - }; + _taskStore.ProvideInput(requestParams.TaskId, requestParams.InputResponses ?? new Dictionary()); + return new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.GetTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - return store.GetTask(taskId); - }; + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); - options.Handlers.UpdateTaskHandler = async (context, cancellationToken) => - { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary()); - return new UpdateTaskResult(); - }; - - options.Handlers.CancelTaskHandler = async (context, cancellationToken) => - { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.CancelTask(taskId); - return new CancelTaskResult(); - }; + _taskStore.CancelTask(requestParams.TaskId); + return new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); }); } @@ -105,9 +131,9 @@ public class McpServerTaskTests : ClientServerTestBase { await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -115,11 +141,11 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() + public async Task CallToolAsTaskAsync_ImmediateResult_ReturnsResultNotTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -130,11 +156,11 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated() + public async Task CallToolAsTaskAsync_AsyncTool_ReturnsTaskCreated() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -154,7 +180,7 @@ public class McpServerTaskTests : ClientServerTestBase // Complete the task after a brief delay so polling finds it. _ = Task.Run(async () => { - await Task.Delay(100, ct); + await Task.Delay(100, cancellationToken: ct); // The store should have exactly one task by now var taskId = _taskStore.GetAllTaskIds().Single(); _taskStore.CompleteTask(taskId, new CallToolResult @@ -163,9 +189,8 @@ public class McpServerTaskTests : ClientServerTestBase }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Single(result.Content); @@ -192,9 +217,8 @@ public class McpServerTaskTests : ClientServerTestBase }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await failedTask.Task); } @@ -219,9 +243,8 @@ public class McpServerTaskTests : ClientServerTestBase }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await cancelledTask.Task); } @@ -231,10 +254,10 @@ public class McpServerTaskTests : ClientServerTestBase { await using var client = await CreateMcpClientForServer(); - // Create a task via CallToolRawAsync - var augmented = await client.CallToolRawAsync( + // Create a task via CallToolAsTaskAsync + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); var taskId = augmented.TaskCreated!.TaskId; @@ -250,7 +273,7 @@ public class McpServerTaskTests : ClientServerTestBase { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -276,7 +299,7 @@ public class McpServerTaskTests : ClientServerTestBase var extensions = client.ServerCapabilities.Extensions; #pragma warning restore MCP_EXTENSIONS Assert.NotNull(extensions); - Assert.True(extensions.ContainsKey(McpExtensions.Tasks)); + Assert.True(extensions.ContainsKey(TasksProtocol.ExtensionId)); } [Fact] @@ -284,7 +307,7 @@ public class McpServerTaskTests : ClientServerTestBase { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -298,7 +321,7 @@ public class McpServerTaskTests : ClientServerTestBase // Strong consistency: tasks/get immediately after CreateTaskResult must resolve. await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -328,7 +351,7 @@ public class McpServerTaskTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -347,7 +370,7 @@ public class McpServerTaskTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; // Create an input-required task - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "input-required-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -373,15 +396,15 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() + public async Task CallToolAsTaskAsync_InjectsTaskCapabilityInMeta() { // Verify the server receives the task extension in _meta by intercepting - // the handler. The CallToolWithTaskHandler already receives the request, + // the handler. The CallToolWithAlternateHandler already receives the request, // so we can observe the meta there. We test the client-side injection indirectly // by confirming the server returns a task result (which requires the capability signal). await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -390,14 +413,14 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope() + public async Task CallToolAsTaskAsync_OptIn_UsesSep2575CapabilitiesEnvelope() { // SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope: // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} // This test pins the literal wire path so future refactors can't regress. await using var client = await CreateMcpClientForServer(); - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -413,7 +436,7 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() + public async Task CallToolAsTaskAsync_OptIn_PreservesExistingMetaSiblings() { // User-supplied _meta entries at the root must not be clobbered, and the SEP-2575 // envelope must be added alongside them, not in place of them. @@ -431,7 +454,7 @@ public class McpServerTaskTests : ClientServerTestBase }, }; - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -452,13 +475,13 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_PreservesExistingUserMeta() + public async Task CallToolAsTaskAsync_PreservesExistingUserMeta() { // Verify that user-supplied meta fields are not clobbered await using var client = await CreateMcpClientForServer(); var userMeta = new JsonObject { ["customKey"] = "customValue" }; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -494,8 +517,8 @@ public class McpServerTaskTests : ClientServerTestBase }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); var elapsed = DateTime.UtcNow - startTime; @@ -506,25 +529,25 @@ public class McpServerTaskTests : ClientServerTestBase } [Fact] - public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult() + public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallToolResult() { - // Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works + // Verify that the implicit conversion from CallToolResult to ResultOrAlternate works // in the handler context — this is already tested by "immediate-tool" working correctly. await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text); } [Fact] - public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive() + public async Task CallToolHandler_And_CallToolWithAlternateHandler_AreMutuallyExclusive() { var handlers = new McpServerHandlers(); - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => handlers.CallToolHandler = async (ctx, ct) => new CallToolResult()); @@ -532,7 +555,7 @@ public class McpServerTaskTests : ClientServerTestBase handlers.CallToolHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult()); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult()); } [Fact] @@ -544,8 +567,8 @@ public class McpServerTaskTests : ClientServerTestBase handlers.CallToolHandler = null; // Now setting the other should work - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); - Assert.NotNull(handlers.CallToolWithTaskHandler); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); + Assert.NotNull(handlers.CallToolWithAlternateHandler); } /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs index 9e264af..db051d1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -10,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server -/// has neither an nor a +/// has neither nor a /// configured. The expected behavior is a silent synchronous fallback: the server returns the normal /// with no Task envelope and no exception. /// @@ -25,7 +26,7 @@ public class McpServerTasksNoStoreTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Intentionally do NOT configure TaskStore or CallToolWithTaskHandler. + // Intentionally do NOT configure TaskStore or CallToolWithAlternateHandler. mcpServerBuilder.WithTools(); } @@ -35,8 +36,8 @@ public class McpServerTasksNoStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - // CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta. - var augmented = await client.CallToolRawAsync( + // CallToolAsTaskAsync always writes the SEP-2575 tasks opt-in into _meta. + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "sync-tool" }, ct); // With no task store configured, the server must complete synchronously and return @@ -54,8 +55,8 @@ public class McpServerTasksNoStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sync-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sync-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sync result", Assert.IsType(result.Content[0]).Text); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 87f363f..0ab19a3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Reflection; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -26,7 +27,7 @@ public class McpServerTests : LoggedTest { return new McpServerOptions { - ProtocolVersion = "2024", + ProtocolVersion = "2024-11-05", InitializationTimeout = TimeSpan.FromSeconds(30), Capabilities = capabilities, }; @@ -283,13 +284,77 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); - Assert.Equal("2024", result.ProtocolVersion); - Assert.Equal("2024", server.NegotiatedProtocolVersion); + Assert.Equal("2024-11-05", result.ProtocolVersion); + Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); + Assert.True(Assert.IsType(response)["capabilities"]!.AsObject().ContainsKey("logging")); }); } + [Theory] + [InlineData("2025-11-25", "\"2025-03-26\"", null)] + [InlineData("2025-11-25", "\"2026-07-28\"", null)] + [InlineData("2025-11-25", "{}", null)] + [InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)] + [InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata( + string transportVersion, string metadataVersionJson, McpErrorCode? expectedError) + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new TestServerTransport(); + var options = CreateOptions(); + options.ProtocolVersion = null; + + await using var server = McpServer.Create(transport, options, LoggerFactory); + var runTask = server.RunAsync(ct); + + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + transport.OnMessageSent = message => + { + if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1") + { + response.TrySetResult(message); + } + }; + + await transport.SendClientMessageAsync(new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.Ping, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson), + }, + }, + Context = new JsonRpcMessageContext + { + ProtocolVersion = transportVersion, + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, + }, ct); + + var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + if (expectedError is { } errorCode) + { + Assert.Equal((int)errorCode, Assert.IsType(message).Error.Code); + Assert.Null(server.NegotiatedProtocolVersion); + } + else + { + Assert.IsType(message); + Assert.Equal(transportVersion, server.NegotiatedProtocolVersion); + } + + await transport.DisposeAsync(); + await runTask; + } + [Fact] public async Task Initialize_IncludesExtensionsInResponse() { @@ -304,6 +369,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -323,6 +389,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -354,6 +421,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); // Use reflection to verify every public property on ServerCapabilities is non-null. // This catches cases where new capability properties are added but not copied @@ -400,6 +468,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Null(result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -443,6 +512,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Null(result.ResultType); Assert.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -486,6 +556,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Null(result.ResultType); Assert.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -535,6 +606,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Null(result.ResultType); Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -590,6 +662,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Null(result.ResultType); // Custom handler values + auto-populated values should be combined Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values); Assert.Equal(3, result.Completion.Total); @@ -637,6 +710,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Null(result.ResultType); Assert.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -675,6 +749,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); + Assert.Null(result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -704,6 +779,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -739,6 +815,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -776,6 +853,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -805,6 +883,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.Equal("test", result.Description); }); } @@ -839,6 +918,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -874,17 +954,149 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Content); Assert.Equal("test", Assert.IsType(result.Content[0]).Text); }); } + [Fact] + public async Task Can_Handle_Call_Tool_Requests_With_Embedded_Pdf_Resource_On_Wire() + { + byte[] pdfBytes = Encoding.ASCII.GetBytes("%PDF-1.7\n"); + await using var transport = new TestServerTransport(); + var options = CreateOptions(new ServerCapabilities { Tools = new() }); + options.Handlers.CallToolHandler = async (request, ct) => + { + return new CallToolResult + { + Content = + [ + new EmbeddedResourceBlock + { + Resource = BlobResourceContents.FromBytes( + pdfBytes, + "file:///mypdf.pdf", + "application/pdf") + } + ] + }; + }; + options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException(); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + var runTask = server.RunAsync(TestContext.Current.CancellationToken); + var receivedMessage = new TaskCompletionSource(); + + transport.OnMessageSent = message => + { + if (message is JsonRpcResponse response && response.Id.ToString() == "55") + { + receivedMessage.SetResult(response); + } + }; + + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Id = new RequestId(55) + }, + TestContext.Current.CancellationToken); + + var response = await receivedMessage.Task.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken); + string wireJson = JsonSerializer.Serialize( + response, + McpJsonUtilities.DefaultOptions); + + using JsonDocument document = JsonDocument.Parse(wireJson); + JsonElement root = document.RootElement; + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + Assert.Equal(55, root.GetProperty("id").GetInt32()); + + JsonElement resourceBlock = root.GetProperty("result").GetProperty("content")[0]; + Assert.Equal("resource", resourceBlock.GetProperty("type").GetString()); + JsonElement resource = resourceBlock.GetProperty("resource"); + Assert.Equal("file:///mypdf.pdf", resource.GetProperty("uri").GetString()); + Assert.Equal("application/pdf", resource.GetProperty("mimeType").GetString()); + Assert.Equal(Convert.ToBase64String(pdfBytes), resource.GetProperty("blob").GetString()); + + var roundTrippedMessage = JsonSerializer.Deserialize( + wireJson, + McpJsonUtilities.DefaultOptions); + var roundTrippedResponse = Assert.IsType(roundTrippedMessage); + var result = roundTrippedResponse.Result.Deserialize( + McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + var embeddedResource = Assert.IsType(Assert.Single(result.Content)); + var pdfResource = Assert.IsType(embeddedResource.Resource); + Assert.Equal(pdfBytes, pdfResource.DecodedData.ToArray()); + + await transport.DisposeAsync(); + await runTask; + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_Assigned() { await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); } + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests() + { + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: options => + { + // logging/setLevel is a legacy (2025-06-18) method whose result must serialize as an + // empty object {}. The custom handler returns a bare result and the server must not + // add a resultType, otherwise the MCP conformance suite rejects the response. + options.Handlers.SetLoggingLevelHandler = async (request, ct) => new EmptyResult(); + }, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); + }); + } + + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests_WithoutHandler_OmitsResultType() + { + // With no custom SetLoggingLevelHandler configured, the server uses its default logging/setLevel + // handler. logging/setLevel is a legacy (<= 2025-11-25) method, so the default handler must also + // serialize its result as an empty object {} without the 2026-07-28 resultType field (issue #1721). + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: null, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); + }); + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_With_McpException() { @@ -907,6 +1119,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -935,6 +1148,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -970,6 +1184,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Null(result.ResultType); Assert.True(result.IsError, "Input validation errors should be returned as tool execution errors (IsError=true), not protocol errors"); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1230,6 +1445,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response.Result); var initResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(initResult); + Assert.Null(initResult.ResultType); Assert.NotNull(initResult.ServerInfo); await transport.DisposeAsync(); diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index 09a501a..ebf7ca4 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -13,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the -based auto-wiring of tools/call into tasks. -/// Verifies that setting enables task support +/// Verifies that enables task support /// for -based tools. /// public class McpTaskStoreTests : ClientServerTestBase @@ -27,23 +28,26 @@ public class McpTaskStoreTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.WithTools(); - - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTools() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); + }, options => options.ExecutionModeSelector = request => + request.Params?.Name switch + { + "sync-tool" => McpTaskExecutionMode.Synchronous, + "required-tool" => McpTaskExecutionMode.Required, + _ => McpTaskExecutionMode.Optional, + }); } [Fact] - public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult() + public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, TestContext.Current.CancellationToken); @@ -54,15 +58,88 @@ public class McpTaskStoreTests : ClientServerTestBase Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status); } + [Fact] + public async Task CallToolAsync_SynchronousExecutionMode_ReturnsResultDirectly() + { + await using McpClient client = await CreateMcpClientForServer(); + + CallToolResult result = await client.CallToolAsync( + "sync-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("sync", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolAsync_RequiredExecutionModeWithoutCapability_Throws() + { + await using McpClient client = await CreateMcpClientForServer(); + + MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "required-tool", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode); + } + + [Fact] + public async Task CallToolAsync_RequiredExecutionModeWithLegacyProtocol_Throws() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "required-tool", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode); + } + + [Fact] + public async Task CallToolAsTaskAsync_RequiredExecutionMode_ReturnsTask() + { + await using McpClient client = await CreateMcpClientForServer(); + + ResultOrCreatedTask result = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "required-tool" }, + TestContext.Current.CancellationToken); + + Assert.True(result.IsTask); + + CallToolResult completedResult = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "required-tool" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("required", Assert.IsType(completedResult.Content[0]).Text); + } + + [Fact] + public void WithTasks_NullExecutionModeSelector_Throws() + { + ServiceCollection services = new(); + + Assert.Throws(() => + services + .AddMcpServer() + .WithStreamServerTransport(Stream.Null, Stream.Null) + .WithTasks( + new InMemoryMcpTaskStore(), + options => options.ExecutionModeSelector = null!)); + } + [Fact] public async Task CallToolAsync_WithTaskStore_PollsToCompletion() { await using var client = await CreateMcpClientForServer(); // CallToolAsync should poll until the background execution completes. - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "slow-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -75,7 +152,7 @@ public class McpTaskStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); // Even a fast tool should go through the task store when the client signals capability. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, TestContext.Current.CancellationToken); @@ -88,8 +165,8 @@ public class McpTaskStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( - new CallToolRequestParams { Name = "fast-tool" }, ct); + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "fast-tool" }, cancellationToken: ct); var taskId = augmented.TaskCreated!.TaskId; @@ -115,7 +192,7 @@ public class McpTaskStoreTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; // Create a slow task that won't complete on its own - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -145,7 +222,7 @@ public class McpTaskStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -176,7 +253,7 @@ public class McpTaskStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -210,7 +287,7 @@ public class McpTaskStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "mrtr-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -233,7 +310,7 @@ public class McpTaskStoreTests : ClientServerTestBase var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains("MRTR", message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains("tasks", message); } [Fact] @@ -253,8 +330,8 @@ public class McpTaskStoreTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; // CallToolAsync will poll and resolve input requests automatically. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text); @@ -279,8 +356,8 @@ public class McpTaskStoreTests : ClientServerTestBase }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sample-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sample-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sampled response", Assert.IsType(result.Content[0]).Text); @@ -309,8 +386,8 @@ public class McpTaskStoreTests : ClientServerTestBase }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "roots-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "roots-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("file:///workspace,file:///other", Assert.IsType(result.Content[0]).Text); @@ -328,12 +405,10 @@ public class McpTaskStoreTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is not null) { notifications.Writer.TryWrite(typed); @@ -342,8 +417,8 @@ public class McpTaskStoreTests : ClientServerTestBase return default; }); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "notifying-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "notifying-tool" }, cancellationToken: ct); Assert.Equal("notified", Assert.IsType(result.Content[0]).Text); @@ -377,12 +452,10 @@ public class McpTaskStoreTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is FailedTaskNotificationParams) { notifications.Writer.TryWrite(typed); @@ -393,8 +466,8 @@ public class McpTaskStoreTests : ClientServerTestBase // The tool emits a Failed notification then returns a normal result, so we isolate the // notification round-trip from the task-store's own failure handling. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "failing-notify-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "failing-notify-tool" }, cancellationToken: ct); Assert.Equal("emitted-failed", Assert.IsType(result.Content[0]).Text); @@ -426,8 +499,8 @@ public class McpTaskStoreTests : ClientServerTestBase }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); // The handler should be called exactly once despite potential multiple polls Assert.Equal(1, elicitCallCount); @@ -435,7 +508,7 @@ public class McpTaskStoreTests : ClientServerTestBase } [Fact] - public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() + public async Task CallToolAsTaskAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() { await using var client = await CreateMcpClientForServer(new McpClientOptions { @@ -447,7 +520,7 @@ public class McpTaskStoreTests : ClientServerTestBase }); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "elicit-tool" }, ct); Assert.True(augmented.IsTask); @@ -476,7 +549,7 @@ public class McpTaskStoreTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; // Run a fast tool to completion via the task store. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -516,7 +589,7 @@ public class McpTaskStoreTests : ClientServerTestBase var sw = System.Diagnostics.Stopwatch.StartNew(); var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct)); sw.Stop(); Assert.Equal("handler-failed", ex.Message); @@ -531,7 +604,7 @@ public class McpTaskStoreTests : ClientServerTestBase public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately() { // SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the - // extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw + // extension capability." We bypass CallToolAsTaskAsync (which injects the marker) and send a raw // tools/call request without the io.modelcontextprotocol/tasks key in _meta. await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; @@ -561,7 +634,7 @@ public class McpTaskStoreTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "iserror-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -602,8 +675,8 @@ public class McpTaskStoreTests : ClientServerTestBase }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "multi-elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "multi-elicit-tool" }, cancellationToken: ct); // Exactly two handler invocations — one per unique input request key. Assert.Equal(2, elicitCount); @@ -615,6 +688,12 @@ public class McpTaskStoreTests : ClientServerTestBase [McpServerToolType] private sealed class TaskStoreTestTools { + [McpServerTool(Name = "sync-tool")] + public static string SyncTool() => "sync"; + + [McpServerTool(Name = "required-tool")] + public static string RequiredTool() => "required"; + [McpServerTool(Name = "slow-tool"), System.ComponentModel.Description("A tool that takes time")] public static async Task SlowTool(CancellationToken cancellationToken) { diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index a263e28..3d927b1 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; @@ -59,3 +61,88 @@ public class MrtrInputRequiredExceptionTests : ClientServerTestBase Assert.Contains("more than", exception.Message); } } + +/// +/// Companion to covering a native (MRTR-capable) round-trip where the +/// server RETURNS an through the alternate result path +/// () rather than throwing . The MRTR +/// client drives the round-trip and receives the final result. +/// +public class MrtrReturnedInputRequiredResultNativeTests : ClientServerTestBase +{ + private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = + (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); + + private int _attempt; + + public MrtrReturnedInputRequiredResultNativeTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "2026-07-28"; + + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + { + Interlocked.Increment(ref _attempt); + + // Retry round: the MRTR client re-sent the request with its responses and our requestState. + if (context.Params?.RequestState is not null) + { + return new ValueTask>(new CallToolResult + { + Content = [new TextContentBlock { Text = "resolved" }], + }); + } + + // First round: RETURN an InputRequiredResult through the alternate path. An MRTR client + // understands it natively and drives the round-trip. + var inputRequired = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "need-input", + RequestedSchema = new(), + }), + }, + RequestState = "round1", + }; + + return new ValueTask>( + ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); + }; + }); + } +#pragma warning restore MCPEXP002 + + [Fact] + public async Task ReturnedInputRequiredResult_MrtrClient_RoundTripsToFinalResult() + { + StartServer(); + + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "return-form", + cancellationToken: TestContext.Current.CancellationToken); + + // Two handler invocations: initial (returned InputRequiredResult) + client-driven retry (final result). + Assert.Equal(2, _attempt); + var content = Assert.Single(result.Content); + Assert.Equal("resolved", Assert.IsType(content).Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index 221a2ff..3c5a52d 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -111,3 +113,89 @@ public class MrtrServerBackcompatTests : ClientServerTestBase Assert.Equal("final-state:", text); } } + +/// +/// Companion to covering the other way a handler can surface an +/// input-required result to a non-MRTR client: by RETURNING an through the +/// alternate result path () instead of throwing +/// . The legacy backcompat resolver must normalize both forms so a non-MRTR +/// stateful client gets the same server-side resolution either way. +/// +public class MrtrReturnedInputRequiredResultBackcompatTests : ClientServerTestBase +{ + private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = + (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); + + private int _attempt; + + public MrtrReturnedInputRequiredResultBackcompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + { + Interlocked.Increment(ref _attempt); + + // Retry round: the backcompat resolver re-invoked us with the client's responses. + if (context.Params?.RequestState is not null) + { + return new ValueTask>(new CallToolResult + { + Content = [new TextContentBlock { Text = "resolved" }], + }); + } + + // First round: RETURN an InputRequiredResult through the alternate path rather than throwing. + var inputRequired = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "need-input", + RequestedSchema = new(), + }), + }, + RequestState = "round1", + }; + + return new ValueTask>( + ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); + }; + }); + } +#pragma warning restore MCPEXP002 + + [Fact] + public async Task ReturnedInputRequiredResult_NonMrtrStatefulClient_ResolvedServerSide() + { + StartServer(); + + // Non-MRTR client → server falls into the legacy backcompat resolver path, which must handle a + // RETURNED InputRequiredResult exactly like a thrown InputRequiredException. + var clientOptions = new McpClientOptions + { + ProtocolVersion = "2025-06-18", + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "return-form", + cancellationToken: TestContext.Current.CancellationToken); + + // Two handler invocations: initial (returned InputRequiredResult) + retry (final result). + Assert.Equal(2, _attempt); + var content = Assert.Single(result.Content); + Assert.Equal("resolved", Assert.IsType(content).Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 29ae782..b4bf440 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -25,6 +25,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl private readonly Pipe _serverToClient = new(); private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); private readonly ServiceProvider _services; + private readonly McpServer _server; private readonly Task _serverTask; private readonly StreamWriter _writer; private readonly StreamReader _reader; @@ -35,14 +36,15 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); serviceCollection.AddSingleton(XunitLoggerProvider); + serviceCollection.AddSingleton(MockLoggerProvider); serviceCollection .AddMcpServer() .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) .WithTools(); _services = serviceCollection.BuildServiceProvider(validateScopes: true); - var server = _services.GetRequiredService(); - _serverTask = server.RunAsync(_cts.Token); + _server = _services.GetRequiredService(); + _serverTask = _server.RunAsync(_cts.Token); _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; _reader = new StreamReader(_serverToClient.Reader.AsStream()); @@ -54,38 +56,425 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl var ct = TestContext.Current.CancellationToken; // The first request establishes the 2026-07-28 version for the stateful session (null -> 2026-07-28). - Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); // Re-sending the same version is an idempotent no-op, not an error. - Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); // Switching to a different (still-supported) version mid-session is rejected. - var error = Assert.IsType(await RoundTripAsync(id: 3, McpHttpHeaders.November2025ProtocolVersion, ct)); + var error = Assert.IsType(await RoundTripAsync(id: 3, McpProtocolVersions.November2025ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); // The rejected request must not have mutated the negotiated version: the original 2026-07-28 version still works. - Assert.IsType(await RoundTripAsync(id: 4, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 4, McpProtocolVersions.July2026ProtocolVersion, ct)); } - private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) + [Fact] + public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() { - // tools/list is available under both the legacy and 2026-07-28 revisions (unlike ping/initialize, + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + + // The advisory legacy value must not block a subsequent modern request from selecting its era. + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson), + }, + }, + }; + + // An unreadable value leaves the request on the legacy path without establishing a version. + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + + var warning = Assert.Single( + MockLoggerProvider.LogMessages, + m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal)); + Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + }, + }, + }; + + // server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy + // handling. This is what keeps a modern client from silently getting legacy treatment on a transport + // with no protocol version header. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + var meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + if (protocolVersionJson is not null) + { + meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson); + } + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + + // Once the era is established the metadata is required, so an unreadable value is an error rather + // than a reason to drop back to legacy handling. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task PerRequestMetadata_ServesRequestMissingClientInfo() + { + var ct = TestContext.Current.CancellationToken; + + // clientInfo is optional: a request whose _meta omits it is served. + var response = await RoundTripAsync( + id: 1, + McpProtocolVersions.July2026ProtocolVersion, + ct, + includeClientInfo: false); + + Assert.IsType(response); + } + + [Fact] + public async Task PerRequestMetadata_RejectsRequestMissingClientCapabilities() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripAsync( + id: 1, + McpProtocolVersions.July2026ProtocolVersion, + ct, + includeClientCapabilities: false)); + + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task ServerDiscover_WithoutPerRequestMetadata_IsRejectedBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Initialize_IgnoresFutureReservedMetadata() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + Meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", + }, + }, McpJsonUtilities.DefaultOptions), + }; + + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + } + + [Fact] + public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct)); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresModernProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", + }, + }, + }; + + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + } + + [Theory] + [InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")] + [InlineData(MetaKeys.ClientCapabilities, "null")] + [InlineData(MetaKeys.ClientCapabilities, "[]")] + [InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")] + [InlineData(MetaKeys.LogLevel, "\"not-a-level\"")] + public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson) + { + var ct = TestContext.Current.CancellationToken; + var meta = PerRequestMetadata(); + meta[key] = JsonNode.Parse(valueJson); + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(key, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); + + Assert.IsType( + await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct)); + } + + [Fact] + public async Task SubscriptionsListen_WithInitializeProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.SubscriptionsListen, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(RequestMethods.SubscriptionsListen, error.Error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("ping")] + [InlineData("logging/setLevel")] + [InlineData("resources/subscribe")] + [InlineData("resources/unsubscribe")] + public async Task RemovedMethod_WithPerRequestMetadataProtocolVersion_IsRejected(string method) + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = method, + Params = new JsonObject + { + ["_meta"] = PerRequestMetadata(), + }, + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(method, error.Error.Message, StringComparison.Ordinal); + } + + private async Task RoundTripAsync( + long id, + string protocolVersion, + CancellationToken cancellationToken, + bool includeClientInfo = true, + bool includeClientCapabilities = true) + { + // tools/list is available under both the initialize-handshake and 2026-07-28 revisions (unlike ping/initialize, // which the 2026-07-28 protocol removed), so it exercises the version guard rather than the // per-method availability gate. + var meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = protocolVersion, + }; + + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) + { + if (includeClientInfo) + { + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }; + } + + if (includeClientCapabilities) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } + } + var request = new JsonRpcRequest { Id = new RequestId(id), Method = RequestMethods.ToolsList, Params = new JsonObject { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = protocolVersion, - }, + ["_meta"] = meta, }, }; + return await SendAndReceiveAsync(request, cancellationToken); + } + + private static JsonObject PerRequestMetadata() => new() + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + + private async Task RoundTripInitializeAsync(long id, string protocolVersion, CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Id = new RequestId(id), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = protocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + return await SendAndReceiveAsync(request, cancellationToken); + } + + private async Task SendAndReceiveAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); #if NET await _writer.WriteLineAsync(json.AsMemory(), cancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs index 9e5a9e1..d5735f1 100644 --- a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that the built-in ping handler is gated by protocol version. /// SEP-2575 (the 2026-07-28 revision) removes ping; servers must -/// respond with -32601 MethodNotFound. Legacy protocol versions still -/// support ping per the spec. +/// respond with -32601 MethodNotFound. Initialize-handshake protocol +/// versions still support ping per the spec. /// public sealed class PingProtocolGatingTests : ClientServerTestBase { @@ -28,7 +28,7 @@ public sealed class PingProtocolGatingTests : ClientServerTestBase StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }); var ex = await Assert.ThrowsAsync(async () => @@ -38,13 +38,13 @@ public sealed class PingProtocolGatingTests : ClientServerTestBase } [Fact] - public async Task Ping_OnLegacySession_StillSucceeds() + public async Task Ping_OnInitializeHandshakeSession_StillSucceeds() { // Default server config; client pinned to 2025-11-25. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs new file mode 100644 index 0000000..db0f658 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Regression tests for issue #1721: the SDK-provided resultType, ttlMs, and +/// cacheScope result decorations are exclusive to the 2026-07-28 protocol revision and +/// must be absent from result objects on a session negotiated to an earlier revision +/// (e.g. 2025-11-25). Emitting them breaks clients that strictly validate the 2025-11-25 +/// schema. +/// +/// +/// These drive the in-process client/server pair and inspect the +/// raw JSON-RPC result wire shape (via ) +/// so the assertions are about the actual serialized fields rather than deserialized objects. +/// tools/list exercises the SetHandler decoration path (its result is both an +/// and a ), while tools/call exercises the +/// SetWithAlternateHandler path. +/// +public class ProtocolVersionResultDecorationTests : ClientServerTestBase +{ + public ProtocolVersionResultDecorationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([McpServerTool.Create(() => "ok", new() { Name = "echo" })]); + } + + [Fact] + public async Task ToolsList_On2025_11_25Session_OmitsResultTypeAndCacheHints() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ToolsList }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); + } + + [Fact] + public async Task ToolsCall_On2025_11_25Session_OmitsResultType() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Params = System.Text.Json.JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = "echo" }, McpJsonUtilities.DefaultOptions), + }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/call result."); + } + + [Fact] + public async Task ToolsList_On2026_07_28Session_IncludesResultTypeAndCacheHints() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ToolsList }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.Equal("complete", result["resultType"]!.GetValue()); + Assert.True(result.ContainsKey("ttlMs"), "ttlMs must be present on a 2026-07-28 tools/list result."); + Assert.True(result.ContainsKey("cacheScope"), "cacheScope must be present on a 2026-07-28 tools/list result."); + } + + [Fact] + public async Task ToolsCall_On2026_07_28Session_IncludesResultType() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Params = System.Text.Json.JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = "echo" }, McpJsonUtilities.DefaultOptions), + }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.Equal("complete", result["resultType"]!.GetValue()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 818bd2b..a41adfc 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -76,7 +76,7 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable return JsonNode.Parse(line!)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -96,12 +96,12 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var supportedVersions = result!["supportedVersions"]!.AsArray() .Select(n => n!.GetValue()) .ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions); - // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. + // Capabilities are mandatory in DiscoverResult; server identity is result metadata. Assert.NotNull(result["capabilities"]); - Assert.NotNull(result["serverInfo"]); - Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue()); + Assert.Null(result["serverInfo"]); + Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. @@ -126,6 +126,7 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var content = result!["content"]!.AsArray(); Assert.Single(content); Assert.Equal("echo:hello", content[0]!["text"]!.GetValue()); + Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); } [Fact] @@ -146,33 +147,35 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] - public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() + public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer() { - // Dual-era: a server defaulting to the 2026-07-28 protocol (ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion in McpServerOptions) must still - // accept the legacy initialize handshake from clients that don't speak the new protocol. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + // Dual-path: a default server must still accept the initialize handshake from clients that + // don't speak the 2026-07-28 per-request metadata path. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var response = await ReadAsync(); Assert.Equal(1, response["id"]!.GetValue()); var result = response["result"]; Assert.NotNull(result); Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue()); + Assert.Null(result["_meta"]?[MetaKeys.ServerInfo]); } [Fact] - public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-era servers must accept 2026-07-28 and legacy traffic on the same connection. The exact mix below - // is what a permissive client running against an unknown server would emit while probing. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + // Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails. + // This is the sequence a permissive client emits against an unknown server: the failed probe never + // establishes a version, so initialize is still available. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"); var discover = await ReadAsync(); - Assert.NotNull(discover["result"]); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); Assert.NotNull(init["result"]); Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); @@ -183,5 +186,29 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var call = await ReadAsync(); Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); } + + [Theory] + [InlineData("tools/list")] + [InlineData("server/discover")] + public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod) + { + // A modern request locks the session's protocol version. initialize declares nothing on stdio, so + // without this gate it would force the session back to a handshake version and make the modern + // _meta on in-flight requests opaque. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + var established = await ReadAsync(); + Assert.NotNull(established["result"]); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); + var init = await ReadAsync(); + Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue()); + + // The session is still modern: a request carrying full per-request metadata keeps working. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," + + July2026ProtocolMetaFragment() + "}}"); + var call = await ReadAsync(); + Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue()); + } } #endif diff --git a/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs new file mode 100644 index 0000000..6024e29 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the legacy resources/subscribe and resources/unsubscribe RPCs are +/// gated by protocol version. SEP-2575 (the 2026-07-28 revision) removes them in favor of +/// subscriptions/listen with resourceSubscriptions; servers must respond with +/// -32601 MethodNotFound. Initialize-handshake protocol versions still support the legacy +/// RPCs per the spec. +/// +public sealed class ResourceSubscriptionProtocolGatingTests : ClientServerTestBase +{ + public ResourceSubscriptionProtocolGatingTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithResources(); + } + + [McpServerResourceType] + private sealed class SubscribableResources + { + [McpServerResource(UriTemplate = "test://resource/{id}"), Description("A subscribable test resource")] + public static string GetResource(string id) => $"Resource content: {id}"; + } + + [Fact] + public async Task Subscribe_OnJuly2026ProtocolSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.SubscribeToResourceAsync( + new SubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + // The rejection must point callers at the SEP-2575 replacement. + Assert.Contains(RequestMethods.SubscriptionsListen, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Unsubscribe_OnJuly2026ProtocolSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.UnsubscribeFromResourceAsync( + new UnsubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + Assert.Contains(RequestMethods.SubscriptionsListen, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Subscribe_OnInitializeHandshakeSession_StillSucceeds() + { + // Default server config; client pinned to 2025-11-25. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + // Should complete without throwing on the initialize-handshake revision. + await client.SubscribeToResourceAsync( + new SubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken); + + await client.UnsubscribeFromResourceAsync( + new UnsubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs new file mode 100644 index 0000000..81e0619 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs @@ -0,0 +1,278 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the custom (SEP-2575, issue #1662). +/// A custom handler is a full replacement for the built-in subscriptions/listen handling: it owns the +/// stream, sends the acknowledgement itself, streams application-defined notifications, and receives no +/// automatic */list_changed fan-out. These tests run over the in-memory stream transport exercised by +/// . +/// +public class SubscriptionsListenHandlerTests : ClientServerTestBase +{ + private const string CustomResourceUri = "custom://event/1"; + + // Signalled after the custom handler has sent its acknowledgement and first notification and is waiting + // for cancellation. Lets cancellation tests wait until the handler is actually holding the stream open. + private readonly TaskCompletionSource _handlerHoldingStream = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // Signalled from the custom handler's finally block, proving it observed cancellation and cleaned up. + private readonly TaskCompletionSource _handlerCleanedUp = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public SubscriptionsListenHandlerTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // Register a tool so the server advertises tools.listChanged; this lets the replacement test below + // trigger a collection change and prove the built-in fan-out no longer runs for the custom handler. + mcpServerBuilder.WithTools(); + + mcpServerBuilder.WithSubscriptionsListenHandler(async (request, cancellationToken) => + { + var subscriptionId = request.JsonRpcRequest.Id; + + // SEP-2575 requires the acknowledgement to be the first message on the stream. The custom handler + // owns this: it echoes back the requested filters as granted and tags the ack with the id. + var ack = new JsonRpcNotification + { + Method = NotificationMethods.SubscriptionsAcknowledgedNotification, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams { Notifications = request.Params.Notifications }, + McpJsonUtilities.DefaultOptions), + }; + TagWithSubscriptionId(ack, subscriptionId); + await request.Server.SendMessageAsync(ack, cancellationToken); + + // Stream one application-defined notification the built-in handler could never produce on its own, + // proving the handler drives the stream. Tagged with the same subscription id. + var updated = new JsonRpcNotification + { + Method = NotificationMethods.ResourceUpdatedNotification, + Params = new JsonObject { ["uri"] = CustomResourceUri }, + }; + TagWithSubscriptionId(updated, subscriptionId); + await request.Server.SendMessageAsync(updated, cancellationToken); + + _handlerHoldingStream.TrySetResult(true); + + try + { + // Remain active for the subscription lifetime, exactly like the built-in handler, until the + // request-scoped token is cancelled (notifications/cancelled on stdio, client disconnect on HTTP). + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(true), cancelled); + await cancelled.Task; + } + finally + { + _handlerCleanedUp.TrySetResult(true); + } + + return new EmptyResult(); + }); + } + + [Fact] + public async Task CustomHandler_July2026_SendsAcknowledgementThenTaggedNotification() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + // Capture the acknowledgement and the streamed notification on separate channels. Both must arrive + // tagged with the subscription id. The test does not assert cross-notification arrival order because + // the client dispatches incoming messages concurrently (see McpSessionHandler.ProcessMessagesCoreAsync), + // so handler invocation order is not observable; acknowledgement-first is a server-side/wire guarantee. + var ackChannel = Channel.CreateUnbounded(); + var updatedChannel = Channel.CreateUnbounded(); + + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification, + (notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; }); + + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var listenTask = SendSubscriptionsListenAsync( + client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token); + + // The acknowledgement carries the subscription id. + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var subscriptionId = GetSubscriptionId(ack); + Assert.NotNull(subscriptionId); + + // The custom application notification is tagged with the same subscription id. + var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(subscriptionId, GetSubscriptionId(updated)); + Assert.Equal(CustomResourceUri, (updated.Params as JsonObject)?["uri"]?.GetValue()); + + await CancelSubscriptionAsync(listenCts, listenTask); + } + + [Fact] + public async Task CustomHandler_ReplacesBuiltIn_SuppressesAutomaticListChangedFanOut() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + var ackChannel = Channel.CreateUnbounded(); + var toolsChannel = Channel.CreateUnbounded(); + + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + // Request tools/list_changed. The built-in handler would fan these out; the custom handler replaces it + // and never tracks the subscription, so the SDK must deliver nothing automatically for this change. + var listenTask = SendSubscriptionsListenAsync( + client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token); + + // Wait until the custom handler is holding the stream open (ack + first notification already sent). + await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken); + await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + + // Mutate the tool collection. With the built-in handler this would deliver a tagged tools/list_changed; + // under the custom replacement handler it must not, because _activeSubscriptions is never populated. + // The list-changed fan-out iterates that (empty) set and completes synchronously during Add, so it + // buffers nothing to send. The ListToolsAsync round-trip then flushes the client read pipeline. + var serverOptions = ServiceProvider.GetRequiredService>().Value; + serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + await CancelSubscriptionAsync(listenCts, listenTask); + + // Completed-and-empty proves nothing was ever delivered, not merely "nothing buffered right now": + // WaitToReadAsync returns false only when the channel is both empty and completed. + toolsChannel.Writer.Complete(); + Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CustomHandler_PreJuly2026_IsRejectedWithMethodNotFound() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + var request = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams { Notifications = new SubscriptionsListenNotifications { ResourcesListChanged = true } }, + McpJsonUtilities.DefaultOptions), + }; + + var ex = await Assert.ThrowsAsync(() => + client.SendRequestAsync(request, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task CustomHandler_OnCancellation_ObservesTokenAndCleansUp() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + // Use an explicit request id so the test can address it in a notifications/cancelled message, + // which is the transport-level cancellation signal for a long-lived subscriptions/listen on stdio + // (an HTTP client would instead disconnect, which cancels the same request-scoped token). + var subscriptionId = new RequestId("listen-cancel-1"); + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var listenTask = SendSubscriptionsListenAsync( + client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token, subscriptionId); + + // Ensure the handler is actively holding the stream before cancelling. + await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Deterministically drive server-side cancellation by sending notifications/cancelled for the request + // id. The server routes this to the request-scoped token the handler is observing. + await client.SendMessageAsync(new JsonRpcNotification + { + Method = NotificationMethods.CancelledNotification, + Params = JsonSerializer.SerializeToNode( + new CancelledNotificationParams { RequestId = subscriptionId }, + McpJsonUtilities.DefaultOptions), + }, TestContext.Current.CancellationToken); + + // The handler observed the token: its cancellation registration ran and its finally block completed. + await _handlerCleanedUp.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // A cancelled request sends no response, so unblock the client side and confirm it observes cancellation. + await CancelSubscriptionAsync(listenCts, listenTask); + } + + private static Task SendSubscriptionsListenAsync( + McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken, RequestId requestId = default) + { + var request = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams { Notifications = notifications }, + McpJsonUtilities.DefaultOptions), + }; + + if (requestId.Id is not null) + { + request.Id = requestId; + } + + return client.SendRequestAsync(request, cancellationToken); + } + + private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask) + { + await listenCts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => listenTask); + } + + private static string? GetSubscriptionId(JsonRpcNotification notification) + => ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString(); + + private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId) + { + var paramsObject = notification.Params as JsonObject ?? new JsonObject(); + if (paramsObject["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + paramsObject["_meta"] = meta; + } + + meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch + { + string stringId => JsonValue.Create(stringId), + long longId => JsonValue.Create(longId), + _ => null, + }; + + notification.Params = paramsObject; + } + + private sealed class ListenTools + { + [McpServerTool, System.ComponentModel.Description("Echoes the input back to the caller.")] + public static string Echo([System.ComponentModel.Description("The message to echo.")] string message) => message; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs index d0dd2a7..d727e00 100644 --- a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -15,7 +15,7 @@ namespace ModelContextProtocol.Tests.Server; /// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an /// in-memory stream transport (the stdio-shaped path exercised by ). /// Validates that a client on the 2026-07-28 protocol receives only the change notifications it subscribed to, each tagged -/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. +/// with the subscription id, and that initialize-handshake sessions keep receiving the session-wide broadcast. /// public class SubscriptionsListenTests : ClientServerTestBase { @@ -104,11 +104,11 @@ public class SubscriptionsListenTests : ClientServerTestBase } [Fact] - public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() + public async Task InitializeHandshake_ListChanged_IsBroadcast_WithoutSubscription() { await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var toolsChannel = Channel.CreateUnbounded(); @@ -118,7 +118,7 @@ public class SubscriptionsListenTests : ClientServerTestBase var serverOptions = ServiceProvider.GetRequiredService>().Value; serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); - // Legacy sessions keep the session-wide broadcast and the notification carries no subscription id. + // Initialize-handshake sessions keep the session-wide broadcast and the notification carries no subscription id. var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); Assert.Null(GetSubscriptionId(notification)); } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs new file mode 100644 index 0000000..47d532d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs @@ -0,0 +1,332 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +public class TaskCallToolFilterCompositionTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) +{ + private readonly TaskCompletionSource _continueBackgroundExecution = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _executionCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _executionScopeDisposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _alternateFilterInvocationCount; + private int _filterInvocationCount; + private string? _matchedPrimitiveId; + private string? _alternateMatchedPrimitiveId; + private string? _throwingAlternateMatchedPrimitiveId; + private RequestContext? _alternateRequestContext; + private RequestContext? _ordinaryRequestContext; + private IServiceProvider? _alternateServicesBeforeNext; + private IServiceProvider? _alternateServicesAfterNext; + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddScoped(_ => new ScopedDependency(_executionScopeDisposed)); + + mcpServerBuilder.Services.Configure(options => + { +#pragma warning disable MCPEXP002 // exercises an alternate filter registered before Tasks + options.Filters.Request.CallToolWithAlternateFilters.Add(async (request, next, cancellationToken) => + { + if (request.Params?.Name == "task-filter-tool") + { + _alternateMatchedPrimitiveId = request.MatchedPrimitive?.Id; + _alternateRequestContext = request; + _alternateServicesBeforeNext = request.Services; + var result = await next(request, cancellationToken); + _alternateServicesAfterNext = request.Services; + return result; + } + + return await next(request, cancellationToken); + }); +#pragma warning restore MCPEXP002 + }); + + mcpServerBuilder + .WithTools() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }); + + mcpServerBuilder.Services.Configure(options => + { +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateFilters seam + options.Filters.Request.CallToolWithAlternateFilters.Add(async (request, next, cancellationToken) => + { + Interlocked.Increment(ref _alternateFilterInvocationCount); + + if (request.Params?.Name is "suppress-flow-direct-tool" or "suppress-flow-task-tool") + { + Task> continuation; + using (ExecutionContext.SuppressFlow()) + { + continuation = Task.Run( + async () => await next(request, cancellationToken).ConfigureAwait(false), + cancellationToken); + } + + return await continuation.ConfigureAwait(false); + } + + if (request.Params?.Name == "alternate-filter-exception-tool") + { + _throwingAlternateMatchedPrimitiveId = request.MatchedPrimitive?.Id; + throw new InvalidOperationException("Alternate filter failure."); + } + + if (request.Params?.Name == "alternate-short-circuit-tool") + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = "short-circuited" }], + }; + } + + if (request.Params?.Name == "replace-jsonrpc-request-tool") + { + request.JsonRpcRequest = new JsonRpcRequest + { + Id = request.JsonRpcRequest.Id, + Method = request.JsonRpcRequest.Method, + }; + } + + if (request.Params?.Name == "replace-request-context-tool") + { + var replacement = new RequestContext( + request.Server, + request.JsonRpcRequest, + request.Params) + { + Services = request.Services, + }; + return await next(replacement, cancellationToken); + } + + if (request.Params?.Name == "alternate-transform-result-tool") + { + _ = await next(request, cancellationToken); + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = "transformed" }], + }; + } + + if (request.Params?.Name == "task-filter-tool") + { + return await next(request, cancellationToken); + } + + _alternateMatchedPrimitiveId = request.MatchedPrimitive?.Id; + _alternateRequestContext = request; + _alternateServicesBeforeNext = request.Services; + var result = await next(request, cancellationToken); + _alternateServicesAfterNext = request.Services; + return result; + }); +#pragma warning restore MCPEXP002 + + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + if (request.Params?.Name != "task-filter-tool") + { + return await next(request, cancellationToken); + } + + Interlocked.Increment(ref _filterInvocationCount); + _matchedPrimitiveId = request.MatchedPrimitive?.Id; + _ordinaryRequestContext = request; + + try + { + await _continueBackgroundExecution.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + _ = request.Services!.GetRequiredService(); + var result = await next(request, cancellationToken); + _executionCompleted.TrySetResult(result); + return result; + } + catch (Exception exception) + { + _executionCompleted.TrySetException(exception); + throw; + } + }); + }); + } + + [Fact] + public async Task TaskBackedTool_RunsOrdinaryFilterOnce_InIndependentScope() + { + await using var client = await CreateMcpClientForServer(); + var cancellationToken = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "task-filter-tool" }, + cancellationToken); + + Assert.True(augmented.IsTask); + _continueBackgroundExecution.TrySetResult(true); + + var result = await _executionCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + Assert.Equal("task filter result", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal(1, _filterInvocationCount); + Assert.Equal("task-filter-tool", _matchedPrimitiveId); + Assert.Equal("task-filter-tool", _alternateMatchedPrimitiveId); + Assert.NotSame(_alternateRequestContext, _ordinaryRequestContext); + Assert.Same(_alternateServicesBeforeNext, _alternateServicesAfterNext); + Assert.True(await _executionScopeDisposed.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken)); + + var task = await client.GetTaskAsync(augmented.TaskCreated!.TaskId, cancellationToken); + Assert.IsType(task); + } + + [Fact] + public async Task AlternateFilterException_IsConvertedToCallToolError() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "alternate-filter-exception-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Equal( + "An error occurred invoking 'alternate-filter-exception-tool'.", + Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal("alternate-filter-exception-tool", _throwingAlternateMatchedPrimitiveId); + } + + [Fact] + public async Task AlternateFilterShortCircuit_LogsCompletionOnce() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "alternate-short-circuit-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("short-circuited", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Single( + MockLoggerProvider.LogMessages, + message => message.Message == "\"alternate-short-circuit-tool\" completed. IsError = False."); + } + + [Fact] + public async Task AlternateInvocationFilter_RunsForEachRequest() + { + await using var client = await CreateMcpClientForServer(); + + await client.CallToolAsync( + "alternate-short-circuit-tool", + cancellationToken: TestContext.Current.CancellationToken); + await client.CallToolAsync( + "alternate-short-circuit-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, _alternateFilterInvocationCount); + } + + [Fact] + public async Task AlternateInvocationFilter_CanSuppressExecutionContextForDirectCall() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "suppress-flow-direct-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("direct succeeded", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task AlternateInvocationFilter_CanSuppressExecutionContextForTaskBackedCall() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "suppress-flow-task-tool" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("task succeeded", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task AlternateFilter_CanReplaceJsonRpcRequest() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "replace-jsonrpc-request-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("replacement succeeded", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task AlternateFilter_CanReplaceRequestContext() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "replace-request-context-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("context replacement succeeded", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task AlternateFilterTransformedResult_LogsFinalResult() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "alternate-transform-result-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Equal("transformed", Assert.IsType(Assert.Single(result.Content)).Text); + var completionLog = Assert.Single( + MockLoggerProvider.LogMessages, + message => message.Message.StartsWith("\"alternate-transform-result-tool\" completed.", StringComparison.Ordinal)); + Assert.Equal("\"alternate-transform-result-tool\" completed. IsError = True.", completionLog.Message); + } + + private sealed class ScopedDependency(TaskCompletionSource disposed) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + disposed.TrySetResult(true); + return default; + } + } + + [McpServerToolType] + private sealed class TaskFilterTools + { + [McpServerTool(Name = "task-filter-tool")] + public static string Invoke(ScopedDependency dependency) => "task filter result"; + + [McpServerTool(Name = "alternate-filter-exception-tool")] + public static string ThrowingAlternateFilterTarget() => "unreachable"; + + [McpServerTool(Name = "alternate-short-circuit-tool")] + public static string ShortCircuitedAlternateFilterTarget() => "unreachable"; + + [McpServerTool(Name = "replace-jsonrpc-request-tool")] + public static string ReplaceJsonRpcRequestTarget() => "replacement succeeded"; + + [McpServerTool(Name = "replace-request-context-tool")] + public static string ReplaceRequestContextTarget() => "context replacement succeeded"; + + [McpServerTool(Name = "alternate-transform-result-tool")] + public static string AlternateTransformResultTarget() => "original"; + + [McpServerTool(Name = "suppress-flow-direct-tool")] + public static string SuppressFlowDirect() => "direct succeeded"; + + [McpServerTool(Name = "suppress-flow-task-tool")] + public static string SuppressFlowTask() => "task succeeded"; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index 0e86933..e751bdc 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -29,16 +30,13 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, DefaultTimeToLive = TimeSpan.FromSeconds(5), - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (CancellationToken ct) => { _toolStarted.TrySetResult(true); @@ -66,7 +64,7 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -93,7 +91,7 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -130,15 +128,12 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string marker, CancellationToken ct) => { TaskCompletionSource startTcs; @@ -219,14 +214,14 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase RegisterMarker("task2"); // Start two tasks - var result1 = await client.CallToolRawAsync( + var result1 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", Arguments = CreateMarkerArgs("task1"), }, ct); - var result2 = await client.CallToolRawAsync( + var result2 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", @@ -273,15 +268,12 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([ + }) + .WithTools([ McpServerTool.Create( async (CancellationToken ct) => { @@ -316,7 +308,7 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "quick-tool" }, ct); Assert.True(augmented.IsTask); @@ -346,7 +338,7 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); Assert.True(augmented.IsTask); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 8cd2f78..621acfd 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -1,17 +1,23 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// -/// Verifies the runtime validation that fires when a handler opts into task-augmented execution -/// (returns a ) without the server having any tasks/get -/// handler registered. +/// Verifies behavior when a handler returns a alternate without +/// the server having any tasks/get handler registered. After the ResultOrAlternate +/// generalization, the Core server no longer guards against this -- the extension is responsible +/// for ensuring lifecycle handlers are registered. /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -19,41 +25,40 @@ public class TaskHandlerConfigurationValidationTests : ClientServerTestBase #endif } +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); - // Intentionally configure a task-augmented handler without TaskStore or any of the - // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "orphan-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + // Configure a task-augmented handler without TaskStore or any of the + // task lifecycle request handlers. + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>( + ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = "orphan-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } +#pragma warning restore MCPEXP002 [Fact] - public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime() + public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError() { + // The Core guard that previously threw InvalidOperationException at request time when + // a CallToolWithAlternateHandler returned a CreateTaskResult without tasks/get being + // registered has been removed. The extension is now responsible for that guarantee. + // This test verifies the server starts and connects successfully with such configuration. await using var client = await CreateMcpClientForServer(); - // Client surfaces a generic protocol error (the server intentionally redacts the message - // on the wire), so use the base McpException type and confirm via server-side logs that - // the originating exception was the misconfiguration guard. - await Assert.ThrowsAnyAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "anything" }, - cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains(MockLoggerProvider.LogMessages, log => - log.Exception is InvalidOperationException ioe && - ioe.Message.Contains("tasks/get", StringComparison.Ordinal) && - ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal)); + // If we get here, the server accepted the handler config without error. + Assert.NotNull(client); } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index 1d17be2..c2552ab 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -1,10 +1,13 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Tests.Server; @@ -29,49 +32,66 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); // CallTool always returns a CreateTaskResult with a tiny poll interval so the // test exercises the threshold in well under a second. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { var taskId = Guid.NewGuid().ToString("N"); - return new ValueTask>(new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - ResultType = "task", - }); + return new ValueTask>( + ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult)); }; // GetTask always reports InputRequired with NO outstanding input requests — the // misbehaving-server condition the stuck-detector exists to break out of. - options.Handlers.GetTaskHandler = (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - Interlocked.Increment(ref _pollCount); - - return new ValueTask(new InputRequiredTaskResult + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - TaskId = context.Params!.TaskId, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - InputRequests = new Dictionary(), - ResultType = "complete", - }); - }; + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); - // CancelTask must succeed since the client issues a best-effort cancel when it - // gives up; otherwise the cancel failure would mask the real exception. - options.Handlers.CancelTaskHandler = (context, cancellationToken) => - new ValueTask(new CancelTaskResult { ResultType = "complete" }); + Interlocked.Increment(ref _pollCount); - // UpdateTask is never invoked in this scenario (there are no input requests to resolve) - // but must be present so the handler-configuration validation passes. - options.Handlers.UpdateTaskHandler = (context, cancellationToken) => - new ValueTask(new UpdateTaskResult { ResultType = "complete" }); + return new ValueTask(JsonSerializer.SerializeToNode(new InputRequiredTaskResult + { + TaskId = requestParams.TaskId, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + InputRequests = new Dictionary(), + ResultType = "complete", + }, McpTasksJsonContext.Default.Options)); + }, + }); + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); }); } @@ -82,7 +102,7 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, cancellationToken: ct)); Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message); Assert.Contains("consecutive polls", ex.Message); @@ -93,18 +113,15 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase [Fact] public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() { - // Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync: + // Verifies CallToolWithPollingAsync plumbs the explicit threshold into PollTaskToCompletionAsync: // a smaller configured threshold is surfaced verbatim in the McpException message. const int CustomThreshold = 3; - await using var client = await CreateMcpClientForServer(new McpClientOptions - { - MaxConsecutiveStuckPolls = CustomThreshold, - }); + await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, maxConsecutiveStuckPolls: CustomThreshold, cancellationToken: ct)); // The message embeds the configured threshold, which is the strongest signal that the // option value (not the 60-default constant) is what governed the loop. @@ -112,19 +129,4 @@ public class TaskPollStuckDetectorTests : ClientServerTestBase Assert.Equal(CustomThreshold, _pollCount); } - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(int.MinValue)] - public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value) - { - var options = new McpClientOptions(); - Assert.Throws(() => options.MaxConsecutiveStuckPolls = value); - } - - [Fact] - public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60() - { - Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls); - } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 7f97905..af4002e 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -6,8 +7,6 @@ using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 - namespace ModelContextProtocol.Tests.Server; /// @@ -32,15 +31,12 @@ public class TaskProtocolGatingTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -68,7 +64,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase { [ExtensionsKey] = new JsonObject { - [McpExtensions.Tasks] = new JsonObject(), + [TasksProtocol.ExtensionId] = new JsonObject(), }, }, }; @@ -115,7 +111,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -127,15 +123,14 @@ public class TaskProtocolGatingTests : ClientServerTestBase } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy - // request. The server must still refuse to create a task because the per-request protocol - // version is not the 2026-07-28 protocol. - var result = await client.CallToolRawAsync( + // A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The + // metadata is tolerated, but it cannot opt the legacy session into modern-only tasks. + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -157,10 +152,9 @@ public class TaskProtocolGatingTests : ClientServerTestBase // gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound. var request = new JsonRpcRequest { - Method = RequestMethods.TasksGet, + Method = TasksProtocol.MethodTasksGet, Params = JsonSerializer.SerializeToNode( - new GetTaskRequestParams { TaskId = "some-task-id" }, - McpJsonUtilities.DefaultOptions), + new GetTaskRequestParams { TaskId = "some-task-id" }, McpTasksJsonContext.Default.Options), }; var ex = await Assert.ThrowsAsync(async () => @@ -176,7 +170,7 @@ public class TaskProtocolGatingTests : ClientServerTestBase await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 303d16e..144b084 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -1,22 +1,25 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; using System.Text.Json; - -#pragma warning disable MCPEXP001 +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that when both and -/// are configured and the handler returns +/// Verifies that when both and +/// are configured and the handler returns /// (IsTask = true), the store's pre-created task is failed with a /// clear error rather than being orphaned in forever. /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -26,21 +29,25 @@ public class TaskStoreOrphanedTaskTests : ClientServerTestBase protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { + mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore()); + mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); - options.TaskStore = new InMemoryMcpTaskStore(); - // Returning IsTask = true here while TaskStore is also configured is the + // Returning IsTask = true here while the tasks extension is also configured is the // misconfiguration the server must guard against. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "user-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>( + ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = "user-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } @@ -51,7 +58,7 @@ public class TaskStoreOrphanedTaskTests : ClientServerTestBase var ct = TestContext.Current.CancellationToken; // The store's task is created synchronously and its taskId returned to the client. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "anything" }, ct); Assert.True(augmented.IsTask); @@ -76,7 +83,8 @@ public class TaskStoreOrphanedTaskTests : ClientServerTestBase var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); - Assert.Contains(nameof(McpServerOptions.TaskStore), message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains(nameof(IMcpTaskStore), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } +#pragma warning restore MCPEXP002 } diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index 768ebf7..c463833 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -1,5 +1,8 @@ using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; +using Microsoft.Extensions.Logging; +using System.IO.Pipelines; using System.Net; namespace ModelContextProtocol.Tests.Transport; @@ -42,12 +45,70 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp }; await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - + // The auto-detecting transport should be returned Assert.NotNull(session); } - [Fact] + [Fact] + public async Task AutoDetectMode_WhenBothTransportsFail_PreservesStreamableHttpException() + { + // Regression test: when Streamable HTTP POST fails (e.g. 403) and the SSE GET + // fallback also fails (e.g. 405), the original Streamable HTTP error should + // be preserved. The SSE connection failure is available as its inner exception. + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + // Streamable HTTP POST fails with 403 (auth error) + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.Forbidden, + Content = new StringContent("Forbidden") + }); + } + + if (request.Method == HttpMethod.Get) + { + // SSE GET fallback fails with 405 + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.MethodNotAllowed, + Content = new StringContent("Method Not Allowed") + }); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + }; + + // ConnectAsync for AutoDetect mode just creates the transport without sending + // any HTTP request. The auto-detection is triggered lazily by the first + // SendMessageAsync call, which happens inside McpClient.CreateAsync when it + // sends the JSON-RPC "initialize" message. + var ex = await Assert.ThrowsAsync( + () => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("403", ex.Message); + Assert.IsType(ex.InnerException); + Assert.Contains("405", ex.InnerException.Message); + Assert.Equal(HttpStatusCode.Forbidden, ex.Data["ModelContextProtocol.HttpStatusCode"]); +#if NET + Assert.Equal(HttpStatusCode.Forbidden, ex.StatusCode); +#endif + } + + [Fact] public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails() { var options = new HttpClientTransportOptions @@ -102,8 +163,497 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp }; await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - + // The auto-detecting transport should be returned Assert.NotNull(session); } -} \ No newline at end of file + + [Fact] + public async Task AutoDetectMode_WhenProvisionalSseFails_LeavesSharedMessageChannelOpen() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect shared channel test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var streamableHttpPostCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + if (request.Method == HttpMethod.Post && ++streamableHttpPostCount == 1) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Invalid session ID"), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}", + System.Text.Encoding.UTF8, + "application/json"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(2) }, + TestContext.Current.CancellationToken); + + var response = await session.MessageReader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(new RequestId(2), Assert.IsType(response).Id); + } + + [Fact] + public async Task AutoDetectMode_WhenAdoptedSseDisconnects_CompletesSharedMessageChannel() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect adopted SSE test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var ssePipe = new Pipe(); + var postCount = 0; + + await ssePipe.Writer.WriteAsync( + System.Text.Encoding.UTF8.GetBytes("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"), + TestContext.Current.CancellationToken); + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + var content = new StreamContent(ssePipe.Reader.AsStream()); + content.Headers.ContentType = new("text/event-stream"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + + if (request.Method == HttpMethod.Post && ++postCount == 1) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Streamable HTTP not supported"), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + await ssePipe.Writer.CompleteAsync(); + + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken)); + Assert.IsType(exception.Details); + } + + // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1526 + // When Streamable HTTP returns 415 (e.g. wrong Content-Type) and the SSE fallback also fails + // (e.g. a Streamable-HTTP-only server returns 405 to the GET), the surfaced exception must + // preserve the original Streamable HTTP error rather than dropping it on the floor. + [Fact] + public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturns415AndSseFallbackFails() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + const string streamableHttpBody = "Content-Type must be 'application/json'"; + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + // Streamable HTTP fails with 415 - this is the real server diagnostic the user needs to see. + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.UnsupportedMediaType, + Content = new StringContent(streamableHttpBody), + }); + } + + if (request.Method == HttpMethod.Get) + { + // Streamable-HTTP-only server: SSE GET is rejected with 405. Without the fix this is the + // ONLY error the user ever sees, masking the real 415 diagnostic above. + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.MethodNotAllowed, + Content = new StringContent("Method not allowed"), + }); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + }; + + // ConnectAsync only constructs the AutoDetect transport; the probe runs on the first SendMessageAsync. + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAnyAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + // Walk the exception chain and assert the original 415 (and its body) is somewhere in it. + // We don't pin the exact exception type so this stays robust to future error-shape tweaks, + // but the underlying status code and server body must reach the caller. + var combined = Flatten(ex); + Assert.Contains("415", combined); + Assert.Contains(streamableHttpBody, combined); + + static string Flatten(Exception e) + { + var sb = new System.Text.StringBuilder(); + void Walk(Exception? cur) + { + while (cur is not null) + { + sb.Append(cur.GetType().FullName).Append(": ").AppendLine(cur.Message); + if (cur is AggregateException agg) + { + foreach (var inner in agg.InnerExceptions) + { + Walk(inner); + } + return; + } + cur = cur.InnerException; + } + } + Walk(e); + return sb.ToString(); + } + } + + // When Streamable HTTP fails (non-JSON-RPC) and the SSE fallback also fails, the surfaced exception must remain + // an HttpRequestException carrying the original Streamable HTTP status/body, with the SSE failure as its inner. + [Fact] + public async Task AutoDetectMode_SurfacesStreamableHttpError_WithSseAsInner_WhenSseFallbackFails() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + const string streamableHttpBody = "Content-Type must be 'application/json'"; + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.UnsupportedMediaType, + Content = new StringContent(streamableHttpBody), + }); + } + + if (request.Method == HttpMethod.Get) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.MethodNotAllowed, + Content = new StringContent("Method not allowed"), + }); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAnyAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + // The surfaced exception is the original Streamable HTTP error (the real server diagnostic), not the SSE 405. + var httpEx = Assert.IsType(ex); + Assert.Contains("415", httpEx.Message); + Assert.Contains(streamableHttpBody, httpEx.Message); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.Data["ModelContextProtocol.HttpStatusCode"]); +#if NET + Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.StatusCode); +#endif + + // The SSE fallback failure (the 405 from the GET) is preserved as the inner exception, not dropped. + Assert.NotNull(httpEx.InnerException); + Assert.Contains("405", httpEx.InnerException.ToString()); + } + + // Cancellation during the AutoDetect probe must surface as an OperationCanceledException, not be masked or + // wrapped in the dual-failure AggregateException. + [Fact] + public async Task AutoDetectMode_SurfacesCancellation_WithoutWrapping() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.UnsupportedMediaType, + Content = new StringContent("Content-Type must be 'application/json'"), + }); + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + cts.Token)); + + Assert.IsNotType(ex); + Assert.IsAssignableFrom(ex); + } + + // The dual-failure case must be visible in logs at Warning level, even when callers swallow the exception. + [Fact] + public async Task AutoDetectMode_LogsWarning_WhenSseFallbackFailsAfterStreamableHttp() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.UnsupportedMediaType, + Content = new StringContent("Content-Type must be 'application/json'"), + }); + } + + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.MethodNotAllowed, + Content = new StringContent("Method not allowed"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAnyAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Contains( + MockLoggerProvider.LogMessages, + m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SSE fallback failed")); + } + + // A 400/404 on the SEP-2575 server/discover probe is protocol negotiation, not transport detection: + // a pre-SEP-2575 server rejects the session-less POST whether it speaks Streamable HTTP or SSE, and + // McpClientImpl.ConnectAsync reads exactly those two statuses as "initialize-handshake server" and + // retries with initialize. The SSE GET can therefore only waste a round trip here. + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadRequest)] + public async Task AutoDetectMode_SkipsSseFallback_WhenDiscoverProbeIsRejected(HttpStatusCode statusCode) + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var getCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + getCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent("Not Found"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, getCount); + Assert.Equal(statusCode, ex.Data["ModelContextProtocol.HttpStatusCode"]); + Assert.DoesNotContain( + MockLoggerProvider.LogMessages, + m => m.Message.Contains("falling back to SSE transport")); + } + + // Skipping the SSE attempt on the discover probe must not strand an SSE-only server: the initialize + // retry that McpClientImpl.ConnectAsync issues next goes through this same transport and still falls + // back to SSE, so such a server is reached one POST later rather than not at all. + [Fact] + public async Task AutoDetectMode_StillFallsBackToSse_WhenInitializeFollowsRejectedDiscoverProbe() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe then initialize test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var ssePipe = new Pipe(); + var sseEndpointPostCount = 0; + + await ssePipe.Writer.WriteAsync( + System.Text.Encoding.UTF8.GetBytes("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"), + TestContext.Current.CancellationToken); + await ssePipe.Writer.FlushAsync(TestContext.Current.CancellationToken); + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + var content = new StreamContent(ssePipe.Reader.AsStream()); + content.Headers.ContentType = new("text/event-stream"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + + if (request.RequestUri?.AbsolutePath == "/sse-endpoint") + { + sseEndpointPostCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Mcp-Session-Id required"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(2) }, + TestContext.Current.CancellationToken); + + Assert.Equal(1, sseEndpointPostCount); + + await ssePipe.Writer.CompleteAsync(); + } + + // The skip is scoped to the two statuses ConnectAsync acts on. Any other failure on the discover probe + // keeps the original fallback, because it is not evidence that an initialize retry is coming. + [Fact] + public async Task AutoDetectMode_FallsBackToSse_WhenDiscoverProbeFailsWithUnrelatedStatus() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe 415 test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var getCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + getCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.UnsupportedMediaType) + { + Content = new StringContent("Content-Type must be 'application/json'"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(1, getCount); + } +} diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs index 2627bc5..a203797 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs @@ -147,6 +147,40 @@ public class HttpClientTransportTests : LoggedTest Assert.True(true); } + [Fact] + public async Task StreamableHttp_NotificationWithEmptyAcceptedJsonResponse_DoesNotLogParseFailure() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = request => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("http://localhost:8080/mcp", request.RequestUri?.AbsoluteUri); + + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.Accepted, + Content = new StringContent("", Encoding.UTF8, "application/json"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcNotification { Method = "notifications/initialized" }, + TestContext.Current.CancellationToken); + + Assert.DoesNotContain(MockLoggerProvider.LogMessages, log => + log.Message.Contains("transport message parsing failed", StringComparison.Ordinal)); + } + [Fact] public async Task SendMessageAsync_Throws_HttpRequestException_With_ResponseBody_On_ErrorStatusCode() { @@ -377,4 +411,179 @@ public class HttpClientTransportTests : LoggedTest // Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections. Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount); } -} \ No newline at end of file + + [Fact] + public async Task StreamableHttp_DisablingStandaloneGetStream_DoesNotOpenGetSseAfterInitialize() + { + var getRequestReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + EnableStandaloneGetStream = false, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""", + Encoding.UTF8, + "application/json"), + }; + response.Headers.Add("Mcp-Session-Id", "test-session"); + return Task.FromResult(response); + } + + if (request.Method == HttpMethod.Get) + { + getRequestReceived.TrySetResult(true); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + Assert.False(getRequestReceived.Task.IsCompleted); + } + + [Fact] + public async Task StreamableHttp_DisablingStandaloneGetStream_DoesNotOpenGetSseForKnownSessionId() + { + var getRequestReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + KnownSessionId = "test-session", + EnableStandaloneGetStream = false, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Get) + { + getRequestReceived.TrySetResult(true); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + Assert.False(getRequestReceived.Task.IsCompleted); + } + + [Fact] + public async Task AutoDetect_DisablingStandaloneGetStream_DisposeCompletesWithHttpDetails() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.AutoDetect, + EnableStandaloneGetStream = false, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""", + Encoding.UTF8, + "application/json"), + }; + response.Headers.Add("Mcp-Session-Id", "test-session"); + return Task.FromResult(response); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken).WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken); + Assert.True(session.MessageReader.TryRead(out var initializeResponse)); + Assert.IsType(initializeResponse); + + await session.DisposeAsync().AsTask().WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken); + + Assert.True(session.MessageReader.Completion.IsCompleted); + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion); + Assert.IsType(exception.Details); + } + + [Fact] + public async Task StreamableHttp_DisablingStandaloneGetStream_StillProcessesPostSseResponses() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + EnableStandaloneGetStream = false, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + "event: message\r\n" + + """data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""" + + "\r\n\r\n", + Encoding.UTF8, + "text/event-stream"), + }; + response.Headers.Add("Mcp-Session-Id", "test-session"); + return Task.FromResult(response); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + Assert.Equal("test-session", session.SessionId); + } +} diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 8b78762..60ce9cf 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -152,8 +152,12 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log { Assert.Skip("mono runtime does not handle arguments ending with backslash correctly."); } - + + const string OutputPrefix = "CLI_ARG:"; + var capturedArgument = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); string cliArgument = $"--cli-arg={cliArgumentValue}"; + string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); + string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); StdioClientTransportOptions options = new() { @@ -161,30 +165,39 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { (true, _) => "mono", - (_, true) => "TestServer.exe", + (_, true) => testServerExecutable, _ => "dotnet", }, Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { - (true, _) => ["TestServer.exe", cliArgument], - (_, true) => [cliArgument], - _ => ["TestServer.dll", cliArgument], + (true, _) => [testServerExecutable, "--echo-cli-arg-and-exit", cliArgument], + (_, true) => ["--echo-cli-arg-and-exit", cliArgument], + _ => [testServerDll, "--echo-cli-arg-and-exit", cliArgument], + }, + StandardErrorLines = line => + { + if (line.StartsWith(OutputPrefix, StringComparison.Ordinal)) + { + capturedArgument.TrySetResult(line[OutputPrefix.Length..]); + } }, }; var transport = new StdioClientTransport(options, LoggerFactory); + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - // Act: Create client (handshake) and list tools to ensure full round trip works with the argument present. - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + string serializedArgument = await capturedArgument.Task.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken); + JsonElement parsedArgument = JsonElement.Parse(serializedArgument); + Assert.Equal(cliArgumentValue ?? "", parsedArgument.GetString()); - // Assert - Assert.NotNull(tools); - Assert.NotEmpty(tools); - - var result = await client.CallToolAsync("echoCliArg", cancellationToken: TestContext.Current.CancellationToken); - var content = Assert.IsType(Assert.Single(result.Content)); - Assert.Equal(cliArgumentValue ?? "", content.Text); + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken)); + var completionDetails = Assert.IsType(exception.Details); + Assert.Equal(0, completionDetails.ExitCode); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs index e472696..4247255 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -278,6 +278,55 @@ public class StdioServerTransportTests : LoggedTest Assert.Equal("44", ((JsonRpcRequest)readMessage).Id.ToString()); } + [Fact] + public async Task ReadMessagesAsync_Should_Respond_With_ParseError_For_Request_Exceeding_MaxDepth() + { + // Build a ping request whose params nest more deeply than System.Text.Json's default + // reader MaxDepth of 64, which is what makes full deserialization throw. The request still + // carries an id, so the transport should reply with a JSON-RPC parse error for that id rather + // than dropping the request and leaving the caller pending. + var nested = new StringBuilder(); + const int depth = 100; + for (int i = 0; i < depth; i++) + { + nested.Append("{\"p").Append(i).Append("\":"); + } + nested.Append("{\"leaf\":true}"); + nested.Append('}', depth); + + var requestLine = $"{{\"jsonrpc\":\"2.0\",\"id\":900100,\"method\":\"ping\",\"params\":{nested}}}"; + + Pipe inputPipe = new(); + Pipe outputPipe = new(); + using var input = inputPipe.Reader.AsStream(); + using var output = outputPipe.Writer.AsStream(); + + await using var transport = new StreamServerTransport( + input, + output, + loggerFactory: LoggerFactory); + + await inputPipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"{requestLine}\n"), TestContext.Current.CancellationToken); + + // Read the single response line the transport writes back to the output stream. + using var responseReader = new StreamReader(outputPipe.Reader.AsStream(), Encoding.UTF8); + var responseLine = await responseReader.ReadLineAsync( +#if NET + TestContext.Current.CancellationToken +#endif + ); + + Assert.NotNull(responseLine); + + var response = JsonSerializer.Deserialize(responseLine!, McpJsonUtilities.DefaultOptions); + var error = Assert.IsType(response); + Assert.Equal("900100", error.Id.ToString()); + Assert.Equal((int)McpErrorCode.ParseError, error.Error.Code); + + // The transport should still be reading further messages after recovering from the bad one. + Assert.True(transport.IsConnected); + } + [Fact] public async Task ReadMessagesAsync_Should_Log_Received_At_Trace_Level() {