diff --git a/.github/agents/release-manager.agent.md b/.github/agents/release-manager.agent.md deleted file mode 100644 index 50f61fb..0000000 --- a/.github/agents/release-manager.agent.md +++ /dev/null @@ -1,254 +0,0 @@ ---- -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 deleted file mode 100644 index bcbdf35..0000000 --- a/.github/agents/release-manager/references/delegation.md +++ /dev/null @@ -1,166 +0,0 @@ -# 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 deleted file mode 100644 index c5c258c..0000000 --- a/.github/agents/release-manager/references/monitoring.md +++ /dev/null @@ -1,170 +0,0 @@ -# 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 deleted file mode 100644 index 07f61ff..0000000 --- a/.github/agents/release-manager/references/session-tracking.md +++ /dev/null @@ -1,224 +0,0 @@ -# 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 deleted file mode 100644 index 966bbe1..0000000 --- a/.github/agents/release-manager/references/summary-template.md +++ /dev/null @@ -1,108 +0,0 @@ -# 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 a8bb2d9..1e48e16 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,25 +2,6 @@ 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 d7418b0..5c973fc 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -2,12 +2,6 @@ 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 @@ -37,19 +31,8 @@ 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. Verify the release +## 4. Monitor the Release workflow -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. +- 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) diff --git a/.github/skills/breaking-changes/references/classification.md b/.github/skills/breaking-changes/references/classification.md index 9c34c37..e588972 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 'This C# SDK extensibility API 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 'Subclassing McpClient and McpServer 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 1127aa2..e789156 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` — 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. +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. ### 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 3d14978..f1f5f6d 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.md) 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.html) for breaking-change policy. ### Branch context -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 "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 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 5e66a16..29bc583 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -18,27 +18,6 @@ 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: @@ -61,21 +40,9 @@ 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` — 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. +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. 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 @@ -131,7 +98,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, 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.** + - 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. 3. Build the solution to verify the version change compiles: `dotnet build` This step creates local changes only — nothing is committed or pushed yet. @@ -142,19 +109,11 @@ 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 `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. +3. If there are unexpected compatibility breaks: - 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 — 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 + - 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 ### Step 8: Generate API Diff Report @@ -195,63 +154,19 @@ 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, using the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). +2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link. 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) — **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. + - Issue reporters (cite resolving PRs) - 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: @@ -276,7 +191,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 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. +7. **API Compatibility results** — the ApiCompat output from Step 7 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 @@ -284,11 +199,6 @@ 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 @@ -303,22 +213,12 @@ 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, 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 +- **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 from release notes entirely - **Single breaking change**: use the same numbered format as multiple @@ -327,21 +227,12 @@ 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**: 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. +- **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 - **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 @@ -357,7 +248,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/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break @@ -412,14 +303,14 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MA 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. 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). +Omit empty sections. The preamble is **always required** — it is not inside a section heading. ```markdown [Preamble — REQUIRED. Summarize the release theme.] ## Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/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 78d1d13..945df1a 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 -{baseline} +1.0.0 ``` -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. 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: +1. **Pack the SDK packages** to trigger validation: ```sh - dotnet pack src/{project}/{project}.csproj + dotnet pack src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj + dotnet pack src/ModelContextProtocol/ModelContextProtocol.csproj + dotnet pack src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj ``` Or pack all at once: ```sh @@ -28,96 +28,17 @@ Read the current values rather than assuming them, and check whether any individ 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 — 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. + - **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory. ### 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 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. +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. ```xml @@ -132,28 +53,7 @@ Do not use a tracked file as a template for what entries should look like — it ``` -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. +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). ### Common Diagnostic IDs @@ -291,37 +191,14 @@ _or_ [Diff or table of changes] ``` -### In the User Summary (Step 12) +### In the User Summary (Step 11) -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: +Present a condensed version for the user review: -| 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: +- **ApiCompat**: pass/fail with count of issues and suppressions per package +- **ApiDiff**: count of additions, removals, and changes per package ``` -API Compatibility: ✅ All {n} packages pass against v{baseline} ({n} stale suppressions removed from {package}) +API Compatibility: ✅ All 3 packages pass (2 existing suppressions in Core) 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 55d2570..8445667 100644 --- a/.github/skills/prepare-release/references/categorization.md +++ b/.github/skills/prepare-release/references/categorization.md @@ -11,30 +11,14 @@ 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, guidance, or examples. Examples: +PRs whose **sole purpose** is documentation. 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 -**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. +**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. ### Repository Infrastructure Updates PRs that maintain the development environment but don't affect the shipped product or test coverage. Examples: @@ -130,6 +114,3 @@ 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 be4d19b..173bb20 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -16,19 +16,6 @@ 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: @@ -44,7 +31,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` — 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). +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). 3. Identify the full commit range: previous release tag → merge commit. ### Step 3: Check for Additional PRs @@ -80,9 +67,7 @@ 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, 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. +4. **Update acknowledgements** to include contributors from new PRs. ### Step 5: Review README and Validate Code Samples @@ -96,24 +81,7 @@ 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. - -**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. +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. **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`. @@ -134,7 +102,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. That link must use the `v{MAJOR}` slug for the version being released. +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. Extract the draft preamble from the prepare-release PR description and present it alongside a freshly drafted alternative (accounting for any new PRs). @@ -155,65 +123,11 @@ 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-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. +- Create with `gh release create --draft {tag} --target {merge-commit-branch}` (always `--draft`), using the prerelease tag verbatim when present - **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 @@ -225,23 +139,18 @@ unsaved draft looks identical to a published release from the browser. - **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. 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). +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. ```markdown [Preamble — REQUIRED. Summarize the release theme.] ## Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/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 888f3c5..467dbb3 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, and must use the `v{MAJOR}` slug for the released version — see [release-branches.md](../../shared-resources/release-branches.md#versioning-documentation-links). +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. 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,25 +47,8 @@ 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 298776b..4775677 100644 --- a/.github/skills/shared-resources/release-branches.md +++ b/.github/skills/shared-resources/release-branches.md @@ -25,61 +25,12 @@ Official NuGet.org publishes happen only when a GitHub Release is created from a ## Previous-release tag lookup -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. +- 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. 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. -## 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. +## Work-branch naming 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 deleted file mode 100644 index ad54776..0000000 --- a/.github/skills/verify-release/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -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 86e48a2..faab32f 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: 🔧 Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: | 10.0.x 9.0.x - name: 🔧 Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index e328f2b..4852801 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: | 10.0.x @@ -24,7 +24,7 @@ jobs: pattern: testresults-* - name: Combine coverage reports - uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # 5.5.11 + uses: danielpalme/ReportGenerator-GitHub-Action@7ae927204961589fcb0b0be245c51fbbc87cbca2 # 5.5.5 with: reports: "**/*.cobertura.xml" targetdir: "${{ github.workspace }}/report" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bed505d..f072844 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v6 # 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 580ac1c..6d0ed95 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@v5.2.0 with: global-json-file: global.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 458e3fc..eba0c0b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,28 +1,9 @@ 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: @@ -30,12 +11,11 @@ permissions: pages: write id-token: write # Required for actions/deploy-pages -# 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. +# 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. concurrency: group: "pages" - cancel-in-progress: true + cancel-in-progress: false jobs: publish-docs: @@ -46,122 +26,23 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - 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: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: .NET Setup - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: | 10.0.x 9.0.x - - 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: Generate documentation + run: make generate-docs - name: Upload Pages artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: - path: 'combined' + path: 'artifacts/_site' - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 1dbf83b..dd907de 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - 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 4c96a86..eca7a31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,12 +56,12 @@ jobs: steps: - name: Clone the repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: | 10.0.x @@ -114,10 +114,10 @@ jobs: packages: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: 10.0.x @@ -149,7 +149,7 @@ jobs: packages: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -169,10 +169,10 @@ jobs: permissions: { } steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: 10.0.x diff --git a/Directory.Build.props b/Directory.Build.props index 8bf8354..f5cdd3a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,19 +11,30 @@ + + + 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 deleted file mode 100644 index d545f8b..0000000 --- a/Directory.Build.targets +++ /dev/null @@ -1,8 +0,0 @@ - - - $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestResults', '$(Configuration)')) - $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'packages', '$(Configuration)')) - $(ArtifactsPackagesDir) - $(ArtifactsTestResultsDir) - - diff --git a/Directory.Packages.props b/Directory.Packages.props index 565d016..498e2a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,10 @@ true - 8.0.29 - 9.0.18 - 10.0.10 - 10.8.3 + 8.0.22 + 9.0.11 + 10.0.7 + 10.5.2 @@ -58,14 +58,13 @@ - + - - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -76,24 +75,24 @@ - + - - - - - - + + + + + + - + - + diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index f224799..285f48d 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 (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. +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. 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 9dbab09..618b2f0 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, you can add delegates to the collection in . +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 . ```csharp mcpClient.RegisterNotificationHandler( diff --git a/docs/concepts/capabilities/capabilities.md b/docs/concepts/capabilities/capabilities.md index d4c7642..b466736 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, and elicitation) are covered in their respective documentation pages. +Handlers for each capability (roots, sampling, 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. -#### Check server capabilities from the client +#### Checking 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 }) { - client.RegisterNotificationHandler( + mcpClient.RegisterNotificationHandler( NotificationMethods.PromptListChangedNotification, async (notification, ct) => { - var prompts = await client.ListPromptsAsync(cancellationToken: ct); + var prompts = await mcpClient.ListPromptsAsync(cancellationToken: ct); }); } diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 0ac808a..7570d27 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 (for example, language names, style options) -- **Resource template argument completions**: Suggest values for URI template parameters (for example, file paths, resource IDs) +- **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) 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.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .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 automatically surfaces 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 will automatically surface 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 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`. +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`. ### 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 deleted file mode 100644 index 8a6fa27..0000000 --- a/docs/concepts/deployment/docker.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -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 b899df2..4e14a2d 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 feature 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 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's requesting from the user. +For form-based elicitation, the MCP Server must specify the schema of each input value it is 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's called when the server requests additional information. The handler should check the `Mode` of the request: +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: - **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 — 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. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. 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 session using protocol revision 2025-11-25)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request user input @@ -223,13 +223,13 @@ public static string ElicitWithMrtr( ``` > [!TIP] -> For the full protocol details, including multiple round trips, concurrent input requests, and the compatibility matrix, see [Multi Round-Trip Requests (MRTR)](xref:mrtr). +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including multiple round trips, concurrent input requests, and the compatibility matrix. -### 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 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. +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. -#### 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 e7b9e37..b10dd7e 100644 --- a/docs/concepts/elicitation/samples/server/Program.cs +++ b/docs/concepts/elicitation/samples/server/Program.cs @@ -1,5 +1,4 @@ using Elicitation.Tools; -using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -9,8 +8,8 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Elicitation requires stateful mode because it sends server-to-client requests. - // Set SessionMode = HttpServerSessionMode.Stateful since it's required. - options.SessionMode = HttpServerSessionMode.Stateful; + // Set Stateless = false explicitly for forward compatibility in case the default changes. + options.Stateless = false; }) .WithTools(); diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index 80d6d46..896cea6 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -10,35 +10,27 @@ 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 (for example, `AddListToolsFilter`, `AddCallToolFilter`) configured via `WithRequestFilters(...)` that target specific MCP operations. +2. **Request-Specific Filters** - Handler-level filters (e.g., `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(...)`: -| 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 | +- `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 -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 +## 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(...)`: @@ -46,7 +38,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: @@ -56,7 +48,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: @@ -90,7 +82,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: @@ -116,7 +108,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: @@ -145,7 +137,7 @@ services.AddMcpServer() .WithTools(); ``` -#### Skipping outgoing messages +#### Skipping Outgoing Messages You can suppress outgoing messages by not calling `next`: @@ -166,7 +158,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`: @@ -197,7 +189,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: @@ -243,7 +235,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: @@ -299,7 +291,7 @@ services.AddMcpServer() }); ``` -## Filter execution order +## Filter Execution Order ```csharp services.AddMcpServer() @@ -314,9 +306,7 @@ services.AddMcpServer() Execution flow: `filter1 -> filter2 -> filter3 -> baseHandler -> filter3 -> filter2 -> filter1` -## Common use cases - -Filters are commonly used for [logging](#logging), [error handling](#error-handling), [performance monitoring](#performance-monitoring), and [caching](#caching). +## Common Use Cases ### Logging @@ -335,7 +325,7 @@ Filters are commonly used for [logging](#logging), [error handling](#error-handl }); ``` -### Error handling +### Error Handling ```csharp .WithRequestFilters(requestFilters => @@ -361,7 +351,7 @@ Filters are commonly used for [logging](#logging), [error handling](#error-handl }); ``` -### Performance monitoring +### Performance Monitoring ```csharp .WithRequestFilters(requestFilters => @@ -401,24 +391,24 @@ Filters are commonly used for [logging](#logging), [error handling](#error-handl }); ``` -## 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.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .AddAuthorizationFilters() // Enable authorization filter support .WithTools(); ``` -**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. +**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. -### Authorization attributes support +### Authorization Attributes Support The MCP server automatically respects the following authorization attributes: @@ -427,7 +417,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: @@ -457,7 +447,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: @@ -481,19 +471,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: @@ -511,7 +501,7 @@ This allows you to implement logging, metrics, or other cross-cutting concerns t ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .WithRequestFilters(requestFilters => { requestFilters.AddListToolsFilter(next => async (context, cancellationToken) => @@ -541,13 +531,11 @@ 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") @@ -558,7 +546,7 @@ builder.Services.AddAuthorization(); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.SessionMode = HttpServerSessionMode.Stateless; + options.Stateless = true; }) .AddAuthorizationFilters() // Required for authorization support .WithTools() @@ -577,7 +565,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 f3e2f58..c6096aa 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 and add the required packages: +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: ``` dotnet new console @@ -37,8 +37,6 @@ 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; @@ -68,17 +66,14 @@ 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, add the following packages: +For HTTP-based servers using ASP.NET Core: ``` dotnet new web dotnet add package ModelContextProtocol.AspNetCore ``` -And add the following code: - ```csharp -using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.ComponentModel; @@ -86,10 +81,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // 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; + // 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; }) .WithToolsFromAssembly(); var app = builder.Build(); @@ -114,23 +109,21 @@ For production servers, configure the exact public host names for the deployment #### Browser cross-origin access -**Only** enable cross-origin requests (CORS) if you intentionally want browser-based cross-origin access to this server. +**Only** enable 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 and add the following packages: +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: ``` 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; @@ -164,7 +157,7 @@ Clients can connect to any MCP server, not just ones created with this library. #### Using tools with an LLM -`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`: +`McpClientTool` inherits from `AIFunction`, so the tools returned by `ListToolsAsync` can be handed directly to any `IChatClient`: ```csharp // Get available tools. @@ -179,4 +172,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 [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. +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. diff --git a/docs/concepts/httpcontext/httpcontext.md b/docs/concepts/httpcontext/httpcontext.md index 25a4228..34ee4db 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` might contain stale claims if the client's token was refreshed after the SSE connection was established. +- The `HttpContext.User` may 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 might 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 may 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 c9d9dc6..a01602d 100644 --- a/docs/concepts/httpcontext/samples/Program.cs +++ b/docs/concepts/httpcontext/samples/Program.cs @@ -1,5 +1,4 @@ using HttpContext.Tools; -using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,7 +7,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.SessionMode = HttpServerSessionMode.Stateless; + options.Stateless = true; }) .WithTools(); diff --git a/docs/concepts/identity/identity.md b/docs/concepts/identity/identity.md index 313523b..3e9756b 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's `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 will be `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. -For more details on authorization filters and their execution order, see [Filters](xref:filters). +See [Filters](xref:filters) for more details on authorization filters and their execution order. -## 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. -For more details, including important caveats about stale `HttpContext` with the legacy SSE transport, see [HTTP Context](xref:httpcontext). +See [HTTP Context](xref:httpcontext) for more details, including important caveats about stale `HttpContext` with the legacy SSE transport. -## Transport considerations +## Transport Considerations | Transport | Identity Source | Notes | | --- | --- | --- | @@ -166,7 +166,7 @@ For more details, including important caveats about stale `HttpContext` with the | 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 b318505..3e763be 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -4,29 +4,23 @@ Welcome to the conceptual documentation for the Model Context Protocol SDK. Here ## Contents -### Getting started +### [Getting Started](getting-started.md) -To install the SDK and build your first MCP client and server, see [Getting started](getting-started.md). +Install the SDK and build your first MCP client and server. -### 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 +### 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 | | - | - | @@ -34,7 +28,7 @@ To install the SDK and build your first MCP client and server, see [Getting star | [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 | | - | - | @@ -44,6 +38,7 @@ To install the SDK and build your first MCP client and server, see [Getting star | [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. | @@ -52,5 +47,4 @@ To install the SDK and build your first MCP client and server, see [Getting star | 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 2b381aa..aa78eda 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -11,32 +11,29 @@ 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 more verbose level in the MCP logging levels, Trace level log messages are mapped to -the MCP `debug` level when sent to the client. +As there is no equivalent level in the MCP logging levels, Trace level logs messages are silently +dropped when sending messages 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 @@ -54,7 +51,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 don't need to +SDK already takes care of setting the in the , so most servers will not 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 , @@ -77,7 +74,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. -For the mapping between MCP and .NET logging levels, see the [Logging Levels](#logging-levels) section. +See the [Logging Levels](#logging-levels) section above for the mapping between MCP and .NET logging levels. [!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 f7bc043..48e2905 100644 --- a/docs/concepts/logging/samples/server/Program.cs +++ b/docs/concepts/logging/samples/server/Program.cs @@ -1,5 +1,4 @@ using Logging.Tools; -using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -9,8 +8,8 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Log streaming requires stateful mode because the server pushes log notifications - // to clients. Set SessionMode = HttpServerSessionMode.Stateful since it's required. - options.SessionMode = HttpServerSessionMode.Stateful; + // to clients. Set Stateless = false explicitly for forward compatibility. + options.Stateless = false; }) .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 edb3916..8ddb9e4 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 0d0fafa..e0d4f8d 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -7,6 +7,10 @@ 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 @@ -23,13 +27,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 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. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. ```csharp // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. @@ -43,9 +47,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 — including `2026-07-28` stdio sessions — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on every stateless session. +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 `2025-11-25` and earlier, `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see the [Compatibility](#compatibility) section. +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. ## Authoring an MRTR tool @@ -53,10 +57,10 @@ A tool participates in MRTR by throwing before throwing `InputRequiredException`. The property returns `true` when either: +Tools should check before throwing `InputRequiredException`. It returns `true` when either: - The negotiated protocol revision is `2026-07-28` (MRTR is native), or -- The session is stateful under protocol revision `2025-11-25` (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). +- The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). ```csharp [McpServerTool, Description("A tool that uses MRTR")] @@ -67,7 +71,7 @@ public static string MyTool( if (!server.IsMrtrSupported) { return "This tool requires a client that negotiates 2026-07-28, " - + "or a stateful session using protocol revision 2025-11-25."; + + "or a stateful current-protocol session."; } // ... MRTR logic @@ -132,11 +136,9 @@ 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. -| Input | Deserialize call | -|-------------|-----------------------------------------------------------------------| -| Elicitation | `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` | -| Sampling | `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` | -| Roots list | `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` | +- Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` +- Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` +- Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` ### Load shedding with requestState-only responses @@ -247,140 +249,30 @@ public static string WizardTool( } ``` -### Supporting down-level clients +### Providing custom error messages -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: +When MRTR is not supported, you can provide domain-specific guidance: ```csharp if (!server.IsMrtrSupported) { - 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."; + 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."; } ``` -#### 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 protocol eras and effective request modes: +The SDK supports `InputRequiredException` across two protocol revisions and two session 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. | -| `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. | +| 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. | > [!NOTE] > The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `2026-07-28` (check `IsMrtrSupported`). @@ -389,6 +281,6 @@ The SDK supports `InputRequiredException` across protocol eras and effective req `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 `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. +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. -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. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. diff --git a/docs/concepts/pagination/pagination.md b/docs/concepts/pagination/pagination.md index 0f60192..6440b72 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 can return large result sets. +MCP uses [cursor-based pagination] for all list operations that may 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 might 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 may 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** (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. +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. ### 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.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .WithListResourcesHandler(async (ctx, ct) => { const int pageSize = 10; diff --git a/docs/concepts/progress/progress.md b/docs/concepts/progress/progress.md index b46bbdb..fe89629 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 automatically handles progress notifications and reports them through the `Progress` instance. +The MCP C# SDK will automatically handle progress notifications and report 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 ef9c67c..cfff458 100644 --- a/docs/concepts/progress/samples/server/Program.cs +++ b/docs/concepts/progress/samples/server/Program.cs @@ -1,4 +1,3 @@ -using ModelContextProtocol.AspNetCore; using Progress.Tools; var builder = WebApplication.CreateBuilder(args); @@ -8,7 +7,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.SessionMode = HttpServerSessionMode.Stateless; + options.Stateless = true; }) .WithTools(); diff --git a/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs index e3a21db..e02889a 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 5dcc236..8b11e91 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 prompts from the client, rich content types, and change notifications. +This document covers implementing prompts on the server, consuming them 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.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .WithPrompts() .WithPrompts(); ``` ### Rich content in prompts -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. +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. #### Image content diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index 6080b2c..959ad73 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 resources from the client, resource templates, subscriptions, and change notifications. +This document covers implementing resources on the server, consuming them 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.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .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 SessionMode = HttpServerSessionMode.Stateful since sessions are required. - .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) + // to clients. Set Stateless = false explicitly for forward compatibility. + .WithHttpTransport(o => o.Stateless = false) .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { if (ctx.Params.Uri is { } uri) { - // Track the subscription (for example, in a concurrent dictionary) + // Track the subscription (e.g., in a concurrent dictionary) subscriptions[ctx.Server.SessionId].TryAdd(uri, 0); } return new EmptyResult(); @@ -231,16 +231,6 @@ 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: @@ -254,7 +244,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 fa9e1cd..de9b9b0 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -11,9 +11,6 @@ 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: @@ -60,7 +57,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). For a stateless-compatible alternative, throw `InputRequiredException` from your handler through MRTR — see [Multi round-trip requests (MRTR)](#multi-round-trip-requests-mrtr). +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). ```csharp [McpServerTool, Description("Lists the user's project roots")] @@ -107,12 +104,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 — 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. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -131,7 +128,7 @@ public static string ListRootsWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (2026-07-28, or a stateful session using protocol revision 2025-11-25)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request the client's root list @@ -145,4 +142,4 @@ public static string ListRootsWithMrtr( ``` > [!TIP] -> For the full protocol details, including load shedding, multiple round trips, and the compatibility matrix, see [Multi Round-Trip Requests (MRTR)](xref:mrtr). +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 7a1f907..7b8eb8e 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -11,11 +11,8 @@ 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. For a stateless-compatible alternative, throw `InputRequiredException` from your handler through MRTR — see [Multi round-trip requests (MRTR)](#multi-round-trip-requests-mrtr). +> 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. ### How sampling works @@ -26,7 +23,7 @@ MCP [sampling] allows servers to request LLM completions from the client. This e ### Server: requesting a completion -To get an that sends requests through the connected client, inject into a tool method and use the extension method: +Inject into a tool method and use the extension method to get an that sends requests through the connected client: ```csharp [McpServerTool(Name = "SummarizeContent"), Description("Summarizes the given text")] @@ -124,12 +121,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 — 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. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -149,7 +146,7 @@ public static string SampleWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (2026-07-28, or a stateful session using protocol revision 2025-11-25)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request LLM completion from the client @@ -174,4 +171,4 @@ public static string SampleWithMrtr( ``` > [!TIP] -> For the full protocol details, including load shedding, multiple round trips, and the compatibility matrix, see [Multi Round-Trip Requests (MRTR)](xref:mrtr). +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index c393d3d..69cf767 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 **. 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 `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). -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)). +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)). [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http @@ -20,36 +20,29 @@ When sessions are enabled (`SessionMode = HttpServerSessionMode.Stateful`), the - 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.** -- 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`). +- Otherwise → **Use stateless** (`options.Stateless = true`). > [!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 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). +> **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). ## Forward and backward compatibility -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: +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), 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: -- **`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 = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`HttpServerSessionMode.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). +- **`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 with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. > [!TIP] -> 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. +> 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. ### The 2026-07-28 protocol revision -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). +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). -**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. +**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. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request. **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. @@ -71,7 +64,7 @@ var clientOptions = new McpClientOptions ### Migrating from legacy SSE -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. +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. **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: @@ -87,7 +80,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 `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`. +**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`. ## Stateless mode (recommended) @@ -96,14 +89,12 @@ 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.SessionMode = HttpServerSessionMode.Stateless; + options.Stateless = true; }) .WithTools(); @@ -114,21 +105,21 @@ app.Run(); ### What stateless mode changes -When is : +When is `true`: - 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 can't 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 cannot connect - **Server-to-client requests are disabled**, including: - [Sampling](xref:sampling) (`SampleAsync`) - [Elicitation](xref:elicitation) (`ElicitAsync`) - [Roots](xref:roots) (`RequestRootsAsync`) - - Ping — the server can't ping the client to verify connectivity + - Ping — the server cannot 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** (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. +- **[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. - **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. @@ -144,22 +135,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. For guidance on choosing between stateless and stateful mode, see [Forward and backward compatibility](#forward-and-backward-compatibility). +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. ### 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. Keep a session if you must interact with clients that do not support `2026-07-28`. +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. ## Stateful mode (sessions) -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: +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: - 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 (for example, `RunSessionHandler`, state that persists across multiple requests within a session) +- Session-scoped state (e.g., `RunSessionHandler`, state that persists across multiple requests within a session) ### When to use stateful mode @@ -173,48 +164,12 @@ 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) 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. +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. ## 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 | @@ -222,15 +177,12 @@ Stateful-only options (`IdleTimeout`, `EventStreamStore`, `SessionMigrationHandl | **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 [might 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 [may 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 @@ -239,28 +191,28 @@ Stateful-only options (`IdleTimeout`, `EventStreamStore`, `SessionMigrationHandl 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. @@ -268,20 +220,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 (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. +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. When a session times out: - The session's `McpServer` is disposed - Any pending requests receive cancellation -- A client that tries to use the expired session ID receives a `404 Session not found` error and should start a new session +- A client trying 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. @@ -299,10 +251,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 @@ -312,11 +264,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 might 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 may 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 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. +**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. -**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 @@ -336,10 +288,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). @@ -347,9 +299,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. @@ -386,7 +338,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 previous example 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 example above 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. @@ -400,19 +352,19 @@ If the server is still tracking the session (or supports [session migration](#se - (optional) - (optional) -For a code example, see the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide. +See the [Resuming sessions](xref:transports#resuming-sessions) section in the Transports guide for a code example. 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 (for example, 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 (e.g., reading capabilities) without intending to own the session's lifetime. ### Client transport options @@ -421,9 +373,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` | 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. | +| | `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 (e.g., 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). @@ -438,7 +390,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Recommended for servers that don't need sessions. - options.SessionMode = HttpServerSessionMode.Stateless; + options.Stateless = true; // --- Options below only apply to stateful (non-stateless) mode --- @@ -465,8 +417,7 @@ builder.Services.AddMcpServer() | Property | Type | Default | Description | |----------|------|---------|-------------| -| | | `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. | +| | `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. | | | `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. | @@ -475,7 +426,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 `SessionMode = HttpServerSessionMode.Stateless`). 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 `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions @@ -496,7 +447,7 @@ options.ConfigureSessionOptions = async (httpContext, mcpServerOptions, cancella }; ``` -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. +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. #### Per-request configuration in stateless mode @@ -506,7 +457,7 @@ In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP reque builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.SessionMode = HttpServerSessionMode.Stateless; + options.Stateless = true; options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => { // This runs on every request in stateless mode, so you can use the @@ -518,7 +469,7 @@ builder.Services.AddMcpServer() }) .WithTools(); ``` - + ### Security and user binding #### User binding @@ -527,13 +478,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). @@ -547,9 +498,9 @@ In stateful mode, the server's Ctrl+C). | +| **Stateful Streamable HTTP** | Linked token: HTTP request + application shutdown + session disposal | Client disconnects, `ApplicationStopping` fires, or the session is terminated (idle timeout, DELETE, max idle count). | +| **SSE (legacy)** | Linked token: GET request + application shutdown | Client disconnects the SSE stream, or `ApplicationStopping` fires. The entire session terminates with the GET stream. | +| **stdio** | Token passed to `McpServer.RunAsync()` | stdin EOF (client process exits), or the token is cancelled (e.g., host shutdown via 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 can 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 may 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 (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. +- 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 #### Graceful shutdown in ASP.NET Core -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. +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. 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 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. +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. ## Tasks and session modes @@ -644,17 +595,17 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ ### 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` 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` 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. 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` 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. +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. -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). +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. -### Task cancellation vs. request cancellation +### Task cancellation vs request cancellation The MCP specification defines two distinct cancellation mechanisms: @@ -665,17 +616,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. @@ -683,32 +634,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 +### With tasks (experimental) -[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**. +[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, 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 @@ -718,15 +669,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 the [Handler cancellation tokens](#handler-cancellation-tokens) section). +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). ### 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 | @@ -771,7 +722,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 @@ -781,10 +732,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 | @@ -795,26 +746,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 `SessionMode = HttpServerSessionMode.Stateless` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. +> 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. ### 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 @@ -827,7 +778,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session migration is a stateful-mode feature. - options.SessionMode = HttpServerSessionMode.Stateful; + options.Stateless = false; options.SessionMigrationHandler = new MySessionMigrationHandler(); }); ``` @@ -840,9 +791,9 @@ builder.Services.AddSingleton { // Session resumability is a stateful-mode feature. - options.SessionMode = HttpServerSessionMode.Stateful; + options.Stateless = false; options.EventStreamStore = new MyEventStreamStore(); }); ``` @@ -866,4 +817,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 might experience transient network issues. Without an event store, clients that disconnect and reconnect might miss events that were sent while they were disconnected. +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. diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index c1ab0c2..5342946 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. -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). +> **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. ### 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 **might** respond +extension key in the request's `_meta`. When that opt-in is present, the server **may** respond with a instead of the standard result -(for example, ). The client then polls `tasks/get` +(e.g., ). 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 @@ -50,12 +50,14 @@ and `"complete"` for ordinary results. #### Using the task store -The easiest way to enable the package's task store integration is to call - on the server builder, -passing an . +Tasks support lives in the `ModelContextProtocol.Extensions.Tasks` package. The easiest way to +enable tasks 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() @@ -77,13 +79,9 @@ When tasks are enabled with `WithTasks` 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 the [Implementing a custom task store](#implementing-a-custom-task-store) section). +(see [Implementing a custom task store](#implementing-a-custom-task-store) below). #### Returning a task from a tool handler @@ -119,7 +117,7 @@ options.Handlers.CallToolWithAlternateHandler = async (context, ct) => > 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 +> returns an alternate. Use one mechanism or the other. When you return a task this way you are also > responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides > automatically. @@ -128,6 +126,28 @@ options.Handlers.CallToolWithAlternateHandler = async (context, ct) => > 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 ModelContextProtocol.Extensions.Tasks; + +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 +`WithTasks`, so this API is only needed for custom handlers. + ### Client usage #### Automatic polling @@ -182,7 +202,7 @@ if (raw.IsTask) `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 +without exposing any new input request keys (i.e. 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 @@ -225,7 +245,7 @@ Per SEP-2663: Implement for production scenarios. Key requirements drawn from the SEP and the SDK contract: -1. **Thread safety** — every method can be called concurrently. +1. **Thread safety** — every method may be called concurrently. 2. **Idempotent terminal transitions** — , , and @@ -244,7 +264,7 @@ requirements drawn from the SEP and the SDK contract: task is durably persisted, so that a subsequent 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 @@ -317,35 +337,16 @@ In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore` uses immutable record snapshots with compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are -exposed as `ImmutableDictionary<,>` so observers can't mutate internal state. +exposed as `ImmutableDictionary<,>` so observers cannot 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's +the client opted in by including the extension marker in the originating request, so it is 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 diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index 276415e..1b49995 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -3,10 +3,6 @@ items: href: index.md - name: Getting Started uid: getting-started -- name: Deployment - items: - - name: Docker - uid: docker-deployment - name: Base Protocol items: - name: Capabilities @@ -21,6 +17,8 @@ items: uid: progress - name: Cancellation uid: cancellation + - name: Tasks + uid: tasks - name: Multi Round-Trip Requests (MRTR) uid: mrtr - name: Client Features @@ -53,11 +51,5 @@ 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 3a68d61..ae552e3 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.SessionMode = HttpServerSessionMode.Stateless) + .WithHttpTransport(o => o.Stateless = true) .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`](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. +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. #### Text content @@ -121,8 +121,6 @@ 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`: @@ -141,16 +139,6 @@ 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: @@ -237,7 +225,7 @@ public static double Divide(double a, double b) #### Protocol errors -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: +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: ```csharp [McpServerTool, Description("Processes the input")] @@ -308,13 +296,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. @@ -351,7 +339,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 (`2026-07-28` and later). +- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `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 bb4e155..8fb2af0 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**](#stdio-transport), [**Streamable HTTP**](#streamable-http-transport), and [**SSE**](#sse-transport-legacy) (Server-Sent Events, legacy). +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). [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); ``` -The following table describes the key properties: +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 @@ The following table describes the key [!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'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. +> **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. #### stdio server @@ -168,16 +168,13 @@ 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 => { - // Stateless mode is the default and recommended for - // servers that don't need server-to-client requests. - options.SessionMode = HttpServerSessionMode.Stateless; + // Recommended for servers that don't need server-to-client requests. + options.Stateless = true; }) .WithTools(); @@ -186,7 +183,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `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). +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. 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. #### Host name validation @@ -205,13 +202,14 @@ If you intentionally expose the server through another host name, such as a tunn #### Browser cross-origin access -**Only** enable cross-origin requests (CORS) if you intentionally want browser-based cross-origin access to this server. +**Only** enable 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 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._ + +_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._ ```json // appsettings.Development.json @@ -232,7 +230,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"); @@ -247,11 +245,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 (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). +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. -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. +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: @@ -261,9 +259,7 @@ 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 (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). +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`). ### SSE transport (legacy) @@ -293,29 +289,27 @@ await using var client = await McpClient.CreateAsync(transport); SSE-specific configuration options: -| Property | Description | Default | -|-------------------------------|--------------------------------------------------------------|----------| -| `MaxReconnectionAttempts` | Maximum number of reconnection attempts on stream disconnect | 5 | -| `DefaultReconnectionInterval` | Wait time between reconnection attempts | 1 second | +| Property | Description | +|----------|-------------| +| `MaxReconnectionAttempts` | Maximum number of reconnection attempts on stream disconnect (default: 5) | +| `DefaultReconnectionInterval` | Wait time between reconnection attempts (default: 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 `SessionMode = HttpServerSessionMode.Stateless`. +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`. -**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). +**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. To enable legacy SSE, set `EnableLegacySse` to `true`: ```csharp -using ModelContextProtocol.AspNetCore; - var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // SSE requires stateful mode; opt in explicitly because stateless mode is the default. - options.SessionMode = HttpServerSessionMode.Stateful; + // SSE requires stateful mode (the default). Set explicitly for forward compatibility. + options.Stateless = false; #pragma warning disable MCP9004 // EnableLegacySse is obsolete // Enable legacy SSE endpoints for clients that don't support Streamable HTTP. @@ -333,14 +327,14 @@ app.MapMcp(); app.Run(); ``` -See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details on SSE session lifetime and configuration. +See [Sessions — 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 | ✓ | ✗ | ✓ | ✓ | @@ -350,7 +344,7 @@ See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse- | 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 [Stateless and Stateful](xref:stateless). +For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:stateless). ### In-memory transport @@ -385,14 +379,13 @@ 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. For information about how session behavior varies across transports, see [Stateless and Stateful](xref:stateless). +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. -## 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 @@ -425,4 +418,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 (for example, 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 (e.g. after a 401 response), call `provider.InvalidateCache()` before retrying. diff --git a/docs/experimental.md b/docs/experimental.md index 0704fb9..59a1d75 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 (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: +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: ### 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 MCPEXP002 // RunSessionHandler is experimental and may change. -options.RunSessionHandler = static (_, _, _) => Task.CompletedTask; -#pragma warning restore MCPEXP002 +#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 ``` 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 107c28d..3e3d88c 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. +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._ 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 5773342..a4a71dd 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 can 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 may 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 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. | +| `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). | | `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,9 +40,8 @@ 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` (for example, `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | +| `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `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 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. | +| `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. | diff --git a/docs/roadmap.md b/docs/roadmap.md index e012cc3..105a039 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,22 +4,20 @@ 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. See the [2026-07-28 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/41/views/1). +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). -## Current focus areas +## Current Focus Areas -### Next spec revision +### Next Spec Revision -The C# SDK's standing plan is to release support for each new MCP specification revision alongside the specification, as it did for the 2026-07-28 revision. Accepted SEPs are tracked on the revision's project board throughout development so implementation is ready when the revision is published. The next revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). -### Feedback and end-to-end scenarios +### 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 953382b..e09c4b5 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -7,5 +7,7 @@ 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 deleted file mode 100644 index 3377ad9..0000000 --- a/docs/version-picker/version-picker.css +++ /dev/null @@ -1,102 +0,0 @@ -/* - * 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 deleted file mode 100644 index 6fbe1c4..0000000 --- a/docs/version-picker/version-picker.js +++ /dev/null @@ -1,159 +0,0 @@ -/* - * 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 b94b955..68f147a 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 -The following support policy applies to stable C# ModelContextProtocol SDK packages: +Beginning with the 1.0.0 release, the following support policy will be applied for the official 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,21 +33,9 @@ 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 -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. +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. 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. @@ -71,8 +59,3 @@ 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 9e13a61..fcb4599 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.101", + "version": "10.0.100", "rollForward": "minor" } } diff --git a/package-lock.json b/package-lock.json index 774becc..6cc1c80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,27 +5,27 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } }, "node_modules/@hono/node-server": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", - "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18.14.1" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.9", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.9.tgz", - "integrity": "sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==", + "version": "0.2.0-alpha.8", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.8.tgz", + "integrity": "sha512-ktbvq6ftvs23TjZ/WVmTKZHue8dqoUF5bBG3Qd5pbawfTyuveZw93o07uQij8tslBlccpPVS5jE212G+SnLo2A==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -52,12 +52,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", + "@hono/node-server": "^1.19.9", "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.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "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==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^1.0.5", "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" @@ -365,19 +365,6 @@ "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", @@ -707,9 +694,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -844,9 +831,9 @@ } }, "node_modules/hono": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", - "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -901,9 +888,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -1159,9 +1146,9 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1427,40 +1414,23 @@ } }, "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "content-type": "^2.0.0", + "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "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": ">= 0.6" } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 577d3e1..c1ad1b2 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.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@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 68c37e1..983d296 100644 --- a/samples/AspNetCoreMcpPerSessionTools/Program.cs +++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs @@ -1,5 +1,4 @@ using AspNetCoreMcpPerSessionTools.Tools; -using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.Collections.Concurrent; using System.Reflection; @@ -15,8 +14,8 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // This sample demonstrates per-session tool filtering, which requires stateful mode. - // Set SessionMode = HttpServerSessionMode.Stateful since sessions are required. - options.SessionMode = HttpServerSessionMode.Stateful; + // Set Stateless = false explicitly for forward compatibility in case the default changes. + options.Stateless = false; // 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 3d37568..e0d9680 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 => { - // Set SessionMode = HttpServerSessionMode.Stateful since per-session - // tool filtering requires sessions. - options.SessionMode = HttpServerSessionMode.Stateful; + // Per-session tool filtering requires stateful mode. Set Stateless = false + // explicitly for forward compatibility in case the default changes. + options.Stateless = false; options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => { var toolCategory = GetToolCategoryFromRoute(httpContext); diff --git a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj index ca64119..29aebc0 100644 --- a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj +++ b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj @@ -15,7 +15,6 @@ - diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index a69f499..f35d0ef 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,5 +1,3 @@ -using Azure.Monitor.OpenTelemetry.AspNetCore; -using ModelContextProtocol.AspNetCore; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -27,36 +25,24 @@ 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 -// SessionMode = HttpServerSessionMode.Stateful since sessions are required for sampling. +// Stateless = false explicitly for forward compatibility in case the default changes. // See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for details. builder.Services.AddMcpServer() - .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) + .WithHttpTransport(o => o.Stateless = false) .WithTools() .WithTools() .WithTools() .WithResources(); -var telemetry = builder.Services.AddOpenTelemetry() +builder.Services.AddOpenTelemetry() .WithTracing(b => b.AddSource("*") .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) .WithMetrics(b => b.AddMeter("*") .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) - .WithLogging(); - -string? applicationInsightsConnectionString = - builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; - -if (string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) -{ - telemetry.UseOtlpExporter(); -} -else -{ - telemetry.UseAzureMonitor(options => - options.ConnectionString = applicationInsightsConnectionString); -} + .WithLogging() + .UseOtlpExporter(); // Configure HttpClientFactory for weather.gov API builder.Services.AddHttpClient("WeatherApi", client => diff --git a/samples/AspNetCoreMcpServer/README.md b/samples/AspNetCoreMcpServer/README.md deleted file mode 100644 index 9ec124d..0000000 --- a/samples/AspNetCoreMcpServer/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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 abccbac..f8c9752 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -3,7 +3,6 @@ using EverythingServer.Prompts; using EverythingServer.Resources; using EverythingServer.Tools; using Microsoft.Extensions.AI; -using ModelContextProtocol.AspNetCore; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -59,8 +58,8 @@ builder.Services .WithHttpTransport(options => { // This sample uses subscriptions, SampleLlmTool (sampling), and RunSessionHandler. - // Set SessionMode = HttpServerSessionMode.Stateful since sessions are required for sampling. - options.SessionMode = HttpServerSessionMode.Stateful; + // Set Stateless = false explicitly for forward compatibility in case the default changes. + options.Stateless = false; // 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 deleted file mode 100644 index e350144..0000000 --- a/samples/InMemoryTransport/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 6cda8e7..eba7925 100644 --- a/samples/ProtectedMcpClient/Program.cs +++ b/samples/ProtectedMcpClient/Program.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Logging; -using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Diagnostics; @@ -33,7 +32,7 @@ var transport = new HttpClientTransport(new() OAuth = new() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "ProtectedMcpClient", @@ -69,16 +68,12 @@ 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 context containing the authorization and redirect URIs. +/// The authorization URL to open in the browser. +/// The redirect URI where the authorization code will be sent. /// The to monitor for cancellation requests. The default is . -/// The authorization result extracted from the callback, or null if the operation failed. -static async Task HandleAuthorizationUrlAsync( - AuthorizationCallbackContext authorizationContext, - CancellationToken cancellationToken) +/// The authorization code extracted from the callback, or null if the operation failed. +static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) { - var authorizationUrl = authorizationContext.AuthorizationUri; - var redirectUri = authorizationContext.RedirectUri; - Console.WriteLine("Starting OAuth authorization flow..."); Console.WriteLine($"Opening browser to: {authorizationUrl}"); @@ -98,8 +93,6 @@ static async Task HandleAuthorizationUrlAsync( 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.

"; @@ -122,7 +115,7 @@ static async Task HandleAuthorizationUrlAsync( } Console.WriteLine("Authorization code received successfully."); - return new AuthorizationResult { Code = code, State = state, Iss = iss }; + return code; } catch (Exception ex) { diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index 59ad352..f539e73 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -2,7 +2,6 @@ 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; @@ -91,10 +90,10 @@ builder.Services.AddMcpServer() .WithTools() .WithHttpTransport(options => { - // 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; + // 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; }); // Configure HttpClientFactory for weather.gov API diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index 7823989..50ddf0d 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -24,9 +24,9 @@ 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); +services.AddSingleton(new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); diff --git a/samples/TasksExtension/README.md b/samples/TasksExtension/README.md index accafa5..f325061 100644 --- a/samples/TasksExtension/README.md +++ b/samples/TasksExtension/README.md @@ -6,12 +6,16 @@ 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 `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. +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. Both ends of the conversation are connected in-process over an in-memory `Pipe`, so no separate server process or HTTP transport is required. @@ -25,7 +29,10 @@ dotnet run --project samples/TasksExtension/TasksExtension.csproj Expected output: ``` -=== CallToolAsTaskAsync (manual poll) === +=== CallToolAsync (auto-poll) === + result: report ready + +=== CallToolRawAsync (manual poll) === task created: id=… status=Working pollIntervalMs=250 poll 1: still working … … @@ -34,6 +41,10 @@ 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/scripts/finalize-docs-site.mjs b/scripts/finalize-docs-site.mjs deleted file mode 100644 index d054952..0000000 --- a/scripts/finalize-docs-site.mjs +++ /dev/null @@ -1,138 +0,0 @@ -// 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 deleted file mode 100644 index 14aeebd..0000000 --- a/scripts/get-docs-version-slug.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// 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 deleted file mode 100644 index 23623f1..0000000 --- a/scripts/inject-version-picker.mjs +++ /dev/null @@ -1,116 +0,0 @@ -// 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 deleted file mode 100644 index 8c1ccfb..0000000 --- a/scripts/list-versions.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// 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 deleted file mode 100644 index 485509f..0000000 --- a/scripts/manifest-path.mjs +++ /dev/null @@ -1,16 +0,0 @@ -// 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 e61f841..1af5ce5 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -13,10 +13,9 @@ namespace ModelContextProtocol; /// These APIs may change as the specification evolves. /// /// -/// 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. +/// 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. /// /// /// @@ -66,31 +65,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 SDK extensibility APIs that enable independently packaged features, - /// such as Tasks, without requiring Core awareness of those features. + /// Diagnostic ID for experimental SDK APIs unrelated to the MCP specification, + /// such as subclassing McpClient/McpServer or referencing RunSessionHandler. /// /// /// 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 Extensibility_DiagnosticId = "MCPEXP002"; + public const string Subclassing_DiagnosticId = "MCPEXP002"; /// - /// Message for experimental extensibility points in the C# SDK implementation. + /// Message for experimental subclassing of McpClient and McpServer. /// - public const string Extensibility_Message = "This C# SDK extensibility API is experimental and subject to change."; + public const string Subclassing_Message = "Subclassing McpClient and McpServer is experimental and subject to change."; /// - /// URL for experimental extensibility points in the C# SDK implementation. + /// URL for experimental subclassing of McpClient and McpServer. /// - public const string Extensibility_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; + public const string Subclassing_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 d155e84..05ef092 100644 --- a/src/Common/HttpResponseMessageExtensions.cs +++ b/src/Common/HttpResponseMessageExtensions.cs @@ -23,37 +23,25 @@ internal static class HttpResponseMessageExtensions { if (!response.IsSuccessStatusCode) { - 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) + string? responseBody = null; + try { - responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "..."; - } - } - catch - { - // Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it. - } + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); - return CreateHttpRequestException(response, responseBody); + 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); + } } /// @@ -69,52 +57,10 @@ 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 - var exception = new HttpRequestException(message, innerException, statusCode); + return new HttpRequestException(message, inner: null, response.StatusCode); #else - var exception = new HttpRequestException(message, innerException); + return new HttpRequestException(message); #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/Obsoletions.cs b/src/Common/Obsoletions.cs index 217ff97..542fddb 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -44,10 +44,6 @@ 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 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_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_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 2b120c4..5ff65ad 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.2.0 - + 2.0.0 + preview.3 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm @@ -18,7 +18,7 @@ $(RepoRoot)\Open.snk true true - 2.0.0 + 1.3.0 diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets deleted file mode 100644 index 0c3c509..0000000 --- a/src/Directory.Build.targets +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)"> - [%(_ProjectReferencesWithVersions.ProjectVersion)] - - - - diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationCallToolFilterGuardSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationCallToolFilterGuardSetup.cs deleted file mode 100644 index 8f41a5b..0000000 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationCallToolFilterGuardSetup.cs +++ /dev/null @@ -1,32 +0,0 @@ -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 6290dd5..3f58707 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -11,16 +11,14 @@ namespace ModelContextProtocol.AspNetCore; /// /// Evaluates authorization policies from endpoint metadata. /// -internal sealed class AuthorizationFilterSetup( - IAuthorizationPolicyProvider? policyProvider = null, - AuthorizationFiltersMarker? marker = null) : IConfigureOptions, IPostConfigureOptions +internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? policyProvider = null) : IConfigureOptions, IPostConfigureOptions { private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked"; public void Configure(McpServerOptions options) { ConfigureListToolsFilter(options); - ConfigureOrdinaryCallToolFilter(options); + ConfigureCallToolFilter(options); ConfigureListResourcesFilter(options); ConfigureListResourceTemplatesFilter(options); @@ -32,13 +30,8 @@ internal sealed class AuthorizationFilterSetup( 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); @@ -86,7 +79,7 @@ internal sealed class AuthorizationFilterSetup( }); } - private void ConfigureOrdinaryCallToolFilter(McpServerOptions options) + private void ConfigureCallToolFilter(McpServerOptions options) { options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => { @@ -102,22 +95,18 @@ internal sealed class AuthorizationFilterSetup( }); } - private void ConfigureCallToolFilter(McpServerOptions options) + private static void CheckCallToolFilter(McpServerOptions options) { -#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) => + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => { - var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); - if (!authResult.Succeeded) + if (HasAuthorizationMetadata(context.MatchedPrimitive) + && !context.Items.ContainsKey(AuthorizationFilterInvokedKey)) { - throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + 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."); } - context.Items[AuthorizationFilterInvokedKey] = true; - return await next(context, cancellationToken); }); -#pragma warning restore MCPEXP002 } private void ConfigureListResourcesFilter(McpServerOptions options) @@ -385,7 +374,7 @@ internal sealed class AuthorizationFilterSetup( : AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build()); } - internal static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive) + private 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 a522683..732821b 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -34,7 +34,6 @@ 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) @@ -56,20 +55,14 @@ public static class HttpMcpServerBuilderExtensions /// /// This method automatically configures authorization filters for all MCP server handlers. These filters respect /// authorization attributes such as - /// 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. + /// and . /// 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 deleted file mode 100644 index 829b7e8..0000000 --- a/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs +++ /dev/null @@ -1,64 +0,0 @@ -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 7ecfc07..0247722 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -18,11 +18,9 @@ public class HttpServerTransportOptions /// with access to the of the request that initiated the session. /// /// - /// 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. + /// 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. /// public Func? ConfigureSessionOptions { get; set; } @@ -41,56 +39,12 @@ 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. @@ -101,18 +55,22 @@ public class HttpServerTransportOptions /// set to only when you need to support legacy clients that rely on session affinity. /// /// - /// 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. + /// If , will be null, and the "MCP-Session-Id" header will not be used, + /// the will be called once for each request, and the "/sse" endpoint will be disabled. + /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses + /// might arrive at another ASP.NET Core application process. + /// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests. + /// + /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: + /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served + /// when this property is . When it is , such a request is + /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to + /// the initialize handshake and obtains the session the server was configured to provide. + /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; + /// the server must not mint or echo session IDs for those revisions. + /// /// - public bool Stateless - { - get => SessionMode is HttpServerSessionMode.Stateless; - set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; - } + public bool Stateless { get; set; } = true; /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -136,9 +94,8 @@ public class HttpServerTransportOptions /// built-in backpressure. /// /// - /// Setting this to while is - /// throws an at - /// startup, because SSE requires in-memory session state. + /// Setting this to while is also + /// 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 de11d8a..b11fe81 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.SessionMode is HttpServerSessionMode.Stateless) + if (_options.Value.Stateless) { return Task.CompletedTask; } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index e5fc3fa..c95a5a8 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.SessionMode is HttpServerSessionMode.Stateless && options.EnableLegacySse) + if (options.Stateless && options.EnableLegacySse) { throw new InvalidOperationException( "Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " + @@ -50,12 +50,10 @@ public static class McpEndpointRouteBuilderExtensions .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); - if (options.SessionMode is not HttpServerSessionMode.Stateless) + if (!options.Stateless) { // The GET endpoint is not mapped in Stateless mode since there's no way to send unsolicited messages. // Resuming streams via GET is currently not supported in Stateless mode. - // In StatefulForInitializeClients mode both endpoints stay mapped for initialize-handshake clients; - // the handlers reject 2026-07-28 and later requests with 405 Method Not Allowed. streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index f26e9ed..f7db463 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -38,8 +38,8 @@ internal sealed class StreamableHttpHandler( /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a fully stateful server so a dual-path client falls - /// back to the initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path + /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. /// private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; @@ -53,16 +53,15 @@ 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) { + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, 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, @@ -105,54 +104,18 @@ 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; - // 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)) + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, 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) { @@ -161,33 +124,8 @@ 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, onResponseStarting, context.RequestAborted); + var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted); if (!wroteResponse) { // We wound up writing nothing, so there should be no Content-Type response header. @@ -199,7 +137,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleGetRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -246,7 +184,7 @@ internal sealed class StreamableHttpHandler( private async Task HandleResumedStreamAsync(HttpContext context, StreamableHttpSession session, string lastEventId) { - if (IsStatelessOnly) + if (HttpServerTransportOptions.Stateless) { await WriteJsonRpcErrorAsync(context, "Bad Request: The Last-Event-ID header is not supported in stateless mode.", @@ -318,7 +256,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleDeleteRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -362,7 +300,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.SessionMode = HttpServerSessionMode.Stateless. " + + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; @@ -443,18 +381,17 @@ internal sealed class StreamableHttpHandler( // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: if (RequiresPerRequestMetadataProtocol(context)) { - if (HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateful) + if (!HttpServerTransportOptions.Stateless) { - // 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. + // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 + // revision cannot provide. Refuse it so a dual-path client falls back to the + // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); return null; } - // Stateless and StatefulForInitializeClients both serve these requests natively, without a session. - return await StartNewSessionAsync(context, serveStatelessly: true); + // The default (stateless) HTTP transport serves these requests natively. + return await StartNewSessionAsync(context); } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); @@ -462,20 +399,20 @@ internal sealed class StreamableHttpHandler( { // 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 (!IsStatelessOnly && !AllowNewSessionForNonInitializeRequests + if (!HttpServerTransportOptions.Stateless && !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.SessionMode = HttpServerSessionMode.Stateless if your server doesn't need sessions. " + + "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true 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, serveStatelessly: IsStatelessOnly); + return await StartNewSessionAsync(context); } - else if (IsStatelessOnly) + else if (HttpServerTransportOptions.Stateless) { // In stateless mode, we should not be getting existing sessions via sessionId // This path should not be reached in stateless mode @@ -500,12 +437,12 @@ internal sealed class StreamableHttpHandler( return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } - private async ValueTask StartNewSessionAsync(HttpContext context, bool serveStatelessly) + private async ValueTask StartNewSessionAsync(HttpContext context) { string sessionId; StreamableHttpServerTransport transport; - if (!serveStatelessly) + if (!HttpServerTransportOptions.Stateless) { sessionId = MakeNewSessionId(); #pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. @@ -534,24 +471,23 @@ internal sealed class StreamableHttpHandler( }; } - return await CreateSessionAsync(context, transport, sessionId, serveStatelessly); + return await CreateSessionAsync(context, transport, sessionId); } private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, - bool serveStatelessly, Action? configureOptions = null) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (serveStatelessly || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (serveStatelessly) + if (HttpServerTransportOptions.Stateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -599,7 +535,7 @@ internal sealed class StreamableHttpHandler( context.Response.Headers[McpSessionIdHeaderName] = sessionId; - return await CreateSessionAsync(context, transport, sessionId, serveStatelessly: false, options => + return await CreateSessionAsync(context, transport, sessionId, options => { options.KnownClientInfo = initializeParams.ClientInfo; options.KnownClientCapabilities = initializeParams.Capabilities; @@ -716,39 +652,6 @@ 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 @@ -758,30 +661,12 @@ internal sealed class StreamableHttpHandler( 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) && !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, @@ -789,7 +674,7 @@ internal sealed class StreamableHttpHandler( Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = [.. advertisedVersions], + Supported = [.. supportedProtocolVersions], Requested = protocolVersionHeader, }, GetRequiredJsonTypeInfo()), @@ -819,8 +704,8 @@ internal sealed class StreamableHttpHandler( } /// - /// Validates that HTTP requests declare matching protocol versions in the - /// MCP-Protocol-Version header and the corresponding body field. + /// Validates that HTTP requests using per-request metadata declare the same protocol version in both + /// the MCP-Protocol-Version header and body _meta envelope. /// private static bool ValidateProtocolVersionEnvelope( HttpContext context, @@ -834,47 +719,26 @@ internal sealed class StreamableHttpHandler( } var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); - 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)) + if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && + !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) { errorDetail = null; return true; } - // Header validity was checked first, so only supported modern headers reach this point. - if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta)) + if (string.IsNullOrEmpty(protocolVersionHeader)) { - // 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; - } + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version."); + return false; + } - // 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}'.", - }; + if (!hasProtocolVersionMeta) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version."); return false; } @@ -920,13 +784,11 @@ internal sealed class StreamableHttpHandler( } /// - /// 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. + /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that + /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to + /// sessions; we return with a supported-versions list + /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake + /// (SEP-2575). /// private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) { @@ -934,8 +796,8 @@ internal sealed class StreamableHttpHandler( var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.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.", + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + + "Use the initialize handshake with a protocol version that still supports sessions instead.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { @@ -955,10 +817,10 @@ internal sealed class StreamableHttpHandler( /// /// The HTTP context containing the request headers. /// The JSON-RPC message to validate against. - /// The server options containing tools and custom request routing metadata. + /// The tool collection to look up tool schemas for parameter header validation. /// Set to the error message if validation fails; null otherwise. /// True if validation passes; false otherwise. - internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerOptions serverOptions, [NotNullWhen(false)] out string? errorMessage) + internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection? toolCollection, [NotNullWhen(false)] out string? errorMessage) { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); @@ -997,10 +859,8 @@ internal sealed class StreamableHttpHandler( return false; } -#pragma warning disable MCPEXP002 - var routingNameParameter = GetRoutingNameParameter(mcpMethodInBody, serverOptions.RequestHandlers); -#pragma warning restore MCPEXP002 - if (routingNameParameter is null) + // 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)) { errorMessage = null; return true; @@ -1039,7 +899,13 @@ internal sealed class StreamableHttpHandler( JsonRpcNotification notification => notification.Params, _ => null, }; - var mcpNameInBody = GetJsonNodeStringProperty(bodyParams, routingNameParameter); + var mcpNameInBody = mcpMethodInBody switch + { + RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"), + RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"), + RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"), + _ => null, + }; // Check that the header value matches the body value if the body value is present. if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) @@ -1049,7 +915,7 @@ internal sealed class StreamableHttpHandler( } // Validate Mcp-Param-* custom headers against tool schema - if (!ValidateCustomParamHeaders(context, message, serverOptions.ToolCollection, out errorMessage)) + if (!ValidateCustomParamHeaders(context, message, toolCollection, out errorMessage)) { return false; } @@ -1058,38 +924,6 @@ 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 deleted file mode 100644 index cbc4538..0000000 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationCallbackContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 b8f6a37..a811e51 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationRedirectDelegate.cs @@ -1,3 +1,4 @@ + namespace ModelContextProtocol.Authentication; /// @@ -8,13 +9,20 @@ 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 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. +/// +/// 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. +/// /// -[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); +public delegate Task AuthorizationRedirectDelegate(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken); \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs deleted file mode 100644 index 4da082f..0000000 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs +++ /dev/null @@ -1,54 +0,0 @@ -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 d8d684a..87df296 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationServerMetadata.cs @@ -72,11 +72,4 @@ 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 df1c44c..0bfb19a 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -70,47 +70,18 @@ public sealed class ClientOAuthOptions public ScopeSelectorDelegate? ScopeSelector { get; set; } /// - /// Gets or sets the callback that handles the OAuth authorization flow. + /// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow. /// /// /// - /// 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. + /// 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. /// /// /// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture - /// 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 . + /// the authorization code from the OAuth redirect. /// /// - 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; } /// @@ -142,9 +113,8 @@ public sealed class ClientOAuthOptions /// /// /// - /// 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. + /// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri", + /// which should instead be configured via . /// /// public IDictionary AdditionalAuthorizationParameters { get; set; } = new Dictionary(); diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 785e3cc..c3b1af7 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -31,17 +31,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly ScopeSelectorDelegate? _scopeSelector; private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; - private readonly Func> _authorizationCallbackHandler; - private readonly bool _validateAuthorizationResponseState; - private readonly bool _validateAuthorizationResponseIssuer; + private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; private readonly Uri? _clientMetadataDocumentUri; - private readonly string? _configuredClientId; - // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) + // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken 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; @@ -50,27 +46,15 @@ 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; /// @@ -98,7 +82,6 @@ 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); @@ -109,48 +92,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient // Set up authorization server selection strategy _authServerSelector = options.AuthServerSelector ?? DefaultAuthServerSelector; - // 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; - } + // Set up authorization URL handler (use default if not provided) + _authorizationRedirectDelegate = options.AuthorizationRedirectDelegate ?? DefaultAuthorizationUrlHandler; _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(); } @@ -162,33 +110,20 @@ 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 and parses the resulting redirect URL. + /// Default authorization URL handler that displays the URL to the user for manual input. /// - /// The context containing the authorization and redirect URIs. + /// The authorization URL to handle. + /// The redirect URI where the authorization code will be sent. /// The to monitor for cancellation requests. - /// The authorization result parsed from the redirect URL. - private static Task DefaultAuthorizationUrlHandler( - AuthorizationCallbackContext context, - CancellationToken cancellationToken) + /// The authorization code entered by the user, or null if none was provided. + private static Task DefaultAuthorizationUrlHandler(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) { Console.WriteLine($"Please open the following URL in your browser to authorize the application:"); - Console.WriteLine($"{context.AuthorizationUri}"); + Console.WriteLine($"{authorizationUrl}"); Console.WriteLine(); - 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"], - }); + Console.Write("Enter the authorization code from the redirect URL: "); + var authorizationCode = Console.ReadLine(); + return Task.FromResult(authorizationCode); } internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) @@ -210,10 +145,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (ShouldRetryWithNewAccessToken(response)) { - // 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 await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, cancellationToken).ConfigureAwait(false); } return response; @@ -229,34 +161,15 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return (tokens.AccessToken, false); } - // 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)) + // 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) { 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, true); + return (null, false); } private static bool ShouldRetryWithNewAccessToken(HttpResponseMessage response) @@ -295,7 +208,6 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient JsonRpcMessage? originalJsonRpcMessage, HttpResponseMessage response, bool attemptedRefresh, - string? usedAccessToken, CancellationToken cancellationToken) { if (response.Headers.WwwAuthenticate.Count == 0) @@ -308,7 +220,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, usedAccessToken, cancellationToken).ConfigureAwait(false); + var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, cancellationToken).ConfigureAwait(false); using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); @@ -329,31 +241,8 @@ 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, 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) + private async Task GetAccessTokenAsync(HttpResponseMessage response, bool attemptedRefresh, CancellationToken cancellationToken) { // Get available authorization servers from the 401 or 403 response var protectedResourceMetadata = await ExtractProtectedResourceMetadata(response, cancellationToken).ConfigureAwait(false); @@ -373,26 +262,17 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) { bool introducesNewScopes = ChallengeIntroducesNewScopes(protectedResourceMetadata); - if (_hasAttemptedStepUp && !introducesNewScopes) + lock (_scopeAccumulatorLock) { - // 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)) + if (_hasAttemptedStepUp && !introducesNewScopes) { - return steppedUpToken.AccessToken; + ThrowFailedToHandleUnauthorizedResponse( + "A repeated insufficient_scope challenge added no scope beyond those already requested, " + + "so step-up authorization cannot satisfy the request."); } - ThrowFailedToHandleUnauthorizedResponse( - "A repeated insufficient_scope challenge added no scope beyond those already requested, " + - "so step-up authorization cannot satisfy the request."); + _hasAttemptedStepUp = true; } - - _hasAttemptedStepUp = true; } // Convert string URIs to Uri objects for the selector @@ -427,28 +307,13 @@ 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 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. + // 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. if (!attemptedRefresh && response.StatusCode == System.Net.HttpStatusCode.Unauthorized && - !string.IsNullOrEmpty(_clientId) && - cachedTokensMatchClientCredentials && - cachedTokens is { RefreshToken: { Length: > 0 } refreshToken }) + await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { RefreshToken: { Length: > 0 } refreshToken }) { var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); if (accessToken is not null) @@ -472,8 +337,6 @@ 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(); @@ -503,15 +366,8 @@ 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); @@ -521,7 +377,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false); + var metadata = await JsonSerializer.DeserializeAsync(stream, McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, cancellationToken).ConfigureAwait(false); if (metadata is null) { @@ -539,94 +395,23 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ThrowFailedToHandleUnauthorizedResponse($"AuthorizationEndpoint must use HTTP or HTTPS. '{metadata.AuthorizationEndpoint}' does not meet this requirement."); } - // 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)."); - } + metadata.ResponseTypesSupported ??= ["code"]; + metadata.GrantTypesSupported ??= ["authorization_code", "refresh_token"]; + metadata.TokenEndpointAuthMethodsSupported ??= ["client_secret_post"]; + metadata.CodeChallengeMethodsSupported ??= ["S256"]; - // 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; + return metadata; } 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 no auth server metadata document was - // discovered, fall back to default endpoint paths per the 2025-03-26 spec. + // 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. return BuildDefaultAuthServerMetadata(authServerUri); } @@ -703,53 +488,24 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { - var codeVerifier = GenerateRandomBase64UrlValue(); + var codeVerifier = GenerateCodeVerifier(); var codeChallenge = GenerateCodeChallenge(codeVerifier); - var state = GenerateRandomBase64UrlValue(); - var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state); + var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge); + var authCode = await _authorizationRedirectDelegate(authUrl, _redirectUri, cancellationToken).ConfigureAwait(false); - var authResult = await _authorizationCallbackHandler( - new AuthorizationCallbackContext - { - AuthorizationUri = authUrl, - RedirectUri = _redirectUri, - }, - cancellationToken).ConfigureAwait(false); - - if (authResult is null) + if (string.IsNullOrEmpty(authCode)) { - ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null authorization result."); + ThrowFailedToHandleUnauthorizedResponse($"The {nameof(AuthorizationRedirectDelegate)} returned a null or empty authorization code."); } - 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); + return await ExchangeCodeForTokenAsync(protectedResourceMetadata, authServerMetadata, authCode!, codeVerifier, cancellationToken).ConfigureAwait(false); } private Uri BuildAuthorizationUrl( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, - string codeChallenge, - string state) + string codeChallenge) { var resourceUri = GetResourceUri(protectedResourceMetadata); @@ -760,7 +516,6 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ["response_type"] = "code", ["code_challenge"] = codeChallenge, ["code_challenge_method"] = "S256", - ["state"] = state, }; if (resourceUri is not null) @@ -881,12 +636,6 @@ 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); @@ -928,7 +677,6 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient LogPerformingDynamicClientRegistration(authServerMetadata.RegistrationEndpoint); - var dcrApplicationType = ResolveApplicationType(_dcrConfiguredApplicationType, _redirectUri); var registrationRequest = new DynamicClientRegistrationRequest { RedirectUris = [_redirectUri.ToString()], @@ -938,7 +686,6 @@ 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); @@ -960,9 +707,7 @@ 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} " + - $"(application_type: '{dcrApplicationType}', redirect_uri: '{_redirectUri}')."); + ThrowFailedToHandleUnauthorizedResponse($"Dynamic client registration failed with status {httpResponse.StatusCode}: {errorContent}"); } using var responseStream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); @@ -996,18 +741,6 @@ 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; @@ -1032,10 +765,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (currentOperationScopes.Count == 0) { - // 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; + 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; + } } // Per SEP-2350: Compute the union of previously requested scopes and newly challenged scopes @@ -1044,13 +780,16 @@ 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. - foreach (var scope in currentOperationScopes) + lock (_scopeAccumulatorLock) { - _accumulatedScopes.Add(scope); - } + 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)); + // Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3). + return string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)); + } } /// @@ -1095,11 +834,14 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return false; } - foreach (var scope in currentOperationScopes) + lock (_scopeAccumulatorLock) { - if (!_accumulatedScopes.Contains(scope)) + foreach (var scope in currentOperationScopes) { - return true; + if (!_accumulatedScopes.Contains(scope)) + { + return true; + } } } @@ -1137,76 +879,6 @@ 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 @@ -1428,7 +1100,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase)); } - private static string GenerateRandomBase64UrlValue() + private static string GenerateCodeVerifier() { #if NET9_0_OR_GREATER Span bytes = stackalloc byte[32]; @@ -1467,98 +1139,6 @@ 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}"); @@ -1575,9 +1155,6 @@ 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 88db6bd..5d145a5 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationOptions.cs @@ -34,24 +34,6 @@ 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 6ad8bf2..8496610 100644 --- a/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs +++ b/src/ModelContextProtocol.Core/Authentication/DynamicClientRegistrationRequest.cs @@ -48,10 +48,4 @@ 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 e1a1df3..9dd440b 100644 --- a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs @@ -25,11 +25,6 @@ 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 62d57b9..3dc6e63 100644 --- a/src/ModelContextProtocol.Core/Authentication/ITokenCache.cs +++ b/src/ModelContextProtocol.Core/Authentication/ITokenCache.cs @@ -3,11 +3,6 @@ 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 ecc5eb3..c949037 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs @@ -164,18 +164,7 @@ internal static class IdentityAssertionGrant ["client_id"] = options.ClientId, }; - 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)) + if (!string.IsNullOrEmpty(options.ClientSecret)) { formData["client_secret"] = options.ClientSecret!; } @@ -185,7 +174,12 @@ internal static class IdentityAssertionGrant formData["scope"] = options.Scope!; } - httpRequest.Content = new FormUrlEncodedContent(formData); + using var requestContent = new FormUrlEncodedContent(formData); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) + { + Content = requestContent + }; + 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 41d2ea3..359c66f 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -28,12 +28,6 @@ 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. -/// /// /// /// @@ -62,15 +56,6 @@ 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. /// @@ -102,22 +87,6 @@ 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; @@ -136,33 +105,12 @@ public sealed class IdentityAssertionGrantProvider Uri authorizationServerUrl, CancellationToken cancellationToken = default) { - // 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); - + // Return cached token if still valid 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 @@ -213,7 +161,6 @@ public sealed class IdentityAssertionGrantProvider Assertion = jag, ClientId = _options.ClientId, ClientSecret = _options.ClientSecret, - TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata), Scope = _options.Scope, }, _httpClient, cancellationToken).ConfigureAwait(false); @@ -223,68 +170,12 @@ 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() { - _tokenAcquisitionLock.Wait(); - try - { - _cachedTokens = null; - } - finally - { - _tokenAcquisitionLock.Release(); - } + _cachedTokens = null; } private string? _resolvedIdpTokenEndpoint; diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs index ecbd055..c6cc7f8 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs @@ -16,17 +16,6 @@ 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 68d4fb8..8126137 100644 --- a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs +++ b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs @@ -35,56 +35,5 @@ 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 7563acd..015d404 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -77,57 +77,27 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) { // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server. - // Adopt it before surfacing the failure so the catch filter leaves the now-owned - // transport alone, and never mask the response by attempting deprecated SSE. + // It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion, + // -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other + // application-level error). Don't fall back to SSE — that would mask the real signal + // and surface a misleading "session id required" error from the SSE GET path. + // Adopt the Streamable HTTP transport and throw the structured exception so the + // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport + // first makes the catch filter below leave the now-owned transport alone. LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; - - if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) - { - throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); - } - - // TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response - // body and status without consuming the network stream a second time. - throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } else { // 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. 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; - } - + // behavior. LogStreamableHttpFailed(_name, response.StatusCode); await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); - await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false); + await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false); } } catch when (ActiveTransport is null) @@ -140,16 +110,7 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport } } - /// - /// Returns when the failed request was the SEP-2575 server/discover probe and the - /// status is one of the two already reads as "this server requires the initialize - /// handshake", meaning the SSE fallback cannot contribute anything the initialize retry won't. - /// - private static bool IsDiscoverProbeRejection(JsonRpcMessage message, HttpStatusCode statusCode) => - statusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound && - message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }; - - private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken) + private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken) { if (_options.KnownSessionId is not null) { @@ -167,20 +128,6 @@ 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); @@ -211,9 +158,6 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")] private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode); - [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} server/discover probe rejected with status code {StatusCode}; skipping the SSE fallback so the initialize handshake is attempted instead.")] - private partial void LogSkippingSseFallbackForDiscoverProbe(string endpointName, HttpStatusCode statusCode); - [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")] private partial void LogUsingStreamableHttp(string endpointName); @@ -222,7 +166,4 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")] private partial void LogUsingSSE(string endpointName); - - [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); -} +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index bccdd7a..a96ba5a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -733,12 +733,6 @@ 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, @@ -937,12 +931,6 @@ 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) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index 67bb21a..d683172 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.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] protected McpClient() { } @@ -29,16 +29,14 @@ 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 or from server/discover result metadata. + /// It is populated during the initialization handshake and is available after a successful connection. /// /// /// This information can be useful for logging, debugging, compatibility checks, and displaying server /// information to users. /// /// - /// - /// The client is not connected, or the server omitted the optional server identity metadata. - /// + /// The client is not connected. public abstract Implementation ServerInfo { get; } /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index d1f2a9d..07a9f2e 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -2,7 +2,6 @@ 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; @@ -171,8 +170,7 @@ 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, or the connected server did not provide optional server identity metadata."); + public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException("The client is not connected."); /// public override string? ServerInstructions => _serverInstructions; @@ -367,6 +365,14 @@ internal sealed partial class McpClientImpl : McpClient // fallback): falling back to initialize wouldn't fix a malformed envelope. throw; } + catch (McpProtocolException ex) when ( + ex.ErrorCode == McpErrorCode.InvalidRequest && + ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) + { + // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. + // This is not evidence of an initialize-handshake server, so do not fall back. + throw; + } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. @@ -380,17 +386,6 @@ internal sealed partial class McpClientImpl : McpClient // never reach here. 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 @@ -437,19 +432,15 @@ internal sealed partial class McpClientImpl : McpClient } else { - var discoveredServerInfo = GetServerInfoFromDiscover(discoverResult!); - if (_logger.IsEnabled(LogLevel.Information)) { LogServerCapabilitiesReceived(_endpointName, capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: discoveredServerInfo is null - ? "(none)" - : JsonSerializer.Serialize(discoveredServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); } _serverCapabilities = discoverResult!.Capabilities; - _serverInfo = discoveredServerInfo; + _serverInfo = discoverResult.ServerInfo; _serverInstructions = discoverResult.Instructions; } @@ -493,30 +484,6 @@ internal sealed partial class McpClientImpl : McpClient LogClientConnected(_endpointName); } - /// - /// 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. diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index f9d353e..6a378ca 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 && - !HasApplicationResultMetadata(result.Meta)) + result.Meta is not { Count: > 0 }) { switch (result.Content.Count) { @@ -150,26 +150,6 @@ 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 99bdc1e..fb91898 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -23,7 +23,6 @@ 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. @@ -67,7 +66,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase { LogTransportConnectFailed(Name, ex); await CloseAsync().ConfigureAwait(false); - throw; + throw new IOException("Failed to connect transport.", ex); } } @@ -108,8 +107,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase throw HttpResponseMessageExtensions.CreateHttpRequestException(response, responseBody); } - - _sseAdopted = true; } private async Task CloseAsync() @@ -132,7 +129,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase } finally { - SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); } } @@ -193,7 +190,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase } else { - SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails + SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails { HttpStatusCode = failureStatusCode, Exception = ex, @@ -201,22 +198,12 @@ internal sealed partial class SseClientSessionTransport : TransportBase LogTransportReadMessagesFailed(Name, ex); _connectionEstablished.TrySetException(ex); + throw; } } finally { - 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); + SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); } } diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 5b29449..a6334aa 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -80,7 +80,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && - ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) + (response.StatusCode == HttpStatusCode.BadRequest || + IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -88,11 +89,10 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) => - statusCode == HttpStatusCode.BadRequest || - (McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion - or McpErrorCode.MissingRequiredClientCapability - or McpErrorCode.HeaderMismatch; + private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => + code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; /// /// Reads a JSON-RPC error envelope from an application/json response body, returning @@ -592,8 +592,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa headers.Add(McpHttpHeaders.Method, method); // Add Mcp-Name header for methods that target a specific named resource -#pragma warning disable MCPEXP002 - string? name = message.Context?.RoutingName ?? message switch + string? name = message switch { JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request => GetParamsStringProperty(request.Params, "name"), @@ -601,7 +600,6 @@ 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 0497d61..b2421e8 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -1,3 +1,2188 @@  - \ No newline at end of file + + + 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.CreateTaskResult + 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.GetTaskRequestParams + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + 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.McpTaskStatus + 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.CreateTaskResult + 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.GetTaskRequestParams + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + 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.McpTaskStatus + 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.CreateTaskResult + 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.GetTaskRequestParams + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + 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.McpTaskStatus + 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.CreateTaskResult + 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.GetTaskRequestParams + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + 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.McpTaskStatus + 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.NotificationMethods.TaskStatusNotification + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + 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.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.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.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + 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.NotificationMethods.TaskStatusNotification + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + 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.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.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.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + 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.NotificationMethods.TaskStatusNotification + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + 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.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.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.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + 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.NotificationMethods.TaskStatusNotification + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + 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.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.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.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + 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 + + + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index d2b0745..61a1872 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -401,6 +401,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { + // Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any + // filters run so they (and downstream handlers) can read client info / capabilities / + // protocol version / log level without re-parsing. + if (_isServer && message is JsonRpcRequest incomingRequest) + { + PopulateContextFromMeta(incomingRequest); + } + Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -536,6 +544,65 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable return result; } + /// + /// Reads the 2026-07-28 protocol's per-request _meta fields off the request and projects them onto + /// so they're available without re-parsing throughout the pipeline. + /// + /// + /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, + /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field + /// that's already set on the context (e.g., + /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone + /// unless explicitly overwritten by a non-null value parsed here. + /// + internal static void PopulateContextFromMeta(JsonRpcRequest request) + { + if (request.Params is not JsonObject paramsObj) + { + return; + } + + if (paramsObj["_meta"] is not JsonObject metaObj) + { + return; + } + + var context = request.Context ??= new JsonRpcMessageContext(); + + if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && + protocolVersion.TryGetValue(out string? protocolVersionValue)) + { + // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already + // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with + // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), + // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather + // than mistaking it for an initialize-handshake server and falling back to initialize. + if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", + McpErrorCode.HeaderMismatch); + } + + context.ProtocolVersion = protocolVersionValue; + } + + if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) + { + context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); + } + + if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) + { + context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + } + + if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) + { + context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + } + /// /// Injects the 2026-07-28 protocol's per-request _meta fields into an outgoing request. /// Protocol version and client info overwrite any existing values; client capabilities are merged @@ -895,10 +962,9 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable tags.Add("network.protocol.name", "http"); } - string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (protocolVersion is not null) + if (NegotiatedProtocolVersion is not null) { - tags.Add("mcp.protocol.version", protocolVersion); + tags.Add("mcp.protocol.version", NegotiatedProtocolVersion); } if (activity is { IsAllDataRequested: true }) diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 3fbef03..ee9f24f 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -7,7 +7,6 @@ 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 index 833f2b2..88055b2 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -104,7 +104,7 @@ public static class McpTasksClientExtensions /// /// Retrieves a task using explicit request parameters. /// - public static async ValueTask GetTaskAsync( + public static ValueTask GetTaskAsync( this McpClient client, GetTaskRequestParams requestParams, CancellationToken cancellationToken = default) @@ -118,19 +118,11 @@ public static class McpTasksClientExtensions #endif ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); - requestParams = new GetTaskRequestParams - { - TaskId = requestParams.TaskId, - Meta = GetMetaWithTaskCapability(requestParams.Meta), - }; - JsonRpcRequest jsonRpcRequest = CreateTaskRequest( + return client.SendRequestAsync( 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."); + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); } /// @@ -156,7 +148,6 @@ public static class McpTasksClientExtensions JsonObject paramsObj = new() { ["taskId"] = requestParams.TaskId, - ["_meta"] = GetMetaWithTaskCapability(requestParams.Meta), }; if (requestParams.InputResponses is { Count: > 0 } inputResponses) @@ -166,10 +157,11 @@ public static class McpTasksClientExtensions McpJsonUtilities.DefaultOptions.GetTypeInfo>()); } - JsonRpcRequest jsonRpcRequest = CreateTaskRequest( - TasksProtocol.MethodTasksUpdate, - paramsObj, - requestParams.TaskId); + JsonRpcRequest jsonRpcRequest = new() + { + Method = TasksProtocol.MethodTasksUpdate, + Params = paramsObj, + }; JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult) @@ -198,7 +190,7 @@ public static class McpTasksClientExtensions /// /// Requests task cancellation using explicit request parameters. /// - public static async ValueTask CancelTaskAsync( + public static ValueTask CancelTaskAsync( this McpClient client, CancelTaskRequestParams requestParams, CancellationToken cancellationToken = default) @@ -212,32 +204,13 @@ public static class McpTasksClientExtensions #endif ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); - requestParams = new CancelTaskRequestParams - { - TaskId = requestParams.TaskId, - Meta = GetMetaWithTaskCapability(requestParams.Meta), - }; - JsonRpcRequest jsonRpcRequest = CreateTaskRequest( + return client.SendRequestAsync( 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(); + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); } - 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, diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj index cfeef00..0ebeb57 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -9,6 +9,8 @@ README.md $(NoWarn);MCPEXP001;MCPEXP002 + + false diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs index 5176cfa..c9d92a0 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs @@ -20,9 +20,4 @@ namespace ModelContextProtocol.Extensions.Tasks; /// public sealed class CancelTaskResult : Result { - /// Initializes a new task cancellation acknowledgement. - public CancelTaskResult() - { - ResultType = "complete"; - } } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs index 0a74886..b9f59f3 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs @@ -19,9 +19,4 @@ namespace ModelContextProtocol.Extensions.Tasks; /// public sealed class UpdateTaskResult : Result { - /// Initializes a new task update acknowledgement. - public UpdateTaskResult() - { - ResultType = "complete"; - } } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs deleted file mode 100644 index 9045cea..0000000 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs +++ /dev/null @@ -1,16 +0,0 @@ -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.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index e61466a..06073ce 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -19,72 +19,34 @@ 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)); + builder.Services.AddSingleton>( + sp => new McpTasksPostConfigureOptions(store, sp.GetService())); return builder; } - private sealed class McpTasksConfigureOptions( - IMcpTaskStore store, - IServiceScopeFactory serviceScopeFactory, - ILoggerFactory? loggerFactory, - McpTasksOptions taskOptions) : IConfigureOptions + private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions { 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 ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); - public void Configure(McpServerOptions options) + public void PostConfigure(string? name, McpServerOptions options) { #if NET ArgumentNullException.ThrowIfNull(options); @@ -100,217 +62,133 @@ public static class McpTasksBuilderExtensions } 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, - }); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask }); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask }); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, 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) => + options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) => + { + if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) { - 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."); - } + var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + var taskId = taskInfo.TaskId; + var cts = new CancellationTokenSource(); + _cancellationSources[taskId] = cts; + var taskCancellationToken = cts.Token; - if (executionMode == McpTaskExecutionMode.Synchronous) + _ = Task.Run(async () => { - return await next(request, cancellationToken).ConfigureAwait(false); - } - - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || - !HasTaskExtensionOptIn(request.JsonRpcRequest)) - { - if (executionMode == McpTaskExecutionMode.Required) + try { - throw CreateMissingTasksCapabilityException(); + using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store)) + { + 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 BuildInitialAlternateToolFilter 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); + } + finally + { + if (_cancellationSources.TryRemove(taskId, out var registeredCts)) + { + registeredCts.Dispose(); + } + } + } } + catch (Exception outer) + { + // The inner handlers above record every expected outcome. Reaching here means a + // store operation inside one of those handlers (or the task scope) threw, most + // likely from a custom IMcpTaskStore. Record the failure best-effort and never let + // it surface as an unobserved task exception. + _logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId); - return await next(request, cancellationToken).ConfigureAwait(false); - } + 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); + } - return await RunAsTaskAsync(next, request, cancellationToken).ConfigureAwait(false); - }); - } + if (_cancellationSources.TryRemove(taskId, out var leftoverCts)) + { + leftoverCts.Dispose(); + } + } + }, CancellationToken.None); - 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; + return ResultOrAlternate.FromAlternate( + ToCreateTaskResult(taskInfo), + McpTasksJsonContext.Default.CreateTaskResult); } - 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); - } + return await next(request, cancellationToken).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); @@ -327,7 +205,6 @@ public static class McpTasksBuilderExtensions 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); @@ -347,7 +224,6 @@ public static class McpTasksBuilderExtensions 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); @@ -375,29 +251,11 @@ public static class McpTasksBuilderExtensions } } - 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 HasTaskExtensionOptIn(JsonObject? meta) => + meta is not null && + meta[MetaKeys.ClientCapabilities] is JsonObject caps && + caps["extensions"] is JsonObject exts && + exts.ContainsKey(TasksProtocol.ExtensionId); private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion); diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs deleted file mode 100644 index 818a53f..0000000 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 index 74b227f..31445fd 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs @@ -37,10 +37,14 @@ public static class McpTasksServerExtensions cancellationToken); } - internal static McpServer WithMcpTaskOutgoingRequestInterceptor( - this McpServer server, - string taskId, - IMcpTaskStore store) + /// + /// Creates a scope that routes server-initiated requests through the specified task store. + /// + /// The server whose outgoing requests should be redirected. + /// The related task identifier. + /// The task store used to surface input requests. + /// An that restores the previous outgoing-request behavior. + public static IDisposable CreateMcpTaskScope(this McpServer server, string taskId, IMcpTaskStore store) { #if NET ArgumentNullException.ThrowIfNull(server); @@ -52,7 +56,7 @@ public static class McpTasksServerExtensions if (store is null) throw new ArgumentNullException(nameof(store)); #endif - return server.WithOutgoingRequestInterceptor(async (method, paramsNode, cancellationToken) => + return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => { var requestId = Guid.NewGuid().ToString("N"); diff --git a/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs b/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs index db939c6..2ed0635 100644 --- a/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs @@ -42,10 +42,6 @@ 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 7e24bc5..7f07be6 100644 --- a/src/ModelContextProtocol/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpServerBuilderExtensions.cs @@ -859,51 +859,6 @@ 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 b3c1d91..06ef940 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -1,24 +1,24 @@ # MCP C# SDK -[![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) +[![NuGet version](https://img.shields.io/nuget/vpre/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.2.0) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-preview.3) 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/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.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](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](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.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.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.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.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.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. +- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/vpre/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 98c225d..abd593f 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -535,16 +535,6 @@ public static class NodeHelpers "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) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index a8ab66e..2b505f9 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -7,20 +7,18 @@ true false - - $(NoWarn);MCPEXP001;MCPEXP003 + + $(NoWarn);MCPEXP001 - - diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 708cd93..687d78e 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,20 +1,17 @@ -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(); -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(); +// 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" })] + }); _ = server.RunAsync(); // Connect a client using a stream-based transport over the same in-memory pipe. @@ -23,18 +20,13 @@ await using McpClient client = await McpClient.CreateAsync( // List all tools. var tools = await client.ListToolsAsync(); -var echo = tools.FirstOrDefault(t => t.Name == "Echo"); -if (echo is null) +if (tools.Count == 0) { - 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}"); + throw new Exception("Expected at least one tool."); } +// 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")) { @@ -42,11 +34,3 @@ 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 6ad643e..76a7201 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -93,91 +93,6 @@ 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/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 1b8be84..51eba1f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -46,29 +46,13 @@ public class ClientConformanceTests [InlineData("auth/resource-mismatch")] [InlineData("auth/pre-registration")] - // 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")] - [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")] + // Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support. + // [InlineData("auth/client-credentials-jwt")] + // [InlineData("auth/client-credentials-basic")] public async Task RunConformanceTest(string scenario) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 914b98d..32e3d00 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -8,7 +8,6 @@ using System.Net; using System.Net.ServerSentEvents; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; namespace ModelContextProtocol.AspNetCore.Tests; @@ -33,18 +32,6 @@ 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(); @@ -123,23 +110,6 @@ 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() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs deleted file mode 100644 index ae5e19f..0000000 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index d64a559..0000000 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ /dev/null @@ -1,243 +0,0 @@ -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 9126331..12bae02 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -57,7 +57,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : base.Dispose(); } - private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false) + private async Task StartServerAsync(RequestDelegate handler) { Builder.Services.Configure(options => { @@ -65,14 +65,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : }); _app = Builder.Build(); - if (acceptGet) - { - _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler); - } - else - { - _app.MapPost("/mcp", handler); - } + _app.MapPost("/mcp", handler); await _app.StartAsync(TestContext.Current.CancellationToken); } @@ -309,63 +302,6 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Assert.False(initializeReceived); } - [Theory] - [InlineData(HttpStatusCode.Unauthorized)] - [InlineData(HttpStatusCode.Forbidden)] - [InlineData(HttpStatusCode.InternalServerError)] - public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode) - { - var ct = TestContext.Current.CancellationToken; - var initializeRequests = 0; - var sseGetRequests = 0; - - await StartServerAsync(async context => - { - if (HttpMethods.IsGet(context.Request.Method)) - { - sseGetRequests++; - context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; - return; - } - - var message = await JsonSerializer.DeserializeAsync( - context.Request.Body, - GetJsonTypeInfo(), - ct); - - if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) - { - initializeRequests++; - } - - await WriteJsonRpcErrorAsync( - context, - statusCode, - code: (int)McpErrorCode.InvalidRequest, - message: "non-modern structured error"); - }, acceptGet: true); - - await using var transport = new HttpClientTransport(new() - { - Endpoint = new("http://localhost:5000/mcp"), - TransportMode = HttpTransportMode.AutoDetect, - }, HttpClient, LoggerFactory); - - var exception = await Assert.ThrowsAsync(async () => - { - await using var client = await McpClient.CreateAsync( - transport, - new McpClientOptions(), - loggerFactory: LoggerFactory, - cancellationToken: ct); - }); - - Assert.Equal(statusCode, exception.StatusCode); - Assert.Contains("non-modern structured error", exception.Message); - Assert.Equal(0, initializeRequests); - Assert.Equal(0, sseGetRequests); - } - [Fact] public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() { @@ -388,10 +324,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : { 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), - }, + ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), @@ -461,10 +394,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : { 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), - }, + ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 365cf28..ab9d04b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -24,10 +24,10 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" }; }).WithHttpTransport(options => { - // SessionMode = HttpServerSessionMode.Stateful maps the GET/DELETE endpoints and opts the author into sessions. Starting with + // Stateless = false 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. SessionMode = HttpServerSessionMode.Stateless (the default) serves them natively. - options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; + // refused on a session-enabled server. Stateless = true (the default) serves them natively. + options.Stateless = stateless; }); _app = Builder.Build(); @@ -69,7 +69,7 @@ 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 (SessionMode = HttpServerSessionMode.Stateful). The server refuses that + // 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-path client falls back // to the initialize handshake. await StartAsync(stateless: false); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs deleted file mode 100644 index 01a4b26..0000000 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs +++ /dev/null @@ -1,344 +0,0 @@ -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 142536b..d593ff2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -11,13 +11,12 @@ 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 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. +/// server that deliberately opted into sessions ( +/// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports +/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. +/// The client must then auto-downgrade to the initialize handshake, obtain the stateful session +/// the server author opted into, and continue to work, including a server→client elicitation round-trip +/// resolved over the stateful session via the initialize-handshake backcompat resolver. /// public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -38,7 +37,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe [McpServerTool(Name = "greet_via_elicit")] private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { - // Server-to-client round-trip: only works when the session is stateful, which is exactly what + // Server→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 { @@ -53,19 +52,15 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe return $"Hello, {name}!"; } - private Task StartStatefulServerAsync() => - StartStatefulServerAsync(options => options.Stateless = false); - - private Task StartStatefulSessionModeServerAsync() => - StartStatefulServerAsync(options => options.SessionMode = HttpServerSessionMode.Stateful); - - private async Task StartStatefulServerAsync(Action configureTransport) + private async Task StartStatefulServerAsync() { Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" }; }) - .WithHttpTransport(configureTransport) + // 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) .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); _app = Builder.Build(); @@ -93,19 +88,6 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe { await StartStatefulServerAsync(); - await AssertDefaultClientDowngradesAndToolsWorkAsync(); - } - - [Fact] - public async Task DefaultClient_AgainstStatefulSessionMode_DowngradesToInitialize_AndToolsWork() - { - await StartStatefulSessionModeServerAsync(); - - await AssertDefaultClientDowngradesAndToolsWorkAsync(); - } - - private async Task AssertDefaultClientDowngradesAndToolsWorkAsync() - { await using var client = await ConnectDefaultClientAsync(); // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs index fbf1a5f..ac19953 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs @@ -2,8 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using OpenTelemetry.Trace; -using System.Diagnostics; namespace ModelContextProtocol.AspNetCore.Tests; @@ -12,57 +10,6 @@ public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStream protected override bool UseStreamableHttp => true; protected override bool Stateless => true; - // In stateless mode each HTTP request is served by a fresh, un-negotiated McpServer, so the - // session-level NegotiatedProtocolVersion is null when the server span is tagged. The tag must - // therefore come from the per-request MCP-Protocol-Version header / _meta value. Exercising more - // than one version proves the tag tracks the per-request value rather than coincidentally matching - // a single hard-coded version (and would regress to an absent tag if the per-request fallback were - // removed, since the negotiated value is null at tagging time). - [Theory] - [InlineData("2025-11-25")] - [InlineData("2026-07-28")] - public async Task ServerActivity_TagsPerRequestProtocolVersion_InStatelessMode(string protocolVersion) - { - var activities = new List(); - string? capturedNegotiatedProtocolVersion = null; - - var protocolVersionTool = McpServerTool.Create( - (RequestContext context) => - { - capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; - return "ok"; - }, - new() { Name = "stateless-capture-version" }); - - using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource("Experimental.ModelContextProtocol") - .AddInMemoryExporter(activities) - .Build()) - { - Builder.Services.AddMcpServer() - .WithHttpTransport(ConfigureStateless) - .WithTools([protocolVersionTool]); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectAsync(configureClient: options => - options.ProtocolVersion = protocolVersion); - - await client.CallToolAsync("stateless-capture-version", cancellationToken: TestContext.Current.CancellationToken); - } - - Assert.Contains(activities, activity => - activity.DisplayName == "tools/call stateless-capture-version" && - activity.Kind == ActivityKind.Server && - activity.GetTagItem("mcp.protocol.version") as string == protocolVersion); - - // Once the per-request version is applied, the request-scoped server settles on that same version, - // so the value tagged on the span and the version the session ends up negotiating agree. - Assert.Equal(protocolVersion, capturedNegotiatedProtocolVersion); - } - [Fact] public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 0a0a6bb..889a7da 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -6,9 +6,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; -using OpenTelemetry.Trace; using System.Collections.Concurrent; -using System.Diagnostics; using System.Net; using System.Security.Claims; using System.Threading; @@ -48,60 +46,6 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name); } - // 2025-11-25 is negotiated through the initialize handshake, so in the (default) stateful configuration - // the session-level NegotiatedProtocolVersion is populated and the client sends a matching - // MCP-Protocol-Version header on every follow-up request. This asserts the span carries the negotiated - // version and that the tagged value agrees with the session's NegotiatedProtocolVersion. - // - // A scenario where the per-request and negotiated versions differ is intentionally not asserted here: - // a single MCP session must not change protocol versions, so the server rejects a follow-up request - // whose header/_meta version differs from the negotiated one, and a conformant client never sends a - // differing header. Reaching that divergence would require a hand-crafted request on an invalid - // (failing) code path and would pin the current ordering of tagging relative to version-change - // validation. Instead the two inputs to the tag are covered independently: the per-request path in - // stateless mode (MapMcpStatelessTests, where NegotiatedProtocolVersion is null at tagging time) and the - // negotiated-only fallback over a header-less transport (DiagnosticTests). - [Fact] - public async Task ServerActivity_TagsNegotiatedProtocolVersion() - { - var activities = new List(); - string? capturedNegotiatedProtocolVersion = null; - - var protocolVersionTool = McpServerTool.Create( - (RequestContext context) => - { - capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; - return "ok"; - }, - new() { Name = "negotiated-capture-version" }); - - using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() - .AddSource("Experimental.ModelContextProtocol") - .AddInMemoryExporter(activities) - .Build()) - { - Builder.Services.AddMcpServer() - .WithHttpTransport(ConfigureStateless) - .WithTools([protocolVersionTool]); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectAsync(configureClient: options => - options.ProtocolVersion = "2025-11-25"); - - await client.CallToolAsync("negotiated-capture-version", cancellationToken: TestContext.Current.CancellationToken); - } - - Assert.Contains(activities, activity => - activity.DisplayName == "tools/call negotiated-capture-version" && - activity.Kind == ActivityKind.Server && - activity.GetTagItem("mcp.protocol.version") as string == "2025-11-25"); - - Assert.Equal("2025-11-25", capturedNegotiatedProtocolVersion); - } - [Fact] public async Task StreamableHttpMode_Works_WithRootEndpoint() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 03af131..3d8abb0 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 (SessionMode = HttpServerSessionMode.Stateful), so a client pinned + // the handler refuses a request when the server opted into sessions (Stateless = false), 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 43a3c12..ef68321 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -239,9 +239,10 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : [Fact] public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() { - // Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing - // intermediate notifications need an eager response flush. Per-request-metadata protocol - // revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status. + // Regression test for: Tool calls that last over HttpClient timeout without producing + // intermediate notifications will timeout because HttpClient doesn't see the 200 response + // until the first message is written. When primingItem is null (no ISseEventStreamStore), + // we should flush the response stream so HttpClient sees the 200 immediately. Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -273,11 +274,7 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : TransportMode = transportMode, }, shortTimeoutClient, LoggerFactory); - await using var mcpClient = await McpClient.CreateAsync( - transport, - new McpClientOptions { ProtocolVersion = "2025-11-25" }, - LoggerFactory, - TestContext.Current.CancellationToken); + await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout // because the response stream is flushed immediately after receiving the request diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 781aa71..d75877b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -60,7 +60,6 @@ - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index df5d4bd..aa724bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -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.SessionMode = HttpServerSessionMode.Stateful); + ]).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); _app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthEventTests.cs index 1866dfe..7aafd31 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"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, @@ -76,7 +76,7 @@ public class AuthEventTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = 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 693c779..e49cb5b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -11,7 +11,6 @@ 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; @@ -42,209 +41,12 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, - }, - }, HttpClient, LoggerFactory); - - await using var client = await McpClient.CreateAsync( - 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] @@ -276,7 +78,7 @@ public class AuthTests : OAuthTestBase ClientId = "unregistered-demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -296,7 +98,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, DynamicClientRegistration = new() { ClientName = "Test MCP Client", @@ -307,33 +109,6 @@ 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] @@ -347,91 +122,8 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - 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, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ClientMetadataDocumentUri = new Uri(ClientMetadataDocumentUrl) }, }, HttpClient, LoggerFactory); @@ -455,7 +147,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), DynamicClientRegistration = new() { @@ -484,12 +176,8 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri("http://invalid-cimd.example.com"), - DynamicClientRegistration = new() - { - ApplicationType = "web", - }, }, }, HttpClient, LoggerFactory); @@ -510,7 +198,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ClientMetadataDocumentUri = new Uri(uri), }, }, HttpClient, LoggerFactory); @@ -575,7 +263,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -602,10 +290,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - lastAuthorizationUri = context.AuthorizationUri; - return HandleAuthorizationUrlAsync(context, ct); + lastAuthorizationUri = uri; + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, AdditionalAuthorizationParameters = new Dictionary { @@ -621,10 +309,8 @@ public class AuthTests : OAuthTestBase Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query); } - [Theory] - [InlineData("redirect_uri")] - [InlineData("state")] - public async Task CannotOverrideExistingParameters_WithExtraParams(string parameterName) + [Fact] + public async Task CannotOverrideExistingParameters_WithExtraParams() { await using var app = await StartMcpServerAsync(); @@ -636,10 +322,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, AdditionalAuthorizationParameters = new Dictionary { - [parameterName] = "custom_value", + ["redirect_uri"] = "custom_value", } }, }, HttpClient, LoggerFactory); @@ -661,7 +347,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -683,7 +369,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -711,11 +397,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -764,11 +450,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -859,11 +545,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -964,11 +650,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScopes.Add(query["scope"].ToString()); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -998,122 +684,6 @@ 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() { @@ -1178,11 +748,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScopes.Add(query["scope"].ToString()); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -1273,11 +843,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScopes.Add(query["scope"].ToString()); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -1321,7 +891,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1360,7 +930,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1388,7 +958,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1421,7 +991,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1438,69 +1008,6 @@ 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() { @@ -1548,7 +1055,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1603,7 +1110,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1650,7 +1157,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1692,7 +1199,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1738,7 +1245,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1785,7 +1292,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -1937,7 +1444,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -2054,7 +1561,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -2156,7 +1663,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); @@ -2164,81 +1671,6 @@ 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() { @@ -2255,11 +1687,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -2287,11 +1719,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -2326,11 +1758,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, }, }, HttpClient, LoggerFactory); @@ -2363,11 +1795,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), }, @@ -2394,11 +1826,11 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - var query = QueryHelpers.ParseQuery(context.AuthorizationUri.Query); + var query = QueryHelpers.ParseQuery(uri.Query); requestedScope = query["scope"].ToString(); - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"], }, @@ -2432,7 +1864,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, ScopeSelector = scopes => { capturedInput = scopes; @@ -2462,10 +1894,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope"); - return HandleAuthorizationUrlAsync(context, ct); + scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, ScopeSelector = _ => null, }, @@ -2492,10 +1924,10 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { - scopePresent = QueryHelpers.ParseQuery(context.AuthorizationUri.Query).ContainsKey("scope"); - return HandleAuthorizationUrlAsync(context, ct); + scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, ScopeSelector = _ => [], }, @@ -2507,21 +1939,6 @@ 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() { @@ -2538,7 +1955,7 @@ public class AuthTests : OAuthTestBase OAuth = new ClientOAuthOptions() { RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = 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 deleted file mode 100644 index 43a69de..0000000 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DcrFailureTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 825ebe2..0000000 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/DefaultAuthorizationUrlHandlerTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -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 80167b0..f9a4b64 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -106,41 +106,18 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable return app; } - protected async Task HandleAuthorizationUrlAsync( - ModelContextProtocol.Authentication.AuthorizationCallbackContext authorizationContext, - CancellationToken cancellationToken) + protected async Task HandleAuthorizationUrlAsync(Uri authorizationUri, Uri redirectUri, CancellationToken cancellationToken) { - using var redirectResponse = await HttpClient.GetAsync(authorizationContext.AuthorizationUri, cancellationToken); + using var redirectResponse = await HttpClient.GetAsync(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 new ModelContextProtocol.Authentication.AuthorizationResult - { - Code = queryParams["code"], - State = queryParams["state"], - Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null, - }; + return queryParams["code"]; } 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 5b6f054..fb9e2bf 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs @@ -1,6 +1,5 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; namespace ModelContextProtocol.AspNetCore.Tests.OAuth; @@ -27,10 +26,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { authDelegateCalledInitially = true; - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, TokenCache = tokenCache, }, @@ -41,7 +40,7 @@ public class TokenCacheTests : OAuthTestBase // Just connecting should trigger auth and storage. } - Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token"); + Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token"); Assert.NotNull(tokenCache.LastStoredToken); var authDelegateCalledAgain = false; @@ -54,10 +53,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { authDelegateCalledAgain = true; - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, TokenCache = tokenCache }, @@ -65,7 +64,7 @@ public class TokenCacheTests : OAuthTestBase await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when token is valid"); + Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate should not be called when token is valid"); } [Fact] @@ -83,7 +82,7 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, TokenCache = tokenCache }, }, HttpClient, LoggerFactory); @@ -110,10 +109,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { authDelegateCalled = true; - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, TokenCache = tokenCache, }, @@ -121,7 +120,7 @@ public class TokenCacheTests : OAuthTestBase await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(authDelegateCalled, "AuthorizationCallbackHandler should be called when cached token is invalid"); + Assert.True(authDelegateCalled, "AuthorizationRedirectDelegate should be called when cached token is invalid"); Assert.NotNull(tokenCache.LastStoredToken); Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } @@ -142,10 +141,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { authDelegateCalledInitially = true; - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, TokenCache = tokenCache, }, @@ -156,7 +155,7 @@ public class TokenCacheTests : OAuthTestBase // Just connecting should trigger auth and storage. } - Assert.True(authDelegateCalledInitially, "AuthorizationCallbackHandler should be called to get initial token"); + Assert.True(authDelegateCalledInitially, "AuthorizationRedirectDelegate should be called to get initial token"); Assert.False(TestOAuthServer.HasRefreshedToken, "Token should not have been refreshed yet"); Assert.NotNull(tokenCache.LastStoredToken); @@ -172,10 +171,10 @@ public class TokenCacheTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = (context, ct) => + AuthorizationRedirectDelegate = (uri, redirect, ct) => { authDelegateCalledAgain = true; - return HandleAuthorizationUrlAsync(context, ct); + return HandleAuthorizationUrlAsync(uri, redirect, ct); }, TokenCache = tokenCache }, @@ -183,447 +182,11 @@ public class TokenCacheTests : OAuthTestBase await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.False(authDelegateCalledAgain, "AuthorizationCallbackHandler should not be called when refresh token is valid"); + Assert.False(authDelegateCalledAgain, "AuthorizationRedirectDelegate 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 d3848e0..edf58e7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -32,8 +32,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() - .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]) - .WithTools(); + .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); _app = Builder.Build(); _app.MapMcp(); @@ -107,9 +106,6 @@ 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. @@ -132,103 +128,12 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); - var capabilities = json["result"]!["capabilities"]!.AsObject(); - Assert.False(capabilities.ContainsKey("logging")); - Assert.True(capabilities.ContainsKey("tools")); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. 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] @@ -274,7 +179,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.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] @@ -352,52 +257,26 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); } - [Theory] - [InlineData(null)] - [InlineData("null")] - [InlineData("{}")] - [InlineData("[]")] - [InlineData("42")] - public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson) + [Fact] + public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); - var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ","; - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty + - @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); 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.Equal((int)McpErrorCode.HeaderMismatch, 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() + public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); @@ -407,61 +286,10 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - // The 2025-03-26 header default only applies when the server has "no other way to identify the - // version". The body metadata is another way, so the transport does not reject the request on the - // strength of a reserved _meta value. stdio, which has no header at all, behaves identically. - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.NotNull(json["result"]); - } - - [Fact] - public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022() - { - await StartAsync(); - - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"; - - using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add("Mcp-Method", "server/discover"); - using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - - var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); - Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue()); - } - - [Fact] - public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored() - { - await StartAsync(); - - // No header and no established era means legacy handling, where the reserved namespace is opaque. - // A value we cannot read is not a version claim, so it must not turn a legacy request into an error. - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}"; - - using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.NotNull(json["result"]!["tools"]); - } - - [Fact] - public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored() - { - await StartAsync(); - - // A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783. - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}"; - - using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.NotNull(json["result"]!["tools"]); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } [Fact] @@ -482,13 +310,11 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn } [Fact] - public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer() + public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() { await StartAsync(); - var body = - @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," + - @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}"; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -496,63 +322,6 @@ 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] @@ -564,92 +333,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn request.Headers.Accept.Add(new("text/event-stream")); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - // SessionMode = HttpServerSessionMode.Stateless doesn't map the GET endpoint - per SEP-2567 the standalone SSE + // Stateless=true (the new default) 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 166622d..73d0007 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -35,7 +35,10 @@ 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) => { @@ -83,8 +86,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 stateful server refuses these requests to allow - // a client to fall back to `initialize` if it supports it. + // served natively only on a stateless server; a Stateless=false server refuses these requests so dual-era + // clients fall back to initialize. await StartAsync(stateless: true); using var request = CreateBlockingToolRequest(july2026Protocol: true); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 8721820..29e6948 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 SessionMode = HttpServerSessionMode.Stateful since the session is required. - options.SessionMode = HttpServerSessionMode.Stateful; + // Resumability is a stateful concern; pin Stateless = false now that the new default is true. + options.Stateless = false; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 2cb1d73..2d71f40 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -27,9 +28,12 @@ public class ServerConformanceTests( } [Fact] - public async Task RunConformanceTest_JsonSchema202012() + public async Task RunPendingConformanceTest_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"); @@ -38,9 +42,12 @@ public class ServerConformanceTests( } [Fact] - public async Task RunConformanceTest_ServerSsePolling() + public async Task RunPendingConformanceTest_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"); @@ -127,31 +134,6 @@ public class ServerConformanceTests( $"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 56fe09b..7609e82 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.SessionMode = HttpServerSessionMode.Stateful); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index a95d989..52b0d36 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -10,7 +9,6 @@ using System.Diagnostics; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -156,43 +154,6 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType(toolContent).Text); } - [Fact] - public async Task ClientCapabilities_AreAvailableFromInjectedServer() - { - await StartAsync(); - var clientOptions = new McpClientOptions(); - clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); - clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException(); - clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); - await using var client = await ConnectMcpClientAsync(clientOptions); - - var toolResponse = await client.CallToolAsync( - "getClientCapabilities", - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal( - "sampling|roots|elicitation", - Assert.IsType(Assert.Single(toolResponse.Content)).Text); - } - - [Fact] - public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode() - { - await StartAsync(); - var clientOptions = new McpClientOptions(); - clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); - clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); - await using var client = await ConnectMcpClientAsync(clientOptions); - - var toolResponse = await client.CallToolAsync( - "testInterceptedRequests", - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal( - "intercepted-sample|Seattle", - Assert.IsType(Assert.Single(toolResponse.Content)).Text); - } - [Fact] public async Task UnsolicitedNotification_Fails_WithInvalidOperationException() { @@ -419,203 +380,13 @@ 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."; - // The declaration is visible to application code, but it cannot make a session-dependent request safe. - Assert.NotNull(server.ClientCapabilities?.Sampling); + // Even when the client has sampling support, it should not be advertised in stateless mode. + Assert.Null(server.ClientCapabilities); var asSamplingChatClientEx = Assert.Throws(() => server.AsSamplingChatClient()); Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message); @@ -635,8 +406,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedRootsErrorMessage = "Roots are not supported in stateless mode."; - // The declaration is visible to application code, but it cannot make a session-dependent request safe. - Assert.NotNull(server.ClientCapabilities?.Roots); + // Even when the client has roots support, it should not be advertised in stateless mode. + Assert.Null(server.ClientCapabilities); var requestRootsEx = Assert.Throws(() => server.RequestRootsAsync(new())); Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message); @@ -653,8 +424,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode."; - // The declaration is visible to application code, but it cannot make a session-dependent request safe. - Assert.NotNull(server.ClientCapabilities?.Elicitation); + // Even when the client has elicitation support, it should not be advertised in stateless mode. + Assert.Null(server.ClientCapabilities); var requestElicitationEx = await Assert.ThrowsAsync(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask()); Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message); @@ -666,39 +437,6 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem return ex.Message; } - [McpServerTool(Name = "getClientCapabilities")] - public static string GetClientCapabilities(McpServer server) => - string.Join( - '|', - server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling", - server.ClientCapabilities?.Roots is null ? "no-roots" : "roots", - server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation"); - - [McpServerTool(Name = "testInterceptedRequests")] - public static async Task TestInterceptedRequests(McpServer server) - { - // Background task execution replaces the server-to-client channel with an interceptor that parks the - // request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not - // depend on session affinity, which is why these requests are allowed through in stateless mode. -#pragma warning disable MCPEXP002 - var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch - { - RequestMethods.SamplingCreateMessage => - """{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""", - RequestMethods.ElicitationCreate => - """{"action":"accept","content":{"city":"Seattle"}}""", - _ => throw new UnreachableException(), - }))); -#pragma warning restore MCPEXP002 - - var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?"); - var elicitResult = await intercepted.ElicitAsync( - "Which city?", - options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options }); - - return $"{samplingResponse.Text}|{elicitResult.Content?.City}"; - } - [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -712,12 +450,3 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem public void Report(T value) => handler(value); } } - -public class CityForm -{ - public string? City { get; set; } -} - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(CityForm))] -internal partial class StatelessInterceptorJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 649286b..57b12d2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -4,7 +4,6 @@ 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; @@ -12,7 +11,6 @@ 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; @@ -608,31 +606,6 @@ 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 => @@ -710,9 +683,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper await _app.StartAsync(TestContext.Current.CancellationToken); } - private async Task StartHeaderCapturingServer( - Dictionary capturedHeaders, - bool supportsTasks = false) + private async Task StartHeaderCapturingServer(Dictionary capturedHeaders) { Builder.Services.Configure(options => { @@ -735,48 +706,12 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper Result = JsonSerializer.SerializeToNode(new InitializeResult { ProtocolVersion = "2025-11-25", - Capabilities = new() - { - Tools = new(), - Extensions = supportsTasks - ? new Dictionary - { - ["io.modelcontextprotocol/tasks"] = new JsonObject(), - } - : null, - }, + Capabilities = new() { Tools = new() }, 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 @@ -797,7 +732,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper }); } - if (request.Method is "tools/call" or "tasks/get") + if (request.Method == "tools/call") { // Capture all MCP headers for verification foreach (var header in context.Request.Headers) @@ -808,16 +743,6 @@ 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 dd051e4..2bdd9d7 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -204,31 +204,6 @@ 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() { @@ -1158,14 +1133,8 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper 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); + Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name); + Assert.Equal("73", discoverResult.ServerInfo.Version); return discoverResult; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs index 59cce6b..c632630 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 client has closed the connection, and dispose the client-half of the Pipes. + // Signal to the server the 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 deleted file mode 100644 index d9c148d..0000000 --- a/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs +++ /dev/null @@ -1,298 +0,0 @@ -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 718c782..60d07d4 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -3,7 +3,6 @@ using System.Net.Sockets; using System.Text.Json; using System.Web; using Microsoft.Extensions.Logging; -using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -40,10 +39,10 @@ McpClientOptions options = new() // The default client now prefers the 2026-07-28 protocol (probing with server/discover and // 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") +// 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 initialize-handshake fallback. +if (scenario is "initialize" or "sse-retry") { options.ProtocolVersion = "2025-11-25"; } @@ -72,17 +71,28 @@ 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"); -var parsedConformanceContext = ConformanceContext.Parse(conformanceContext); -var preRegisteredClientId = parsedConformanceContext?.GetString("client_id"); -var preRegisteredClientSecret = parsedConformanceContext?.GetString("client_secret"); +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 oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions { RedirectUri = clientRedirectUri, // Configure the metadata document URI for CIMD. ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct), }; if (preRegisteredClientId is not null) @@ -99,55 +109,12 @@ else }; } -var endpointUri = new Uri(endpoint); -HttpClientTransport clientTransport; -if (scenario is "auth/client-credentials-basic" or "auth/client-credentials-jwt") +var clientTransport = new HttpClientTransport(new() { - 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); -} + Endpoint = new Uri(endpoint), + TransportMode = HttpTransportMode.StreamableHttp, + OAuth = oauthOptions, +}, loggerFactory: consoleLoggerFactory); try { @@ -223,19 +190,6 @@ 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 @@ -366,27 +320,6 @@ 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; @@ -407,12 +340,8 @@ catch (Exception ex) // Copied from ProtectedMcpClient sample // Simulate a user opening the browser and logging in // Copied from OAuthTestBase -static async Task HandleAuthorizationUrlAsync( - AuthorizationCallbackContext authorizationContext, - CancellationToken cancellationToken) +static async Task HandleAuthorizationUrlAsync(Uri authorizationUrl, Uri redirectUri, CancellationToken cancellationToken) { - var authorizationUrl = authorizationContext.AuthorizationUri; - Console.WriteLine("Starting OAuth authorization flow..."); Console.WriteLine($"Simulating opening browser to: {authorizationUrl}"); @@ -426,35 +355,16 @@ static async Task HandleAuthorizationUrlAsync( if (location is not null && !string.IsNullOrEmpty(location.Query)) { - // Parse query string to extract "code", "state", and "iss" parameters + // Parse query string to extract "code" parameter 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) + if (parts.Length == 2 && parts[0] == "code") { - 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]); - } + return 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 dffffa9..c81d8d2 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -15,7 +15,6 @@ - diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 73f6382..22e2227 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -3,7 +3,6 @@ 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; @@ -53,27 +52,8 @@ public class Program .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) => { diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs deleted file mode 100644 index 4441c1d..0000000 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTaskTools.cs +++ /dev/null @@ -1,102 +0,0 @@ -#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 deleted file mode 100644 index b95ecdd..0000000 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs +++ /dev/null @@ -1,62 +0,0 @@ -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 1e9c9fc..50592bb 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/ClientRegistrationRequest.cs @@ -55,12 +55,6 @@ 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 744ab0e..c05a45b 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthServerMetadata.cs @@ -12,8 +12,7 @@ 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")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Issuer { get; init; } + public required string Issuer { get; init; } /// /// Gets or sets the authorization endpoint URL. @@ -179,11 +178,4 @@ 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 73663dc..69eb606 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -28,7 +28,6 @@ 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; @@ -101,52 +100,12 @@ 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. /// @@ -266,7 +225,7 @@ public sealed class Program var metadata = new OAuthServerMetadata { - Issuer = IncludeIssuerInMetadata ? MetadataIssuerOverride ?? $"{_url}{issuerPath}" : null, + Issuer = $"{_url}{issuerPath}", AuthorizationEndpoint = $"{_url}/authorize", TokenEndpoint = $"{_url}/token", JwksUri = $"{_url}/.well-known/jwks.json", @@ -278,14 +237,11 @@ public sealed class Program : ["openid", "profile", "email", "mcp:tools"], TokenEndpointAuthMethodsSupported = ["client_secret_post"], ClaimsSupported = ["sub", "iss", "name", "email", "aud"], - CodeChallengeMethodsSupported = MetadataPathsWithoutPkceSupport.Contains(context.Request.Path) - ? null - : CodeChallengeMethodsSupported, + CodeChallengeMethodsSupported = ["S256"], GrantTypesSupported = ["authorization_code", "refresh_token"], IntrospectionEndpoint = $"{_url}/introspect", RegistrationEndpoint = $"{_url}/register", ClientIdMetadataDocumentSupported = ClientIdMetadataDocumentSupported, - AuthorizationResponseIssParameterSupported = AuthorizationResponseIssParameterSupported ? true : null, }; return Results.Ok(metadata); @@ -417,10 +373,6 @@ public sealed class Program { redirectUrl += $"&state={Uri.EscapeDataString(state)}"; } - if (!string.IsNullOrEmpty(AuthorizationResponseIssuer)) - { - redirectUrl += $"&iss={Uri.EscapeDataString(AuthorizationResponseIssuer)}"; - } return Results.Redirect(redirectUrl); }); @@ -464,7 +416,6 @@ 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(); @@ -714,7 +665,6 @@ 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 6812fe5..d04f216 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -34,13 +34,6 @@ 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 030f056..f93b6ab 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Connections; -using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Serilog; @@ -379,7 +378,7 @@ public class Program serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService()); serviceCollection.AddRoutingCore(); - serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless); + serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.Stateless = true); var appBuilder = new ApplicationBuilder(serviceCollection.BuildServiceProvider()); appBuilder.UseRouting(); @@ -429,8 +428,8 @@ public class Program .WithHttpTransport(options => { // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). - // Set SessionMode = HttpServerSessionMode.Stateful explicitly since sessions are required. - options.SessionMode = HttpServerSessionMode.Stateful; + // Set Stateless = false explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true. + options.Stateless = false; options.EnableLegacySse = true; }); diff --git a/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs b/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs deleted file mode 100644 index 9c38963..0000000 --- a/tests/ModelContextProtocol.Tests/Authentication/ClientOAuthProviderApplicationTypeTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -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 e681757..e5ae047 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -3,7 +3,6 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; -using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -53,39 +52,6 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } - [Fact] - public async Task Client_AllowsDiscoverResultWithoutServerInfo() - { - 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() { @@ -120,19 +86,4 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase 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 557dc56..03f8f5b 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -2,10 +2,7 @@ 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; @@ -209,180 +206,6 @@ 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); - - /// - /// 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 @@ -528,10 +351,7 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : { 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), - }, + ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, }, McpJsonUtilities.DefaultOptions), }); } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index c6f8016..48f4e66 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -4,7 +4,6 @@ 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; @@ -180,13 +179,10 @@ public class McpClientCreationTests(ITestOutputHelper testOutputHelper) : Logged { Capabilities = new ServerCapabilities(), SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], - Meta = new JsonObject + ServerInfo = new Implementation { - [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation - { - Name = "NopTransport", - Version = "1.0.0" - }, McpJsonUtilities.DefaultOptions), + Name = "NopTransport", + Version = "1.0.0" }, }, McpJsonUtilities.DefaultOptions), }); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 94558a6..3afbb52 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -137,53 +137,6 @@ public class McpClientMetaTests : ClientServerTestBase Assert.Contains("bar baz", textContent.Text); } - [Fact] - public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState() - { - Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( - (RequestContext context) => - { - Assert.Null(context.JsonRpcRequest.Context?.ClientInfo); - Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities); - - Assert.Equal("initialized-client", context.Server.ClientInfo?.Name); - Assert.NotNull(context.Server.ClientCapabilities?.Elicitation); - Assert.Null(context.Server.ClientCapabilities?.Sampling); - - return "ok"; - }, - new() { Name = "legacy_meta_tool" })); - - var clientOptions = new McpClientOptions - { - ProtocolVersion = LatestStableVersion, - ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" }, - Handlers = new McpClientHandlers - { - ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), - }, - }; - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "legacy_meta_tool", - Meta = new JsonObject - { - [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode( - new Implementation { Name = "request-client", Version = "2.0.0" }, - McpJsonUtilities.DefaultOptions), - [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( - new ClientCapabilities { Sampling = new SamplingCapability() }, - McpJsonUtilities.DefaultOptions), - }, - }, - TestContext.Current.CancellationToken); - - Assert.Equal("ok", Assert.IsType(Assert.Single(result.Content)).Text); - } - [Fact] public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() { @@ -297,9 +250,9 @@ public class McpClientMetaTests : ClientServerTestBase ClientCapabilities? handlerObservedCapabilities = null; Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( - (McpServer server) => + (RequestContext context) => { - handlerObservedCapabilities = server.ClientCapabilities; + handlerObservedCapabilities = context.Server.ClientCapabilities; return "ok"; }, new() { Name = "capability_probe_tool" })); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs index 82a3bd9..2ee3cec 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs @@ -19,12 +19,6 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase mcpServerBuilder.WithResources(); } - private Task CreateLegacyMcpClientForServer() => - CreateMcpClientForServer(new() - { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, - }); - [McpServerResourceType] private sealed class SubscribableResources { @@ -36,7 +30,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_ReceivesNotifications() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); const string resourceUri = "test://resource/1"; var notificationReceived = new TaskCompletionSource(); @@ -67,7 +61,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_FiltersNotificationsByUri() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); const string subscribedUri = "test://resource/1"; const string otherUri = "test://resource/2"; var notificationCount = 0; @@ -113,7 +107,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_DisposalUnsubscribes() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); const string resourceUri = "test://resource/1"; var notificationCount = 0; @@ -154,7 +148,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_UriOverload_ReceivesNotifications() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); var resourceUri = new Uri("test://resource/1"); var notificationReceived = new TaskCompletionSource(); @@ -185,7 +179,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithNullHandler_ThrowsArgumentNullException() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -199,7 +193,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithNullUri_ThrowsArgumentNullException() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -213,7 +207,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithEmptyUri_ThrowsArgumentException() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -227,7 +221,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_MultipleSubscriptions_BothReceiveNotifications() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); const string uri1 = "test://resource/1"; const string uri2 = "test://resource/2"; var notification1Received = new TaskCompletionSource(); @@ -284,7 +278,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_DisposalIsIdempotent() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); const string resourceUri = "test://resource/1"; var subscription = await client.SubscribeToResourceAsync( @@ -305,7 +299,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_MultipleHandlersSameUri_BothReceiveNotifications() { // Arrange - await using McpClient client = await CreateLegacyMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(); 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 e3da699..af1334f 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -154,10 +154,9 @@ public class McpClientTaskMethodsTests : ClientServerTestBase { await client.CancelTaskAsync(taskId, ct); - // Cancellation is eventually consistent. The task may complete before the cancellation - // request wins the race, but the terminal-task cancellation acknowledgement is idempotent. + // If cancel succeeded, verify the task is cancelled var taskResult = await client.GetTaskAsync(taskId, ct); - Assert.True(taskResult is CancelledTaskResult or CompletedTaskResult); + Assert.IsType(taskResult); } catch (McpProtocolException) { diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs index 1e4fc00..26de71b 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs @@ -104,16 +104,6 @@ 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/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 86a107f..70a9d11 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -195,10 +195,7 @@ public class MrtrIntegrationTests : ClientServerTestBase { SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - Meta = new JsonObject - { - [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MockMrtrServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), - }, + ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; await WriteJsonRpcAsync(serverWriter, discoverResponse); @@ -448,10 +445,7 @@ public class MrtrIntegrationTests : ClientServerTestBase { SupportedVersions = ["2026-07-28"], Capabilities = new ServerCapabilities { Tools = new() }, - Meta = new JsonObject - { - [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MrtrServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), - }, + ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } }, McpJsonUtilities.DefaultOptions), }; await WriteJsonRpcAsync(serverWriter, discoverResponse); diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs index 726a3fb..6f0e8e4 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs @@ -16,8 +16,6 @@ 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() { @@ -29,14 +27,14 @@ public class ClientIntegrationTestFixture TestServerTransportOptions = new() { - Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? testServerExecutable : PlatformDetection.IsMonoRuntime ? "mono" : "dotnet", + Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "TestServer.exe" : 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 ? testServerExecutable : testServerDll]; + TestServerTransportOptions.Arguments = [PlatformDetection.IsMonoRuntime ? "TestServer.exe" : "TestServer.dll"]; } } diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index d01f40a..9c63f12 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -277,7 +277,6 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture tcs = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -308,7 +307,6 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -729,7 +727,6 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture tcs = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -759,7 +756,6 @@ 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 4e500b5..564225a 100644 --- a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs +++ b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs @@ -86,12 +86,6 @@ 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 be965e7..e61c442 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs @@ -147,17 +147,4 @@ 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/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index 3f6a4a3..d3de3a2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -256,10 +256,7 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddSubscribeToResourcesFilter_Logs_When_SubscribeToResources_Called() { - await using McpClient client = await CreateMcpClientForServer(new() - { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, - }); + await using McpClient client = await CreateMcpClientForServer(); await client.SubscribeToResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken); @@ -271,10 +268,7 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromResources_Called() { - await using McpClient client = await CreateMcpClientForServer(new() - { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, - }); + await using McpClient client = await CreateMcpClientForServer(); await client.UnsubscribeFromResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs index 92e81c9..44afcce 100644 --- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -37,7 +37,6 @@ 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"), }); } @@ -53,10 +52,6 @@ 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", @@ -72,8 +67,6 @@ 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) => @@ -95,30 +88,6 @@ 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() { @@ -344,121 +313,6 @@ 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 @@ -498,67 +352,6 @@ 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/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs index 20e55a7..4a5873d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -292,10 +292,7 @@ public class CacheableResultWarningTests : LoggedTest { SupportedVersions = [serverProtocolVersion], Capabilities = new ServerCapabilities(), - Meta = new JsonObject - { - [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MockServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), - }, + ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }, cancellationToken); } diff --git a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs index 7dca600..b1ac90c 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs @@ -1,5 +1,4 @@ using ModelContextProtocol.Protocol; -using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -46,42 +45,4 @@ 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 b225dd6..71afd89 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -44,6 +44,7 @@ public static class DiscoverProtocolTests { Tools = new ToolsCapability { ListChanged = true }, }, + ServerInfo = new Implementation { Name = "test-server", Version = "2.0" }, Instructions = "Use this server for testing.", }; @@ -54,6 +55,7 @@ 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); } @@ -64,6 +66,7 @@ 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 0670f61..4a2d7e6 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 . /// @@ -19,6 +19,7 @@ public static class DiscoverResultCacheableTests { 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 f2e4472..da4afb6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -91,18 +91,6 @@ 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] diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs index e891c8c..3c9b8bb 100644 --- a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs @@ -5,8 +5,9 @@ using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.Tests.Server; /// -/// Verifies composition for the non-alternate -/// and alternate pipelines. +/// Verifies that combining the non-alternate with the +/// alternate fails at configuration time +/// with an actionable message. /// public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { @@ -14,50 +15,24 @@ public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : Log private static McpRequestFilter PassThroughCallToolFilter => next => next; - private static McpRequestInvocationFilter> PassThroughAlternateFilter => - static (context, next, cancellationToken) => next(context, cancellationToken); + private static McpRequestFilter> PassThroughAlternateFilter => + next => next; [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() + public async Task MixingCallToolFilters_WithAlternateFilters_ThrowsActionableError() { 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); + Assert.Contains(nameof(McpRequestFilters.CallToolWithAlternateFilters), ex.Message); + Assert.Contains("AddAuthorizationFilters", ex.Message); + Assert.Contains("WithTasks", ex.Message); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs index 56e8fd1..4245374 100644 --- a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs @@ -75,28 +75,5 @@ public class CustomRequestHandlerCollisionTests(ITestOutputHelper testOutputHelp 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/McpAppsIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs deleted file mode 100644 index aadb669..0000000 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -#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/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 0ab19a3..c0f85a9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -4,7 +4,6 @@ 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; @@ -284,25 +283,16 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); - Assert.True(Assert.IsType(response)["capabilities"]!.AsObject().ContainsKey("logging")); }); } - [Theory] - [InlineData("2025-11-25", "\"2025-03-26\"", null)] - [InlineData("2025-11-25", "\"2026-07-28\"", null)] - [InlineData("2025-11-25", "{}", null)] - [InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)] - [InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)] - [InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)] - [InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)] - public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata( - string transportVersion, string metadataVersionJson, McpErrorCode? expectedError) + [Fact] + public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() { var ct = TestContext.Current.CancellationToken; await using var transport = new TestServerTransport(); @@ -312,44 +302,71 @@ public class McpServerTests : LoggedTest await using var server = McpServer.Create(transport, options, LoggerFactory); var runTask = server.RunAsync(ct); - var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var rejectedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); transport.OnMessageSent = message => { - if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1") + if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1") { - response.TrySetResult(message); + rejectedResponse.TrySetResult(error); + } + else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2") + { + acceptedResponse.TrySetResult(message); } }; await transport.SendClientMessageAsync(new JsonRpcRequest { Id = new RequestId(1), - Method = RequestMethods.Ping, + Method = RequestMethods.ToolsList, Params = new JsonObject { ["_meta"] = new JsonObject { - [MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson), + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, }, }, Context = new JsonRpcMessageContext { - ProtocolVersion = transportVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, }, }, ct); - var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - if (expectedError is { } errorCode) + var error = await rejectedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Null(server.NegotiatedProtocolVersion); + + var clientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }; + var clientCapabilities = new ClientCapabilities(); + await transport.SendClientMessageAsync(new JsonRpcRequest { - Assert.Equal((int)errorCode, Assert.IsType(message).Error.Code); - Assert.Null(server.NegotiatedProtocolVersion); - } - else - { - Assert.IsType(message); - Assert.Equal(transportVersion, server.NegotiatedProtocolVersion); - } + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.DefaultOptions), + [MetaKeys.ClientCapabilities] = new JsonObject(), + }, + }, + Context = new JsonRpcMessageContext + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + ClientInfo = clientInfo, + ClientCapabilities = clientCapabilities, + }, + }, ct); + + await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, server.NegotiatedProtocolVersion); await transport.DisposeAsync(); await runTask; @@ -369,7 +386,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -389,7 +406,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -421,7 +438,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", 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 @@ -468,7 +485,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -512,7 +529,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("complete", result.ResultType); Assert.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -556,7 +573,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("complete", result.ResultType); Assert.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -606,7 +623,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("complete", result.ResultType); Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -662,7 +679,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("complete", 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); @@ -710,7 +727,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("complete", result.ResultType); Assert.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -749,7 +766,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -779,7 +796,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -815,7 +832,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -853,7 +870,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -883,7 +900,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.Equal("test", result.Description); }); } @@ -918,7 +935,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -954,90 +971,12 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", 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() { @@ -1072,31 +1011,6 @@ public class McpServerTests : LoggedTest }); } - [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() { @@ -1119,7 +1033,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1148,7 +1062,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1184,7 +1098,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Null(result.ResultType); + Assert.Equal("complete", 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]); @@ -1445,7 +1359,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.Equal("complete", 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 ebf7ca4..a904c87 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -33,13 +33,7 @@ public class McpTaskStoreTests : ClientServerTestBase .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }, options => options.ExecutionModeSelector = request => - request.Params?.Name switch - { - "sync-tool" => McpTaskExecutionMode.Synchronous, - "required-tool" => McpTaskExecutionMode.Required, - _ => McpTaskExecutionMode.Optional, - }); + }); } [Fact] @@ -58,79 +52,6 @@ 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() { @@ -688,12 +609,6 @@ 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/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index b4bf440..d3e6f5e 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -25,7 +25,6 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl private readonly Pipe _serverToClient = new(); private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); private readonly ServiceProvider _services; - private readonly McpServer _server; private readonly Task _serverTask; private readonly StreamWriter _writer; private readonly StreamReader _reader; @@ -36,15 +35,14 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); serviceCollection.AddSingleton(XunitLoggerProvider); - serviceCollection.AddSingleton(MockLoggerProvider); serviceCollection .AddMcpServer() .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) .WithTools(); _services = serviceCollection.BuildServiceProvider(validateScopes: true); - _server = _services.GetRequiredService(); - _serverTask = _server.RunAsync(_cts.Token); + var server = _services.GetRequiredService(); + _serverTask = server.RunAsync(_cts.Token); _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; _reader = new StreamReader(_serverToClient.Reader.AsStream()); @@ -71,134 +69,20 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() + public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() { var ct = TestContext.Current.CancellationToken; - Assert.IsType( - await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - Assert.Null(_server.NegotiatedProtocolVersion); - Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + var error = Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); - // The advisory legacy value must not block a subsequent modern request from selecting its era. + // The rejected initialize-handshake _meta request must not have established session state. Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); - Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); - } - - [Theory] - [InlineData("null")] - [InlineData("{}")] - [InlineData("[]")] - [InlineData("42")] - [InlineData("true")] - public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson) - { - var ct = TestContext.Current.CancellationToken; - var request = new JsonRpcRequest - { - Id = new RequestId(1), - Method = RequestMethods.ToolsList, - Params = new JsonObject - { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson), - }, - }, - }; - - // An unreadable value leaves the request on the legacy path without establishing a version. - Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Null(_server.NegotiatedProtocolVersion); - - var warning = Assert.Single( - MockLoggerProvider.LogMessages, - m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal)); - Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal); } [Fact] - public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected() - { - var ct = TestContext.Current.CancellationToken; - var request = new JsonRpcRequest - { - Id = new RequestId(1), - Method = RequestMethods.ServerDiscover, - Params = new JsonObject - { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = new JsonObject(), - }, - }, - }; - - // server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy - // handling. This is what keeps a modern client from silently getting legacy treatment on a transport - // with no protocol version header. - var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); - Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); - } - - [Theory] - [InlineData(null)] - [InlineData("null")] - [InlineData("{}")] - [InlineData("[]")] - [InlineData("42")] - [InlineData("true")] - public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson) - { - var ct = TestContext.Current.CancellationToken; - - Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); - - var meta = new JsonObject - { - [MetaKeys.ClientCapabilities] = new JsonObject(), - }; - if (protocolVersionJson is not null) - { - meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson); - } - - var request = new JsonRpcRequest - { - Id = new RequestId(2), - Method = RequestMethods.ToolsList, - Params = new JsonObject - { - ["_meta"] = meta, - }, - }; - - // Once the era is established the metadata is required, so an unreadable value is an error rather - // than a reason to drop back to legacy handling. - var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); - Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal); - Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); - } - - [Fact] - 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() + public async Task PerRequestMetadata_RejectsRequestMissingRequiredMetadata() { var ct = TestContext.Current.CancellationToken; @@ -207,11 +91,10 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl id: 1, McpProtocolVersions.July2026ProtocolVersion, ct, - includeClientCapabilities: false)); + includeClientInfo: false)); Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); - Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); - Assert.Null(_server.NegotiatedProtocolVersion); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); } [Fact] @@ -244,7 +127,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task Initialize_IgnoresFutureReservedMetadata() + public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() { var ct = TestContext.Current.CancellationToken; @@ -259,101 +142,18 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, Meta = new JsonObject { - [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, - [MetaKeys.ClientInfo] = "not-an-object", - [MetaKeys.ClientCapabilities] = "not-an-object", - [MetaKeys.LogLevel] = "not-a-level", + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "per-request-meta-client", + ["version"] = "1.0.0", + }, }, }, McpJsonUtilities.DefaultOptions), }; - Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); - Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); - } - - [Fact] - public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata() - { - var ct = TestContext.Current.CancellationToken; - - Assert.IsType( - await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - Assert.IsType( - await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct)); - - Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); - } - - [Fact] - public async Task LegacySession_IgnoresModernProtocolVersionMetadata() - { - var ct = TestContext.Current.CancellationToken; - - Assert.IsType( - await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - - Assert.IsType( - await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); - Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); - } - - [Fact] - public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() - { - var ct = TestContext.Current.CancellationToken; - - Assert.IsType( - await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); - - var request = new JsonRpcRequest - { - Id = new RequestId(2), - Method = RequestMethods.ToolsList, - Params = new JsonObject - { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = new JsonObject(), - [MetaKeys.ClientInfo] = "not-an-object", - [MetaKeys.ClientCapabilities] = "not-an-object", - [MetaKeys.LogLevel] = "not-a-level", - }, - }, - }; - - Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); - } - - [Theory] - [InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")] - [InlineData(MetaKeys.ClientCapabilities, "null")] - [InlineData(MetaKeys.ClientCapabilities, "[]")] - [InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")] - [InlineData(MetaKeys.LogLevel, "\"not-a-level\"")] - public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson) - { - var ct = TestContext.Current.CancellationToken; - var meta = PerRequestMetadata(); - meta[key] = JsonNode.Parse(valueJson); - var request = new JsonRpcRequest - { - Id = new RequestId(1), - Method = RequestMethods.ToolsList, - Params = new JsonObject - { - ["_meta"] = meta, - }, - }; - var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); - Assert.Contains(key, error.Error.Message, StringComparison.Ordinal); - Assert.Null(_server.NegotiatedProtocolVersion); - - Assert.IsType( - await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); } [Fact] @@ -376,28 +176,25 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl 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) + [Fact] + public async Task LoggingSetLevel_WithPerRequestMetadataProtocolVersion_IsRejected() { var ct = TestContext.Current.CancellationToken; var request = new JsonRpcRequest { Id = new RequestId(1), - Method = method, + Method = RequestMethods.LoggingSetLevel, Params = new JsonObject { + ["level"] = "info", ["_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); + Assert.Contains(RequestMethods.LoggingSetLevel, error.Error.Message, StringComparison.Ordinal); } private async Task RoundTripAsync( diff --git a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs deleted file mode 100644 index db0f658..0000000 --- a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -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 a41adfc..5ece0f6 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -98,10 +98,10 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable .ToList(); Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions); - // Capabilities are mandatory in DiscoverResult; server identity is result metadata. + // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. Assert.NotNull(result["capabilities"]); - Assert.Null(result["serverInfo"]); - Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); + Assert.NotNull(result["serverInfo"]); + Assert.Equal("raw-conformance-server", result["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,7 +126,6 @@ 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] @@ -147,7 +146,7 @@ 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.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] @@ -162,18 +161,17 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable 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_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed() + public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails. - // This is the sequence a permissive client emits against an unknown server: the failed probe never - // establishes a version, so initialize is still available. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"); + // Dual-path servers must accept 2026-07-28 per-request metadata and initialize-handshake traffic + // on the same connection. The exact mix below is what a permissive client running against an unknown + // server would emit while probing. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); var discover = await ReadAsync(); - Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); + Assert.NotNull(discover["result"]); await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); @@ -186,29 +184,5 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var call = await ReadAsync(); Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); } - - [Theory] - [InlineData("tools/list")] - [InlineData("server/discover")] - public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod) - { - // A modern request locks the session's protocol version. initialize declares nothing on stdio, so - // without this gate it would force the session back to a handshake version and make the modern - // _meta on in-flight requests opaque. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"); - var established = await ReadAsync(); - Assert.NotNull(established["result"]); - - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); - var init = await ReadAsync(); - Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue()); - - // The session is still modern: a request carrying full per-request metadata keeps working. - await SendAsync( - @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," + - July2026ProtocolMetaFragment() + "}}"); - var call = await ReadAsync(); - Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue()); - } } #endif diff --git a/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs deleted file mode 100644 index 6024e29..0000000 --- a/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 81e0619..0000000 --- a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs +++ /dev/null @@ -1,278 +0,0 @@ -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/TaskCallToolFilterCompositionTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs deleted file mode 100644 index 47d532d..0000000 --- a/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs +++ /dev/null @@ -1,332 +0,0 @@ -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/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index af4002e..6fa9fa2 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -123,23 +123,23 @@ public class TaskProtocolGatingTests : ClientServerTestBase } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - // A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The - // metadata is tolerated, but it cannot opt the legacy session into modern-only tasks. - var result = await client.CallToolAsTaskAsync( + // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy + // request. The server rejects reserved per-request metadata before it can affect behavior. + var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "forged"), Meta = CreateForgedTaskOptInMeta(), - }, ct); + }, ct)); - Assert.False(result.IsTask); - Assert.NotNull(result.Result); + Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode); + Assert.Contains(ClientCapabilitiesMetaKey, ex.Message); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index c463833..768ebf7 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -1,8 +1,5 @@ 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; @@ -45,70 +42,12 @@ 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] - 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] + [Fact] public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails() { var options = new HttpClientTransportOptions @@ -163,497 +102,8 @@ 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] - 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); - } -} +} \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 60ce9cf..8b78762 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -152,12 +152,8 @@ 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() { @@ -165,39 +161,30 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { (true, _) => "mono", - (_, true) => testServerExecutable, + (_, true) => "TestServer.exe", _ => "dotnet", }, Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { - (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..]); - } + (true, _) => ["TestServer.exe", cliArgument], + (_, true) => [cliArgument], + _ => ["TestServer.dll", cliArgument], }, }; var transport = new StdioClientTransport(options, LoggerFactory); - await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - string serializedArgument = await capturedArgument.Task.WaitAsync( - TestConstants.DefaultTimeout, - TestContext.Current.CancellationToken); - JsonElement parsedArgument = JsonElement.Parse(serializedArgument); - Assert.Equal(cliArgumentValue ?? "", parsedArgument.GetString()); + // 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); - 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); + // 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); } [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 4247255..e472696 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioServerTransportTests.cs @@ -278,55 +278,6 @@ 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() {