diff --git a/.github/release-process.md b/.github/release-process.md index 41f772759..5c973fca7 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -12,11 +12,13 @@ The following process is used when publishing new releases to NuGet.org. From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. +As part of Step 9 (documentation review), the skill also updates the shared embedded NuGet README (`src/PACKAGE.md`) -- adding any newly introduced packages to the package-list closure, applying the correct badge style (`nuget/vpre` for a prerelease series or `nuget/v` for a stable release), adding a release-notes link pointing to the tag being created, and syncing the same closure changes to the root `README.md`. + Review the PR, request changes if needed, and merge when ready. ## 3. Publish the release -After the prepare-release PR is merged, invoke the `publish-release` skill. The skill checks for any late-arriving PRs that could affect the release, refreshes the release notes, and creates a **draft** GitHub release. +After the prepare-release PR is merged, invoke the `publish-release` skill. The skill checks for any late-arriving PRs that could affect the release, refreshes the release notes, re-runs the README content checklist (confirming package closure, badge style, and release-notes link), and creates a **draft** GitHub release. Review the draft release on GitHub, check 'Set as a pre-release' if appropriate, and click 'Publish release'. diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index f881332a1..29bc58394 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -130,13 +130,25 @@ Generate a human-readable diff of the public API surface between the previous re Review repository documentation for changes needed to compensate for or adapt to this release: -1. **NuGet package READMEs** — Validate that code samples in `README.md` and `src/PACKAGE.md` compile against the current SDK. Follow [references/readme-snippets.md](references/readme-snippets.md) for the validation procedure. Propose fixes for any API mismatches. -2. **Conceptual documentation** — Review `docs/` for content affected by the changes in this release. Update references to changed APIs, new features, or removed functionality. -3. **Versioning documentation** — If the release introduces new versioning-relevant policies (new experimental APIs, obsoletion changes), verify `docs/versioning.md` reflects them. -4. **Changelogs** — If the repository contains changelog files (e.g., `CHANGELOG.md`), update them with the release information. If no changelogs exist, skip this sub-step and note it in the summary. +1. **NuGet package READMEs** -- Run the README content checklist from [references/readme-content.md](references/readme-content.md) and validate code samples: + a. **Content checklist** -- Open `src/PACKAGE.md` and verify each item in the checklist: + - **Package-list closure**: every shipping SDK package is listed. If a new package was introduced in this release, add it now. Use non-counting phrasing -- do not say "N main packages". + - **Badge strategy**: all package badges use `nuget/vpre` for a prerelease series or `nuget/v` for a stable release. Switch all badges together if the release type has changed. + - **Release-notes link**: add or update the link to `https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}` for the confirmed release version. The tag does not yet exist at prepare time; the link is forward-referencing and resolves when the GitHub release is published. + - **Root README.md sync**: mirror any package-list closure changes in the root `README.md`. + - **Other salient content**: descriptions, getting-started links, version-specific notes. + b. **Snippet validation** -- Validate that `csharp`-fenced code blocks in `src/PACKAGE.md` and `README.md` compile against the current SDK. Follow [references/readme-snippets.md](references/readme-snippets.md) for the full procedure. Propose fixes for any API mismatches. +2. **Conceptual documentation** -- Review `docs/` for content affected by the changes in this release. Update references to changed APIs, new features, or removed functionality. +3. **Versioning documentation** -- If the release introduces new versioning-relevant policies (new experimental APIs, obsoletion changes), verify `docs/versioning.md` reflects them. +4. **Changelogs** -- If the repository contains changelog files (e.g., `CHANGELOG.md`), update them with the release information. If no changelogs exist, skip this sub-step and note it in the summary. Stage all documentation changes for inclusion in the release commit. +**Edge Cases for README updates:** +- **New package introduced** -- Add it to the package-list closure in `src/PACKAGE.md` and `README.md`. Use the package's `` from its `.csproj` as the short description. +- **Release type changes (prerelease to stable or vice versa)** -- Switch all package badges between `nuget/vpre` and `nuget/v` together. +- **Release tag does not yet exist at prepare time** -- The release-notes link is forward-referencing; it is verified to resolve during the publish-release step. + ### Step 10: Draft Release Notes 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. diff --git a/.github/skills/prepare-release/references/readme-content.md b/.github/skills/prepare-release/references/readme-content.md new file mode 100644 index 000000000..e7fa16aed --- /dev/null +++ b/.github/skills/prepare-release/references/readme-content.md @@ -0,0 +1,94 @@ +# README Content Checklist + +This reference describes what to review and update in the shared embedded NuGet README +(`src/PACKAGE.md`) and the root repository README (`README.md`) as part of every release. + +## The Shared Embedded README + +All SDK packages embed the **same** README file: `src/PACKAGE.md`. + +Each project packs it identically: + +```xml + +README.md +``` + +Updating `src/PACKAGE.md` updates every package's nuget.org README at once. +There are no per-package README files; `src/ModelContextProtocol.Core/README.md` and +similar paths do not exist. + +## Checklist + +### 1. Package-list closure + +Every shipping SDK package must be listed in the packages section of `src/PACKAGE.md`, +including packages introduced after the initial SDK launch and including the package +being viewed in its own embedded README on nuget.org. + +Current packages to list: +- `ModelContextProtocol.Core` +- `ModelContextProtocol` +- `ModelContextProtocol.AspNetCore` +- `ModelContextProtocol.Extensions.Apps` + +Avoid counting phrases such as "three main packages" -- they become stale when packages +are added. Use a non-counting closure such as "The SDK packages are:" instead. + +When a new package is introduced, add it to the list in both `src/PACKAGE.md` and the +root `README.md` (see section below). + +### 2. Badge strategy + +Each package entry carries a nuget.org version badge. The correct badge endpoint depends +on the release type: + +| Release type | Badge endpoint | Example | +|---|---|---| +| Prerelease series (e.g., `2.0.0-preview.*`) | `nuget/vpre/{package}` | `https://img.shields.io/nuget/vpre/ModelContextProtocol.svg` | +| Stable release | `nuget/v/{package}` | `https://img.shields.io/nuget/v/ModelContextProtocol.svg` | + +`nuget/v` renders only the latest stable version and shows nothing (or a placeholder) +during a prerelease-only series. `nuget/vpre` renders the latest version including +prereleases. Switch all package badges together when the release type changes. + +Verify every badge in `src/PACKAGE.md` uses the correct endpoint for this release. + +### 3. Release-notes link + +`src/PACKAGE.md` must contain one statement linking to the release notes for the +current version: + +```markdown +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}) +for what's new in this version. +``` + +Replace `{version}` with the exact version being released, including any prerelease +suffix (e.g., `2.0.0-preview.2`). + +At prepare time the tag does not yet exist; the link is forward-referencing. The link +resolves once the GitHub release is published during the publish-release step. + +Update this link for every release -- it must point to the tag being created, not a +prior release. + +### 4. Root README.md sync + +The root `README.md` (the GitHub repo readme, NOT packed into packages) has its own +package-list section. Keep it aligned with `src/PACKAGE.md`: +- Same set of packages listed +- Same non-counting closure phrasing +- Badge strategy in `README.md` may also be updated for consistency, but the root + README is visible on GitHub (not nuget.org) so the badge choice is less critical + +## Salient content to review + +Beyond the structural checks above, read the current `src/PACKAGE.md` for any content +that has become stale due to changes in this release: + +- Package descriptions (are they still accurate?) +- Getting-started links (do they resolve and describe the current API?) +- Code samples, if any (do they compile against the current SDK? see + [readme-snippets.md](readme-snippets.md)) +- Any version-specific notes from a prior release that should be removed or updated diff --git a/.github/skills/prepare-release/references/readme-snippets.md b/.github/skills/prepare-release/references/readme-snippets.md index 7d2cd4563..a91efeeff 100644 --- a/.github/skills/prepare-release/references/readme-snippets.md +++ b/.github/skills/prepare-release/references/readme-snippets.md @@ -4,19 +4,21 @@ This reference describes how to validate that C# code samples in README files co ## Which READMEs to Validate -Validate code samples from the **package README** files — these are shipped with NuGet packages and are the primary documentation users see: +Validate code samples from the **package README** and the root repository README: -| README | Package | -|--------|---------| -| `README.md` (root) | ModelContextProtocol | -| `src/ModelContextProtocol.Core/README.md` | ModelContextProtocol.Core | -| `src/ModelContextProtocol.AspNetCore/README.md` | ModelContextProtocol.AspNetCore | +| README | Notes | +|--------|-------| +| `src/PACKAGE.md` | The single shared embedded README packed into every SDK package. This is the primary documentation users see on nuget.org. | +| `README.md` (root) | The GitHub repository readme. Not packed into packages, but visible to developers browsing the repo. | -Sample README files (`samples/*/README.md`) are excluded — the samples themselves are buildable projects and are validated by CI. +All SDK packages embed `src/PACKAGE.md` via their `.csproj` files. There are no per-package +README files; paths such as `src/ModelContextProtocol.Core/README.md` do not exist. + +Sample README files (`samples/*/README.md`) are excluded -- the samples themselves are buildable projects and are validated by CI. ## What to Extract -Extract only fenced code blocks tagged as `csharp` (` ```csharp `). Skip blocks tagged as plain ` ``` ` (shell commands, install instructions) or any other language. +Extract only fenced code blocks tagged as `csharp` (` ```csharp `) from `src/PACKAGE.md` and `README.md`. Skip blocks tagged as plain ` ``` ` (shell commands, install instructions) or any other language. ### Handling Incomplete Snippets diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index 0706cd361..173bb2064 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -69,14 +69,24 @@ Re-categorize all PRs in the commit range (including any new ones from Step 3). 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. -### Step 5: Validate README Code Samples +### Step 5: Review README and Validate Code Samples -Verify that all C# code samples in the package README files compile against the current SDK at the merge commit. Follow the [README validation guide](../prepare-release/references/readme-snippets.md) for the full procedure. +Re-run the README content checklist from [../prepare-release/references/readme-content.md](../prepare-release/references/readme-content.md) and validate code samples against the current SDK at the merge commit. Produce final suggestions before the release is created. -1. Extract `csharp`-fenced code blocks from `README.md` and `src/PACKAGE.md` -2. Create a temporary test project at `tests/ReadmeSnippetValidation/` -3. Build and report results -4. Delete the temporary project +1. **Content checklist** -- Open `src/PACKAGE.md` and verify: + - **Package-list closure**: every shipping SDK package is listed. If a new package was introduced after prepare-release ran, it may be missing. + - **Badge strategy**: all badges use `nuget/vpre` for a prerelease or `nuget/v` for a stable release. Verify the badge style is correct for this release type. + - **Release-notes link**: the link points to `https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}` for the tag being created. The tag is about to exist -- verify the URL is correct. + - **Root README.md sync**: confirm the root `README.md` package list is aligned. +2. **Snippet validation** -- Extract `csharp`-fenced code blocks from `src/PACKAGE.md` and `README.md`, build the temporary test project, and report results. Follow [../prepare-release/references/readme-snippets.md](../prepare-release/references/readme-snippets.md) for the full procedure. +3. **Delete** the temporary project after validation. + +If issues are found, present them to the user with proposed fixes. Any fixes must be applied as a separate commit before the draft release is created. + +**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`. +- **Wrong badge style for the release type** -- Switch all badges together from `nuget/vpre` to `nuget/v` (or vice versa) if the prepare-release step used the wrong style. +- **Missing or incorrect release-notes link** -- Correct the link to target the exact tag being created, including any prerelease suffix. ### Step 6: Review Sections diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index abfb430cb..9020d2fbe 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -69,6 +69,7 @@ + diff --git a/README.md b/README.md index f0ab4a0ac..fd2ed2ae2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotoco ## Packages -This SDK consists of three main 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. @@ -14,6 +14,8 @@ This SDK consists of three main packages: - **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/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. + ## Getting Started To get started, see the [Getting Started](https://csharp.sdk.modelcontextprotocol.io/concepts/getting-started.html) guide in the conceptual documentation for installation instructions, package-selection guidance, and complete examples for both clients and servers. diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 60530d85d..4e14a2d1e 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including 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: diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 5044e6d51..e0d4f8d0f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -33,7 +33,7 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support it. Servers accept `2026-07-28` automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `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. @@ -283,4 +283,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two 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 runs statelessly whenever a client speaks `2026-07-28`. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves `2026-07-28` requests without a session and creates a legacy session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `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/roots/roots.md b/docs/concepts/roots/roots.md index bb0218f97..de9b9b0ac 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -109,7 +109,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including 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: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 825acfb0a..7b8eb8e59 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -126,7 +126,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including 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: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443df..c2cf715ee 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -28,11 +28,11 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `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: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`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. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that even with `Stateless = false`, a `2026-07-28` request is still served without a session because the protocol has no session header; the stateful path activates only when a client falls back to a legacy revision. +- **`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] @@ -42,18 +42,18 @@ The `Stateless` property is the single most important setting for forward-proofi 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 `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP POST that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request (which carries no session) on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A `2026-07-28` request that carries an `Mcp-Session-Id` is always rejected, since the revision has no session concept. +**Server side.** With `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 legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. +**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients. **Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it: -- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. -- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. +- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. The era is cached per instance, so the probe cost is paid only on the first connect. -**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to a legacy revision. This is useful for strict-modern production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. +**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. ```csharp var clientOptions = new McpClientOptions @@ -590,7 +590,7 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ ## Tasks and session modes -[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. +[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (enabled via `WithTasks`), and behavior differs between session modes. ### Stateless mode @@ -600,9 +600,9 @@ In stateless mode, there is no `SessionId`, so the task store does not apply ses ### Stateful mode -In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. +In stateful mode, the `IMcpTaskStore` 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. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. +Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. ### Task cancellation vs request cancellation @@ -654,7 +654,7 @@ The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expira ### With tasks (experimental) -[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. +[Tasks](xref:tasks) 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: diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 16e3a6dc2..5342946bc 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -18,7 +18,7 @@ and the client polls for status, optionally exchanging additional input along th A client opts into tasks on a per-request basis by including the `io.modelcontextprotocol/tasks` extension key in the request's `_meta`. When that opt-in is present, the server **may** respond -with a instead of the standard result +with a instead of the standard result (e.g., ). The client then polls `tasks/get` until the task reaches a terminal state. @@ -39,36 +39,35 @@ the extension opt-in. The SDK enforces this on the server side. └──→ Failed (terminal — JSON-RPC errors only) ``` - wire values are serialized in snake_case: + wire values are serialized in snake_case: `working`, `input_required`, `completed`, `cancelled`, `failed`. The discriminator field -on the response payload is `"task"` for +on the response payload is `"task"` for and `"complete"` for ordinary results. ### Server configuration #### Using the task store -The easiest way to enable tasks is to set an -on . -The SDK ships for development and tests: +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 -builder.Services.AddMcpServer(options => -{ - options.TaskStore = new InMemoryMcpTaskStore(); -}) -.WithTools(); +using ModelContextProtocol.Extensions.Tasks; + +builder.Services.AddMcpServer() + .WithTools() + .WithTasks(new InMemoryMcpTaskStore()); ``` -When a `TaskStore` is configured the SDK automatically: +When tasks are enabled with `WithTasks` the SDK automatically: -- Wires `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. Explicit - handlers in still take precedence - for any slot they fill. +- Wires the `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. - Advertises the `io.modelcontextprotocol/tasks` extension in . - Wraps each `[McpServerTool]` invocation so that, when the client opts in to the extension, @@ -81,40 +80,21 @@ When a `TaskStore` is configured the SDK automatically: `tasks/cancel`, so cancellation propagates cooperatively. For production scenarios that need durability, session isolation, multi-process routing, or -TTL-based cleanup, implement yourself +TTL-based cleanup, implement yourself (see [Implementing a custom task store](#implementing-a-custom-task-store) below). -#### Custom task handlers - -For full control without a store, set the handlers directly. Each handler is an - that receives an - with typed parameters: - -```csharp -options.Handlers.GetTaskHandler = (context, ct) => -{ - var taskId = context.Params!.TaskId; - // … look up state and return one of the GetTaskResult subtypes. - return new ValueTask(new WorkingTaskResult { TaskId = taskId, /* … */ }); -}; - -options.Handlers.UpdateTaskHandler = (context, ct) => /* return ValueTask */; -options.Handlers.CancelTaskHandler = (context, ct) => /* return ValueTask */; -``` - -> **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting -> into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get` -> handler is wired, the server throws `InvalidOperationException` at request time so misconfigured -> deployments fail loudly instead of shipping unpollable tasks. - #### Returning a task from a tool handler - -returns , so each invocation can choose -between an immediate result and a background task: +For full control without the store's auto-wrapping, set +. +It returns a , so each invocation can choose +between an immediate result and an alternate result such as a +: ```csharp -options.Handlers.CallToolWithTaskHandler = async (context, ct) => +using ModelContextProtocol.Extensions.Tasks; + +options.Handlers.CallToolWithAlternateHandler = async (context, ct) => { if (ShouldRunInline(context.Params!)) { @@ -122,7 +102,7 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) => } var taskId = await StartBackgroundWorkAsync(context.Params!, ct); - return new CreateTaskResult + var created = new CreateTaskResult { TaskId = taskId, Status = McpTaskStatus.Working, @@ -130,22 +110,32 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) => LastUpdatedAt = DateTimeOffset.UtcNow, PollIntervalMs = 1000, }; + + return new ResultOrAlternate(created, McpTasksJsonContext.Default.CreateTaskResult); }; ``` +> This low-level handler is mutually exclusive with `WithTasks`. When a store is configured, the +> SDK does the wrapping for you and throws `InvalidOperationException` if the alternate handler also +> returns an alternate. Use one mechanism or the other. When you return a task this way you are also +> responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides +> automatically. + > -> and +> and > are mutually exclusive. Setting one while the other is already non-null throws > `InvalidOperationException` at the property setter. #### Task scope for server-initiated requests -When you start background work from a custom -(rather than the SDK's auto-wrapping), use +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 @@ -156,13 +146,13 @@ using (server.CreateMcpTaskScope(taskId, taskStore)) `CreateMcpTaskScope` returns an `IDisposable` that restores the prior ambient context on `Dispose`. The scope is established automatically for `[McpServerTool]` methods that run via -`McpServerOptions.TaskStore`, so this API is only needed for custom handlers. +`WithTasks`, so this API is only needed for custom handlers. ### Client usage #### Automatic polling - + handles the full task lifecycle automatically: - Injects the `io.modelcontextprotocol/tasks` extension capability into the request's `_meta`. @@ -176,21 +166,25 @@ handles the full task lifecycle automatically: or throws on `Failed`/`Cancelled`. ```csharp -var result = await client.CallToolAsync( +using ModelContextProtocol.Extensions.Tasks; + +var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments }, - cancellationToken); + cancellationToken: cancellationToken); ``` #### Manual control -Use to receive the raw - without auto-polling, then drive the -lifecycle yourself using , -, and -: +Use to receive the raw + without auto-polling, then drive the +lifecycle yourself using , +, and +: ```csharp -var raw = await client.CallToolRawAsync(requestParams, cancellationToken); +using ModelContextProtocol.Extensions.Tasks; + +var raw = await client.CallToolAsTaskAsync(requestParams, cancellationToken); if (raw.IsTask) { var taskId = raw.TaskCreated!.TaskId; @@ -206,17 +200,17 @@ if (raw.IsTask) #### Stuck-task detector -`CallToolAsync` includes a safety net for misbehaving servers: if the task stays in - across many consecutive polls +`CallToolWithPollingAsync` includes a safety net for misbehaving servers: if the task stays in + across many consecutive polls without exposing any new input request keys (i.e. every previously requested input has already been resolved by the client and yet the server keeps returning `InputRequired`), the client gives up, issues a best-effort `tasks/cancel`, and throws . This guards against a server that never transitions out of `InputRequired` and prevents an unbounded poll loop. -The threshold defaults to `60` consecutive stuck polls and is configurable via -. The effective -wall-clock timeout is roughly `MaxConsecutiveStuckPolls * pollIntervalMs`, so tune the option +The threshold defaults to `60` consecutive stuck polls and is configurable via the +`maxConsecutiveStuckPolls` parameter on `CallToolWithPollingAsync`. The effective +wall-clock timeout is roughly `maxConsecutiveStuckPolls * pollIntervalMs`, so tune the value with the server-side poll cadence in mind. Setting it too low risks false positives for servers that are slow to surface follow-up input requests; setting it too high can mask misbehaving servers. @@ -224,11 +218,11 @@ servers. ### Input requests (multi-round-trip) When a task needs additional input from the client, the server transitions it to - and returns the outstanding -requests in . Each + and returns the outstanding +requests in . Each entry is an arbitrary key paired with a `{ method, params }` envelope representing an equivalent standalone server-to-client request. The client provides answers via -, keyed by the same identifiers. +, keyed by the same identifiers. Supported input request methods: @@ -241,34 +235,34 @@ Per SEP-2663: - Each input request key **must** be unique over the lifetime of the task. - Clients **should** deduplicate keys across polls so a request is only presented to the user - or model once. `CallToolAsync` does this automatically. + or model once. `CallToolWithPollingAsync` does this automatically. - Servers **should** ignore `inputResponses` entries whose key does not currently correspond to an outstanding request, including responses for terminal-state tasks. - follows this rule. + follows this rule. ### Implementing a custom task store -Implement for production scenarios. Key +Implement for production scenarios. Key requirements drawn from the SEP and the SDK contract: 1. **Thread safety** — every method may be called concurrently. 2. **Idempotent terminal transitions** — - , - , and - must be no-ops on a task + , + , and + must be no-ops on a task that is already in a terminal state so a late cancellation cannot overwrite a result. 3. **`InputResponseReceived` event** — after persisting an input response inside - , raise - + , raise + for each resolved entry. This is the only mechanism that wakes a pending `server.ElicitAsync`/`server.SampleAsync` call waiting inside a task scope. In distributed deployments where a different server instance receives the `tasks/update`, the event must be propagated to the originating server (for example via Redis pub/sub, SignalR, or a custom transport). 4. **Strong-consistency on `CreateTaskAsync`** — - must not return until the + must not return until the task is durably persisted, so that a subsequent - with the returned task ID + with the returned task ID resolves immediately — even from a different process or node. Stores backed by eventually-consistent storage must wait for the write to become visible (quorum acknowledgement, write-through, etc.) before returning. Required by SEP-2663 §306. @@ -310,12 +304,12 @@ public sealed class MyTaskStore : IMcpTaskStore ### Status semantics - is the terminal status whenever the + is the terminal status whenever the underlying request produced its standard result, *including a with `IsError = true`*. Per SEP-2663, tool-level error results are not promoted to `Failed`. - is reserved for JSON-RPC protocol-level + is reserved for JSON-RPC protocol-level errors during execution — for example, a malformed request, or an unhandled exception in a custom handler that the SDK converts to a JSON-RPC error. Use for @@ -341,7 +335,7 @@ In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore` #### Immutable store design - uses immutable record snapshots with + uses immutable record snapshots with compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state. @@ -357,13 +351,13 @@ responsible for handling — or rejecting — the input requests surfaced throug - **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on polling exclusively. -- **Lazy task creation**: when a tool runs through `TaskStore`, the store's - is invoked eagerly before +- **Lazy task creation**: when a tool runs through the task store, the store's + is invoked eagerly before the inner handler runs, so tools that complete inline still incur a store write. There is currently no built-in deferral. - **Mid-execution promotion to task**: an `[McpServerTool]` method cannot start executing synchronously and then transition its remaining work to a background task. Use a custom - + if you need that pattern. - **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the task channel when called from inside a task scope, but the client SDK does not currently diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 354299ce1..85ccec209 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -183,7 +183,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `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 diff --git a/package-lock.json b/package-lock.json index fc400cc42..6cc1c80aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,9 +23,9 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.5.tgz", - "integrity": "sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==", + "version": "0.2.0-alpha.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", diff --git a/package.json b/package.json index 3dbb9fba1..c1ad1b2b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index fff353307..50ddf0d03 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -1,18 +1,17 @@ // Demonstrates the MCP tasks extension (SEP-2663): // -// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation +// - A server is configured with .WithTasks(store) so that any [McpServerTool] invocation // becomes a background task when the client opts in via the per-request _meta marker. -// - The client invokes the same tool two ways: -// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final -// CallToolResult, just like a synchronous call. -// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls, -// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream -// status updates rather than block on a single await. +// - The client invokes the tool and manually drives the lifecycle via GetTaskAsync. // // Both server and client are wired together in-process over an in-memory pipe so the sample // is self-contained — no separate server process or HTTP transport required. +#pragma warning disable MCPEXP001, MCPEXP002, MCPEXP004 + +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; @@ -21,28 +20,25 @@ Pipe clientToServerPipe = new(), serverToClientPipe = new(); -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions - { - // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be - // automatically wrapped as background tasks when the client opts in. - TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }, - ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], - }); +var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }; + +var services = new ServiceCollection(); +services.AddMcpServer() + .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(); _ = server.RunAsync(); await using McpClient client = await McpClient.CreateAsync( - new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); - -Console.WriteLine("=== CallToolAsync (auto-poll) ==="); -var auto = await client.CallToolAsync( - new CallToolRequestParams { Name = "run-report" }); -Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}"); -Console.WriteLine(); + new StreamClientTransport( + serverInput: clientToServerPipe.Writer.AsStream(), + serverOutput: serverToClientPipe.Reader.AsStream())); -Console.WriteLine("=== CallToolRawAsync (manual poll) ==="); -var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" }); +Console.WriteLine("=== CallToolAsTaskAsync (manual poll) ==="); +var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" }); if (!raw.IsTask) { // Either the server doesn't advertise the tasks extension or it chose to run the call @@ -88,9 +84,6 @@ continue; case InputRequiredTaskResult inputRequired: - // The auto-poll path (CallToolAsync above) routes these through the registered - // ElicitationHandler/SamplingHandler automatically. The manual path needs to call - // UpdateTaskAsync with responses for each outstanding key. Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))"); continue; } diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj index 2f66badd7..fe01a8440 100644 --- a/samples/TasksExtension/TasksExtension.csproj +++ b/samples/TasksExtension/TasksExtension.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 31800e2fa..c08326e20 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -9,25 +9,6 @@ namespace ModelContextProtocol.Protocol; /// internal static class McpHttpHeaders { - /// - /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize - /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled - /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) - /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request - /// version against it (see ), so it underpins the more - /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it - /// by default. - /// - public const string July2026ProtocolVersion = "2026-07-28"; - - /// - /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP - /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. - /// It is the default version for the legacy initialize and session-resume code paths, and the - /// version the server advertises when a peer requests an unsupported version on the legacy handshake. - /// - public const string November2025ProtocolVersion = "2025-11-25"; - /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -71,27 +52,4 @@ internal static class McpHttpHeaders /// internal const string ToolContextKey = "Mcp.Tool"; - /// - /// Returns if the given protocol version is - /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. - /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. - /// - internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) - => !string.IsNullOrEmpty(protocolVersion) - && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; - - /// - /// Returns if the given protocol version requires standard MCP request headers - /// (Mcp-Method, Mcp-Name). - /// - public static bool SupportsStandardHeaders(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); - - /// - /// Returns if the negotiated protocol version reports unresolvable - /// resource URIs with the standard JSON-RPC (-32602) - /// rather than the legacy (-32002). - /// - internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); } diff --git a/src/Common/McpProtocolVersions.cs b/src/Common/McpProtocolVersions.cs new file mode 100644 index 000000000..09b1f5b3d --- /dev/null +++ b/src/Common/McpProtocolVersions.cs @@ -0,0 +1,113 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Internal helpers for MCP protocol revision strings and protocol-era behavior gates. +/// +internal static class McpProtocolVersions +{ + /// + /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize + /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled + /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) + /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request + /// version against it (see ), so it underpins the more + /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it + /// by default. + /// + public const string July2026ProtocolVersion = "2026-07-28"; + + /// + /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP + /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. + /// It is the default version for the initialize and session-resume code paths, and the version + /// the server advertises when a peer requests an unsupported version on the initialize handshake. + /// + public const string November2025ProtocolVersion = "2025-11-25"; + + /// The 2025-06-18 MCP protocol revision. + public const string June2025ProtocolVersion = "2025-06-18"; + + /// The 2025-03-26 MCP protocol revision. + public const string March2025ProtocolVersion = "2025-03-26"; + + /// The 2024-11-05 MCP protocol revision. + public const string November2024ProtocolVersion = "2024-11-05"; + + /// + /// Protocol versions that still use the initialize handshake. + /// + internal static readonly string[] InitializeHandshakeProtocolVersions = + [ + November2024ProtocolVersion, + March2025ProtocolVersion, + June2025ProtocolVersion, + November2025ProtocolVersion, + ]; + + /// + /// Protocol versions that use per-request metadata instead of the initialize handshake. + /// + internal static readonly string[] PerRequestMetadataProtocolVersions = + [ + July2026ProtocolVersion, + ]; + + /// + /// All protocol versions supported by this implementation. + /// + internal static readonly string[] SupportedProtocolVersions = + [ + .. InitializeHandshakeProtocolVersions, + .. PerRequestMetadataProtocolVersions, + ]; + + /// + /// Returns if the given protocol version is + /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. + /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. + /// + internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) + => !string.IsNullOrEmpty(protocolVersion) + && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; + + /// + /// Returns if the given protocol version is supported by this implementation. + /// + internal static bool IsSupportedProtocolVersion(string? protocolVersion) + => protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version is available through the + /// initialize handshake. + /// + internal static bool SupportsInitializeHandshake(string? protocolVersion) + => protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version requires the handshake-free + /// per-request metadata path. + /// + internal static bool RequiresPerRequestMetadata(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); + + /// + /// Returns if the given protocol version requires standard MCP request headers + /// (Mcp-Method, Mcp-Name). + /// + internal static bool RequiresStandardHeaders(string? protocolVersion) + => RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the given protocol version supports Streamable HTTP sessions. + /// + internal static bool SupportsHttpSessions(string? protocolVersion) + => !RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the negotiated protocol version reports unresolvable + /// resource URIs with the standard JSON-RPC (-32602) + /// rather than the legacy (-32002). + /// + internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5758c4936..5e1e4951e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/modelcontextprotocol/csharp-sdk git 2.0.0 - preview.1 + preview.2 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index e0c8a8826..024772240 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -64,10 +64,10 @@ public class HttpServerTransportOptions /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served /// when this property is . When it is , such a request is - /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-era client downgrades to - /// the legacy initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id is always rejected by the 2026-07-28 and later - /// revisions, regardless of this property's value. + /// 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; set; } = true; diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 44bac1bc9..4c46acdd1 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -25,6 +25,7 @@ + diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 0ecf7ab37..f7db4630e 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -13,6 +13,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -32,24 +33,15 @@ internal sealed class StreamableHttpHandler( /// /// All protocol versions supported by this implementation. - /// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core. /// - private static readonly HashSet s_supportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + private static readonly string[] s_supportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-era - /// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a 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 = - [.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))]; + private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -63,7 +55,8 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -111,6 +104,12 @@ await WriteJsonRpcErrorAsync(context, // Notifications carry no id, so this stays default (null), which is correct. var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default; + if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError)) + { + await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId); + return; + } + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId); @@ -137,7 +136,8 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -148,11 +148,12 @@ public async Task HandleGetRequestAsync(HttpContext context) // The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited // server-to-client messages; clients use subscriptions/listen (POST) instead. Because Streamable HTTP // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, - "Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -254,7 +255,8 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -264,11 +266,12 @@ public async Task HandleDeleteRequestAsync(HttpContext context) // Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567), // so the DELETE is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, - "Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -374,25 +377,14 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default) { - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - // The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { - if (!string.IsNullOrEmpty(sessionId)) - { - // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). - await WriteJsonRpcErrorAsync(context, - "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", - StatusCodes.Status400BadRequest, requestId: requestId); - return null; - } - if (!HttpServerTransportOptions.Stateless) { // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-era client falls back to the legacy + // 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; @@ -402,6 +394,7 @@ await WriteJsonRpcErrorAsync(context, return await StartNewSessionAsync(context); } + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); if (string.IsNullOrEmpty(sessionId)) { // In stateful mode, only allow creating new sessions for initialize requests. @@ -435,13 +428,13 @@ await WriteJsonRpcErrorAsync(context, /// /// Returns when the request's MCP-Protocol-Version header declares a /// revision that operates without sessions, so the server must serve it statelessly. Such requests - /// never carry an Mcp-Session-Id and never perform the legacy initialize handshake + /// do not use an Mcp-Session-Id and never perform the initialize handshake /// (SEP-2575 + SEP-2567). /// - private static bool IsJuly2026OrLaterProtocol(HttpContext context) + private static bool RequiresPerRequestMetadataProtocol(HttpContext context) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader); + return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } private async ValueTask StartNewSessionAsync(HttpContext context) @@ -665,11 +658,14 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// rejection uses the error code with a data payload /// listing the server's supported versions so the client can select a fallback. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + private static bool ValidateProtocolVersionHeader( + HttpContext context, + IReadOnlyList supportedProtocolVersions, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + !supportedProtocolVersions.Contains(protocolVersionHeader)) { errorDetail = new JsonRpcErrorDetail { @@ -678,7 +674,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = [.. s_supportedProtocolVersions], + Supported = [.. supportedProtocolVersions], Requested = protocolVersionHeader, }, GetRequiredJsonTypeInfo()), @@ -690,11 +686,108 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW return true; } + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return s_supportedProtocolVersions; + } + + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; + } + + /// + /// 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, + JsonRpcMessage message, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + { + if (message is not (JsonRpcRequest or JsonRpcNotification)) + { + errorDetail = null; + return true; + } + + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); + + if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && + !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) + { + errorDetail = null; + return true; + } + + if (string.IsNullOrEmpty(protocolVersionHeader)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version."); + return false; + } + + if (!hasProtocolVersionMeta) + { + 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; + } + + if (!string.Equals(protocolVersionHeader, protocolVersionMeta, StringComparison.Ordinal)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body _meta/{MetaKeys.ProtocolVersion} value '{protocolVersionMeta}'."); + return false; + } + + errorDetail = null; + return true; + } + + private static JsonRpcErrorDetail CreateHeaderMismatchError(string message) => new() + { + Code = (int)McpErrorCode.HeaderMismatch, + Message = message, + }; + + private static bool TryGetProtocolVersionMeta(JsonRpcMessage message, [NotNullWhen(true)] out string? protocolVersion) + { + var parameters = message switch + { + JsonRpcRequest request => request.Params, + JsonRpcNotification notification => notification.Params, + _ => null, + }; + + string? value = null; + if (parameters is JsonObject paramsObj && + paramsObj["_meta"] is JsonObject metaObj && + metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && + protocolVersionValue.TryGetValue(out value) && + !string.IsNullOrEmpty(value)) + { + protocolVersion = value; + return true; + } + + protocolVersion = null; + return false; + } + /// /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to /// sessions; we return with a supported-versions list - /// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake + /// 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) @@ -703,7 +796,7 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpHttpHeaders.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + + 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 @@ -731,7 +824,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion)) + if (!McpProtocolVersions.RequiresStandardHeaders(protocolVersion)) { errorMessage = null; return true; diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 9ecfe1c12..a96ba5ae0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -944,25 +944,17 @@ public Task UnsubscribeFromResourceAsync( McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - /// /// Invokes a tool on the server. /// - /// The name of the tool to call on the server. + /// The name of the tool to invoke. /// An optional dictionary of arguments to pass to the tool. - /// An optional progress reporter for server notifications. + /// An optional progress handler for tracking operation progress. /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// The from the tool execution. + /// The result of the tool invocation. /// is . /// The request failed or the server returned an error response. - /// - /// This overload supports the tasks extension transparently. If the server responds with a - /// task handle rather than an immediate result, this method polls tasks/get until the - /// task completes, dispatching any entries through - /// the client's registered sampling and elicitation handlers along the way. Use - /// to disable automatic polling. - /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, @@ -1033,221 +1025,19 @@ async ValueTask SendRequestWithProgressAsync( /// The result of the request. /// is . /// The request failed or the server returned an error response. - /// - /// This method automatically includes the io.modelcontextprotocol/tasks extension capability - /// in the request metadata. If the server returns a task handle instead of an immediate result, - /// this method transparently polls tasks/get until the task completes, fails, or is cancelled. - /// Use - /// to receive the raw without automatic polling. - /// - public async ValueTask CallToolAsync( - CallToolRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - - var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); - - if (!augmented.IsTask) - { - return augmented.Result!; - } - - return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false); - } - - /// - /// Polls a task until it reaches a terminal state and returns the final . - /// - private async ValueTask PollTaskToCompletionAsync( - CreateTaskResult taskCreated, - CancellationToken cancellationToken) - { - // If the server claims InputRequired but never publishes new input requests after we have - // already responded to everything it asked for, treat that as a stuck task. The client - // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll - // loop when the server is misbehaving. The threshold is configurable via - // McpClientOptions.MaxConsecutiveStuckPolls. - int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls; - - string taskId = taskCreated.TaskId; - long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; - HashSet? resolvedRequestKeys = null; - bool isFirstPoll = true; - int consecutiveStuckPolls = 0; - - while (true) - { - // Skip the delay before the first poll: many tasks complete almost immediately and we - // don't want to pay the poll interval as gratuitous latency. - if (!isFirstPoll) - { - await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); - } - isFirstPoll = false; - - var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - - // Update poll interval if the server changed it. - if (taskResult.PollIntervalMs is { } newInterval) - { - pollIntervalMs = newInterval; - } - - switch (taskResult) - { - case CompletedTaskResult completed: - return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); - - case FailedTaskResult failed: - throw new McpException($"Task '{taskId}' failed: {failed.Error}"); - - case CancelledTaskResult: - throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); - - case InputRequiredTaskResult inputRequired: - // Dedup: only resolve input requests we haven't already responded to. - var newRequests = new Dictionary(); - if (inputRequired.InputRequests is { } incomingRequests) - { - foreach (var kvp in incomingRequests) - { - if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) - { - newRequests[kvp.Key] = kvp.Value; - } - } - } - - if (newRequests.Count > 0) - { - consecutiveStuckPolls = 0; - - IDictionary inputResponses; - try - { - inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // The input handler failed (e.g., ElicitationHandler threw or no handler was registered). - // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired - // until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating the original exception. - } - - throw; - } - - await UpdateTaskAsync(new UpdateTaskRequestParams - { - TaskId = taskId, - InputResponses = inputResponses, - }, cancellationToken).ConfigureAwait(false); - - resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); - foreach (var key in inputResponses.Keys) - { - resolvedRequestKeys.Add(key); - } - } - else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) - { - // Best-effort cancel of the server-side task so it doesn't leak until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating an exception. - } - - throw new McpException( - $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + - "without publishing new input requests after all previously requested inputs were resolved."); - } - - break; - - case WorkingTaskResult: - // Continue polling. - consecutiveStuckPolls = 0; - break; - - default: - throw new McpException( - $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); - } - } - } - - /// - /// Invokes a tool on the server with task extension support, returning the raw response - /// without automatic polling. The caller is responsible for handling task lifecycle. - /// - /// The request parameters to send. The tasks extension capability will be injected into the request metadata. - /// The to monitor for cancellation requests. The default is . - /// A that is either an immediate result or a task handle. - /// is . - /// The request failed or the server returned an error response. - /// - /// - /// Unlike , this method does not - /// automatically poll for task completion. If the server returns a , - /// the caller must manage polling via . - /// - /// - public async ValueTask> CallToolRawAsync( + public ValueTask CallToolAsync( CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - var paramsWithMeta = new CallToolRequestParams - { - Name = requestParams.Name, - Arguments = requestParams.Arguments, - // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call - // (no task capability envelope) so the server returns a direct CallToolResult and never - // creates a task. - Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, - }; - - JsonRpcRequest jsonRpcRequest = new() - { - Method = RequestMethods.ToolsCall, - Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams), - }; - - JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); - - // Discriminate based on resultType field. - if (response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && - resultTypeNode?.GetValue() == "task") - { - var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult) - ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); - return new ResultOrCreatedTask(taskCreated); - } - - var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from response."); - return new ResultOrCreatedTask(callToolResult); + return SendRequestAsync( + RequestMethods.ToolsCall, + requestParams, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken); } - /// /// Sets the logging level for the server to control which log messages are sent to the client. /// @@ -1302,111 +1092,6 @@ public Task SetLoggingLevelAsync( McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The stable identifier of the task to retrieve. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - GetTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(GetTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksGet, - requestParams, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Provides input responses to a task that is in the state. - /// - /// The request parameters containing the task ID and input responses. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the update. - /// is . - /// The request failed or the server returned an error response. - public ValueTask UpdateTaskAsync( - UpdateTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksUpdate, - requestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The stable identifier of the task to cancel. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - CancelTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksCancel, - requestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult, - cancellationToken: cancellationToken); - } - - /// Converts a dictionary with values to a dictionary with values. private static Dictionary? ToArgumentsDictionary( IReadOnlyDictionary? arguments, JsonSerializerOptions options) { @@ -1425,43 +1110,4 @@ public ValueTask CancelTaskAsync( return result; } - // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: - // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) - { - JsonObject meta = existingMeta is not null - ? (JsonObject)existingMeta.DeepClone() - : []; - - if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) - { - capsRoot = []; - meta[MetaKeys.ClientCapabilities] = capsRoot; - } - - if (capsRoot["extensions"] is not JsonObject extensionsRoot) - { - extensionsRoot = []; - capsRoot["extensions"] = extensionsRoot; - } - - extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); - return meta; - } - - /// - /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks - /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session - /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel - /// on an older session is a programming error rather than a recoverable protocol condition. - /// - private void ThrowIfTasksNotSupported(string operationName) - { - if (!IsJuly2026OrLaterProtocol()) - { - throw new InvalidOperationException( - $"'{operationName}' requires a newer protocol revision that supports tasks. " + - $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'."); - } - } } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index da85e9792..d683172da 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -73,7 +73,7 @@ protected McpClient() public abstract Task Completion { get; } /// - /// Resolves input requests embedded in an by dispatching + /// Resolves input requests by dispatching /// each request to the appropriate registered handler. /// /// @@ -82,16 +82,9 @@ protected McpClient() /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - private protected abstract ValueTask> ResolveInputRequestsAsync( + public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); - /// - /// Gets the maximum number of consecutive stuck-in- polls - /// allowed by before the client cancels and throws. - /// Sourced from . - /// - private protected abstract int MaxConsecutiveStuckPolls { get; } - /// /// Inspects a received cacheable result (tools/list, prompts/list, resources/list, /// resources/templates/list, or resources/read) so derived clients can emit diagnostics. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 39f6579e3..07a9f2ea3 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -179,10 +179,9 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not public override Task Completion => _sessionHandler.CompletionTask; /// - private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; /// - private protected override async ValueTask> ResolveInputRequestsAsync( + public override async ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken) { @@ -294,24 +293,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // handshake. Instead, the client calls server/discover to learn the server's // capabilities and then begins sending normal RPCs that carry protocolVersion / // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion - // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize - // handshake when the server doesn't support it. The legacy branch below runs only when + // prefers the 2026-07-28 revision and automatically falls back to the initialize + // handshake when the server doesn't support it. The initialize branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). - if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) + if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion)) { - string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; - - // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being - // on the 2026-07-28 revision when SendRequestAsync is invoked for server/discover. - _negotiatedProtocolVersion = preferredVersion; - _sessionHandler.NegotiatedProtocolVersion = preferredVersion; + string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion; DiscoverResult? discoverResult = null; - bool fallbackToLegacy = false; + bool fallbackToInitialize = false; IList? serverSupportedVersions = null; + string discoverVersion = preferredVersion; - // Apply a probe timeout so dual-era clients don't block forever waiting for a - // legacy server that silently drops unknown methods (per stdio.mdx fallback rules). + // Apply a probe timeout so dual-path clients don't block forever waiting for an + // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is // always bounded by InitializationTimeout (only applied when it is the tighter bound). var probeTimeout = _options.DiscoverProbeTimeout; @@ -323,75 +318,101 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - discoverResult = await SendRequestAsync( - RequestMethods.ServerDiscover, - new DiscoverRequestParams(), - McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, - McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); } catch (UnsupportedProtocolVersionException ex) { - // Spec-recognized modern-server signal: -32022 with data.supported[]. The server is - // modern but doesn't speak our preferred version. Retry with a mutually supported - // version from data.supported[] instead of falling back to legacy initialize. - fallbackToLegacy = true; + // Spec-recognized SEP-2575 signal: -32022 with data.supported[]. The server is + // refusing our preferred version. Retry with a supported per-request metadata + // version if one exists; otherwise fall back to initialize with the highest + // mutually supported initialize-capable version. serverSupportedVersions = (IList)ex.Supported; + var retryVersion = serverSupportedVersions + .Where(McpProtocolVersions.PerRequestMetadataProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault(); + + if (retryVersion is not null) + { + if (_options.ProtocolVersion is { } pinnedVersion && + StringComparer.Ordinal.Compare(retryVersion, pinnedVersion) < 0) + { + throw new McpException( + $"The server does not support the requested protocol version '{pinnedVersion}'. " + + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + + $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."); + } + + discoverVersion = retryVersion; + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + } + else + { + fallbackToInitialize = true; + } } catch (MissingRequiredClientCapabilityException) { - // Spec-recognized modern-server signal: -32021. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32021. The server rejected // our capability set. Surface as-is (no fallback): the user must add capabilities. throw; } catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) { - // Spec-recognized modern-server signal: -32020. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32020. The server rejected // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no - // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. + // fallback): falling back to initialize wouldn't fix a malformed envelope. + throw; + } + catch (McpProtocolException 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. - // Any non-modern JSON-RPC error from the probe indicates a legacy server. + // Any non-SEP-2575 JSON-RPC error from the probe indicates an initialize-handshake server. // Common causes include MethodNotFound from a server that has no // server/discover handler, InvalidParams from a server confused by the // SEP-2575 _meta envelope, ParseError from a server that can't handle our - // payload shape, or any other transport-defined error. The three modern-server + // payload shape, or any other transport-defined error. The three SEP-2575 // signals (-32022 UnsupportedProtocolVersion, -32021 // MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and // never reach here. - fallbackToLegacy = true; + fallbackToInitialize = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no - // response within a reasonable timeout means the server is legacy. Fall back. - fallbackToLegacy = true; + // response within a reasonable timeout means the server requires initialize. Fall back. + fallbackToInitialize = true; } - if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(discoverVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // 2026-07-28 version. Fall back to legacy initialize with the highest - // mutually-supported version from supportedVersions[]. - fallbackToLegacy = true; + // version we are using. Fall back to initialize with the highest + // mutually-supported initialize-capable version from supportedVersions[]. + fallbackToInitialize = true; serverSupportedVersions = discoverResult.SupportedVersions; } - if (fallbackToLegacy) + if (fallbackToInitialize) { - // Reset negotiated state and try legacy initialize. + // Reset negotiated state and try initialize. _negotiatedProtocolVersion = null; _sessionHandler.NegotiatedProtocolVersion = null; string fallbackVersion = serverSupportedVersions? - .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .Where(McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // A non-null ProtocolVersion is also the minimum: refuse to fall back below the // explicitly requested version. String.Compare is the spec's prescribed ordering @@ -403,11 +424,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) $"The server does not support the requested protocol version '{pinnedVersion}'. " + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + (serverSupportedVersions is null - ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + ? "The server appears to require the initialize handshake." : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); } - await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + await PerformInitializeHandshakeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); } else { @@ -422,14 +443,29 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _serverInfo = discoverResult.ServerInfo; _serverInstructions = discoverResult.Instructions; } + + async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken) + { + // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being + // on a per-request metadata revision when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + + return await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + } } else { - // Legacy initialize handshake. Reached only when the caller explicitly pinned a + // initialize handshake. Reached only when the caller explicitly pinned a // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // _options.ProtocolVersion is non-null here. - string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; - await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); + string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; + await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -449,10 +485,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } /// - /// Performs the legacy initialize handshake (initialize request + initialized notification), + /// Performs the initialize handshake (initialize request + initialized notification), /// records the negotiated protocol version, and stores the server capabilities/info/instructions. /// - private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) + private async Task PerformInitializeHandshakeAsync(string requestProtocol, CancellationToken cancellationToken) { var initializeResponse = await SendRequestAsync( RequestMethods.Initialize, @@ -479,18 +515,16 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella _serverInstructions = initializeResponse.Instructions; // When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. - // When the user pinned the 2026-07-28 version but we fell back (e.g., legacy server rejected - // server/discover), or when no version was pinned, accept any supported response. This is the - // spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever - // version the server advertises. + // When no version was pinned, accept any supported initialize-handshake response. initialize cannot negotiate + // the 2026-07-28 and later protocol revisions. bool isResponseProtocolValid; - if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) + if (_options.ProtocolVersion is { } optionsProtocol && !McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) { isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; } else { - isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); } if (!isResponseProtocolValid) { @@ -525,7 +559,7 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) _serverInstructions = resumeOptions.ServerInstructions; _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion ?? _options.ProtocolVersion - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; @@ -691,7 +725,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && /// /// Injects the 2026-07-28 protocol's per-request _meta fields (protocol version, client info, /// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision - /// (SEP-2575). No-op on a legacy session. + /// (SEP-2575). No-op on an initialize-handshake session. /// private void InjectRequestMetaIfNeeded(JsonRpcRequest request) { diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..61a0613df 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,16 +52,20 @@ public sealed class McpClientOptions /// /// /// + /// Supported values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, + /// and 2026-07-28. + /// + /// /// When (the default), the client prefers the latest revision (2026-07-28), /// which removed the initialize handshake and Streamable HTTP sessions. It probes with - /// server/discover and automatically falls back to the legacy initialize handshake, - /// downgrading to any version the server advertises, when the server does not support that revision. + /// server/discover and automatically falls back to the initialize handshake, + /// downgrading to an initialize-capable version the server advertises, when the server does not support that revision. /// /// /// When non-, this value is both the requested version and the minimum the client /// will accept: the client requests exactly this version and refuses to downgrade below it, throwing an /// instead of falling back. Setting it to 2026-07-28 therefore disables - /// the automatic legacy-server fallback, and setting it to a version that still supports Streamable HTTP + /// the automatic initialize-handshake server fallback, and setting it to a version that still supports Streamable HTTP /// sessions, such as 2025-11-25, forces the initialize handshake and fails if the server /// negotiates a different version. To try more than one version, leave this unset for automatic fallback /// or retry the connection with a different value. @@ -90,7 +94,7 @@ public sealed class McpClientOptions /// /// Gets or sets the timeout applied to the server/discover probe that the client issues - /// before falling back to the legacy initialize handshake. + /// before falling back to the initialize handshake. /// /// /// The probe timeout. The default value is 5 seconds. Use @@ -102,17 +106,17 @@ public sealed class McpClientOptions /// This timeout only has an effect when the client prefers the 2026-07-28 protocol revision, that is, /// when is (the default) or 2026-07-28. /// In that mode the client first probes the server with a - /// server/discover request. A legacy server that predates the 2026-07-28 revision may + /// server/discover request. A server that predates the 2026-07-28 revision may /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the - /// client concludes the server is legacy and falls back to the initialize handshake on the - /// same connection. When the caller pins a legacy , no probe is issued + /// client concludes the server requires initialize and falls back to that handshake on the + /// same connection. When the caller pins an initialize-capable , no probe is issued /// and this value has no effect. /// /// - /// The default is intentionally short so that dual-era clients fall back quickly against legacy + /// The default is intentionally short so that dual-path clients fall back quickly against initialize-handshake /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or - /// satellite links) where a short probe could trigger the legacy fallback before a server on the new revision - /// server has had a chance to respond. The probe is always also bounded by + /// satellite links) where a short probe could trigger the initialize fallback before a server on the + /// per-request metadata revision has had a chance to respond. The probe is always also bounded by /// , which governs the overall connect budget: if this value is /// greater than or equal to , the probe is effectively bounded by /// alone. @@ -147,41 +151,4 @@ public McpClientHandlers Handlers } } - /// - /// Gets or sets the maximum number of consecutive task polls during which a task may report - /// without publishing any new input requests, before - /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws - /// an . - /// - /// - /// The maximum number of consecutive stuck polls allowed. The default value is 60. - /// - /// - /// - /// This guard prevents an unbounded poll loop when the server keeps a task in - /// but never publishes new input requests after the - /// client has already responded to every previously surfaced request. It only affects the - /// long-poll path used by ; - /// it does not affect direct calls. - /// - /// - /// Callers should size this value with the configured server-side poll interval in mind: the - /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs. - /// Setting this to a very small value can cause false positives for servers that are slow to - /// surface follow-up input requests; setting it too large can mask misbehaving servers. - /// - /// - /// The value is less than 1. - public int MaxConsecutiveStuckPolls - { - get; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1."); - } - field = value; - } - } = 60; } diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 26ffdce65..1bfb68f43 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -67,19 +67,19 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a // JSON-RPC error envelope means the peer is signalling something application-level about // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the - // connect-time logic can react. For example, the three modern protocol error codes + // connect-time logic can react. For example, the three per-request metadata protocol error codes // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, // -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from - // legacy servers that don't understand the SEP-2575 _meta envelope) become generic - // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. + // initialize-handshake servers that don't understand the SEP-2575 _meta envelope) become generic + // McpProtocolException instances and trigger the initialize-handshake fallback path. // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. - // The three modern protocol error codes are also surfaced for non-400 status codes + // The three per-request metadata protocol error codes are also surfaced for non-400 status codes // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && (response.StatusCode == HttpStatusCode.BadRequest || - IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -87,7 +87,7 @@ await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsModernProtocolErrorCode(McpErrorCode code) => + private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => code is McpErrorCode.UnsupportedProtocolVersion or McpErrorCode.MissingRequiredClientCapability or McpErrorCode.HeaderMismatch; @@ -217,7 +217,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. - if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) + if (response.Headers.TryGetValues(McpHttpHeaders.SessionId, out var sessionIdValues)) { SessionId = sessionIdValues.FirstOrDefault(); } @@ -528,7 +528,7 @@ internal static void CopyAdditionalHeaders( string? protocolVersion, string? lastEventId = null) { - if (sessionId is not null) + if (sessionId is not null && McpProtocolVersions.SupportsHttpSessions(protocolVersion)) { headers.Add(McpHttpHeaders.SessionId, sessionId); } diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index e686b454f..b2421e854 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -57,6 +57,13 @@ 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 @@ -71,6 +78,20 @@ 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 @@ -113,6 +134,13 @@ 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 @@ -211,6 +239,13 @@ 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 @@ -225,6 +260,20 @@ 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 @@ -267,6 +316,13 @@ 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 @@ -365,6 +421,13 @@ 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 @@ -379,6 +442,20 @@ 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 @@ -421,6 +498,13 @@ 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 @@ -519,6 +603,13 @@ 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 @@ -533,6 +624,20 @@ 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 @@ -575,6 +680,13 @@ 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 @@ -624,6 +736,27 @@ 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 @@ -785,20 +918,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -813,34 +932,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -960,6 +1051,13 @@ 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 @@ -995,6 +1093,27 @@ 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 @@ -1156,20 +1275,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -1184,34 +1289,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1331,6 +1408,13 @@ 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 @@ -1366,6 +1450,27 @@ 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 @@ -1527,20 +1632,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -1555,34 +1646,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1702,6 +1765,13 @@ 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 @@ -1737,6 +1807,27 @@ 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 @@ -1898,20 +1989,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -1926,34 +2003,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -2073,6 +2122,13 @@ 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 @@ -2102,57 +2158,29 @@ true - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled + 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 - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + 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 - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + 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 - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + 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 diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index da1b898dd..b193bb8de 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -54,7 +54,13 @@ private static JsonSerializerOptions CreateDefaultOptions() return options; } - internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => + /// + /// Gets the resolved for from the specified options. + /// + /// The type whose serialization metadata should be resolved. + /// The serializer options providing the type-info resolver chain. + /// The resolved . + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8); @@ -120,12 +126,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(ResourceUpdatedNotificationParams))] [JsonSerializable(typeof(RootsListChangedNotificationParams))] [JsonSerializable(typeof(ToolListChangedNotificationParams))] - [JsonSerializable(typeof(TaskStatusNotificationParams))] - [JsonSerializable(typeof(WorkingTaskNotificationParams))] - [JsonSerializable(typeof(CompletedTaskNotificationParams))] - [JsonSerializable(typeof(FailedTaskNotificationParams))] - [JsonSerializable(typeof(CancelledTaskNotificationParams))] - [JsonSerializable(typeof(InputRequiredTaskNotificationParams))] // MCP Request Params / Results [JsonSerializable(typeof(CallToolRequestParams))] @@ -174,19 +174,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(GetTaskRequestParams))] - [JsonSerializable(typeof(GetTaskResult))] - [JsonSerializable(typeof(WorkingTaskResult))] - [JsonSerializable(typeof(CompletedTaskResult))] - [JsonSerializable(typeof(FailedTaskResult))] - [JsonSerializable(typeof(CancelledTaskResult))] - [JsonSerializable(typeof(InputRequiredTaskResult))] - [JsonSerializable(typeof(UpdateTaskRequestParams))] - [JsonSerializable(typeof(UpdateTaskResult))] - [JsonSerializable(typeof(CancelTaskRequestParams))] - [JsonSerializable(typeof(CancelTaskResult))] - [JsonSerializable(typeof(CreateTaskResult))] - // MCP Content [JsonSerializable(typeof(ContentBlock))] [JsonSerializable(typeof(TextContentBlock))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index e1bd0844b..1747a6303 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -55,7 +55,7 @@ public abstract partial class McpSession : IAsyncDisposable /// definition of "is this peer speaking the 2026-07-28 or later revision" used by both the client and server. /// internal bool IsJuly2026OrLaterProtocol() => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); /// /// Sends a JSON-RPC request to the connected session and waits for a response. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index a0122c9a4..61a1872f2 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -29,18 +29,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent."); /// - /// All protocol versions supported by this implementation. The version constants live on + /// All protocol versions supported by this implementation. The era-specific lists live on /// so the shared source file is the single source of truth. - /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. /// - internal static readonly string[] SupportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + internal static readonly string[] SupportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// Checks if the given protocol version supports priming events. @@ -53,7 +45,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// internal static bool SupportsPrimingEvent(string? protocolVersion) { - const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + const string MinResumabilityProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; if (protocolVersion is null) { @@ -81,7 +73,7 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion) return false; } - return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; + return string.Compare(protocolVersion, McpProtocolVersions.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; } private readonly bool _isServer; @@ -162,7 +154,7 @@ public McpSessionHandler( (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion)) + if (McpProtocolVersions.RequiresPerRequestMetadata(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", @@ -583,8 +575,8 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant 2026-07-28 clients recognize as a modern-server signal and surface as-is rather - // than mistaking it for a legacy server and falling back to the initialize handshake. + // 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( @@ -618,7 +610,7 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) /// /// /// Used by on a 2026-07-28 or later session to carry protocol version, client - /// info, and client capabilities on every outgoing request (replacing what the legacy + /// info, and client capabilities on every outgoing request (replacing what the /// initialize handshake previously negotiated once). /// internal static void InjectRequestMeta( diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 455c36798..ee9f24f3c 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -32,6 +32,7 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index 5ec348c06..6ab6cedf6 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -8,16 +8,19 @@ namespace ModelContextProtocol.Protocol; /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client -/// to learn what a server supports without performing the legacy initialize handshake. +/// to learn what a server supports without performing the initialize handshake. /// /// public sealed class DiscoverResult : Result, ICacheableResult { /// - /// Gets or sets the list of MCP protocol version strings that the server supports. + /// Gets or sets the list of MCP protocol version strings the server supports for subsequent + /// per-request metadata requests. /// /// - /// The client should choose a version from this list for use in subsequent requests. + /// The client should choose a version from this list for subsequent requests that carry the + /// 2026-07-28-style per-request _meta envelope. Versions that require the + /// initialize handshake are negotiated through initialize instead. /// [JsonPropertyName("supportedVersions")] public required IList SupportedVersions { get; set; } diff --git a/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs b/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs index cf26cc3d5..6bf9d633d 100644 --- a/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs @@ -9,5 +9,5 @@ namespace ModelContextProtocol.Protocol; public sealed class EmptyResult : Result { [JsonIgnore] - internal static EmptyResult Instance { get; } = new(); + internal static EmptyResult Instance { get; } = new() { ResultType = "complete" }; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs index 468754e96..63b17cdfe 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs @@ -22,16 +22,17 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeRequestParams : RequestParams { /// - /// Gets or sets the version of the Model Context Protocol that the client wants to use. + /// Gets or sets the initialize-handshake Model Context Protocol version that the client wants to use. /// /// /// /// Protocol version is specified using a date-based versioning scheme in the format "YYYY-MM-DD". - /// The client and server must agree on a protocol version to communicate successfully. + /// The client and server must agree on an initialize-capable protocol version to communicate successfully. /// /// - /// During initialization, the server will check if it supports this requested version. If there's a - /// mismatch, the server will reject the connection with a version mismatch error. + /// During initialization, the server will check if it supports this requested version. Protocol + /// revisions starting with 2026-07-28 do not use initialize; clients select them with + /// server/discover and per-request metadata instead. /// /// /// See the protocol specification for version details. diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs index e79113687..4c0f014f0 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs @@ -22,11 +22,11 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeResult : Result { /// - /// Gets or sets the version of the Model Context Protocol that the server will use for this session. + /// Gets or sets the initialize-handshake Model Context Protocol version that the server will use for this session. /// /// /// - /// This is the protocol version the server has agreed to use, which should match the client's + /// This is the initialize-capable protocol version the server has agreed to use, which should match the client's /// requested version. If there's a mismatch, the client should throw an exception to prevent /// communication issues due to incompatible protocol versions. /// diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 646dac75e..0daeb010a 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -217,7 +217,7 @@ public sealed class Converter : JsonConverter // Per JSON-RPC 2.0, when an error occurs before the request id can be determined // (e.g. parse error or invalid request), the server MUST respond with id=null. // Accept null-id error responses so callers can recognize the structured signal - // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-modern error code). + // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-SEP-2575 error code). return new JsonRpcError { Id = id, diff --git a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs deleted file mode 100644 index a41e4a576..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ModelContextProtocol.Protocol; - -/// -/// Provides constants for well-known MCP extension identifiers. -/// -public static class McpExtensions -{ - /// - /// The extension identifier for the MCP Tasks extension. - /// - /// - /// When included in client per-request capabilities, indicates the client can handle - /// in lieu of a standard result. - /// See the SEP-2663 - /// specification for details. - /// - public const string Tasks = "io.modelcontextprotocol/tasks"; -} diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs index 4d9139953..29db6f5ae 100644 --- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -53,24 +53,4 @@ public static class MetaKeys /// belonging to different subscriptions on a shared channel (especially STDIO). /// public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId"; - - /// - /// The metadata key used to associate requests, responses, and notifications with a task. - /// - /// - /// - /// This constant defines the key "io.modelcontextprotocol/related-task" used in the - /// _meta field to associate messages with their originating task across the entire - /// request lifecycle. - /// - /// - /// For example, an elicitation that a task-augmented tool call depends on must share the - /// same related task ID with that tool call's task. - /// - /// - /// For tasks/get, tasks/list, and tasks/cancel operations, this - /// metadata should not be included as the taskId is already present in the message structure. - /// - /// - public const string RelatedTask = "io.modelcontextprotocol/related-task"; } diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 0bd3348f8..7911d9e33 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -144,16 +144,6 @@ public static class NotificationMethods /// public const string CancelledNotification = "notifications/cancelled"; - /// - /// The name of the notification sent by the server to push task status updates to subscribed clients. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Each notification carries a complete task state for the current status, identical to what - /// tasks/get would have returned at that moment. - /// - public const string TaskStatusNotification = "notifications/tasks/status"; - /// /// The name of the notification sent first on a /// response stream to indicate which notification types the server agreed to deliver. diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs index 54432a4c2..b13a746cd 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class NotificationParams { - /// Prevent external derivations. - private protected NotificationParams() + /// Initializes the base notification parameter type. + protected NotificationParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a2884efba..f0300a564 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -125,33 +125,6 @@ public static class RequestMethods /// public const string Initialize = "initialize"; - /// - /// The name of the request method sent from the client to poll for task completion. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Clients poll for task status by sending this request with the task ID. - /// - public const string TasksGet = "tasks/get"; - - /// - /// The name of the request method sent from the client to provide input responses to a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Used when a task has input_required status and the client needs to fulfill outstanding requests. - /// - public const string TasksUpdate = "tasks/update"; - - /// - /// The name of the request method sent from the client to signal intent to cancel a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Cancellation is cooperative — the server decides whether and when to honor it. - /// - public const string TasksCancel = "tasks/cancel"; - /// /// The name of the request method sent from the client to discover the server's protocol versions, /// capabilities, and metadata. @@ -159,15 +132,15 @@ public static class RequestMethods /// /// /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client - /// to learn what a server supports without performing the legacy initialize handshake. + /// to learn what a server supports without performing the initialize handshake. /// /// /// The server's response includes its supported protocol versions, capabilities, implementation /// information, and optional usage instructions. /// /// - /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Clients on the 2026-07-28 revision - /// typically call this once during connection establishment. + /// Servers SHOULD implement this method. Initialize-handshake clients MAY ignore it. Clients on the + /// 2026-07-28 revision typically call this once during connection establishment. /// /// public const string ServerDiscover = "server/discover"; @@ -179,7 +152,7 @@ public static class RequestMethods /// /// /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) and replaces the unsolicited - /// HTTP GET endpoint and the legacy / + /// HTTP GET endpoint and the initialize-handshake / /// request methods. /// /// diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 37872ec5b..04c3d82ef 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -11,8 +11,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class RequestParams { - /// Prevent external derivations. - private protected RequestParams() + /// Initializes the base request parameter type. + protected RequestParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 15eb6fa46..e64be9728 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class Result { - /// Prevent external derivations. - private protected Result() + /// Initializes the base result type. + protected Result() { } @@ -28,10 +28,8 @@ private protected Result() /// /// /// When absent or set to "complete", the result is a normal completed response. - /// When set to "input_required", the result is an indicating - /// that additional input is needed before the request can be completed. - /// When set to "task", the result is a indicating that the server - /// has created a long-running task in lieu of returning the result directly. + /// Other values discriminate alternate result subtypes so callers can choose the appropriate + /// concrete payload to deserialize. /// /// /// Defaults to , which is equivalent to "complete". diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs new file mode 100644 index 000000000..0944ba135 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -0,0 +1,95 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a request that may return either the standard result or an alternate +/// subtype for scenarios like asynchronous task execution. +/// +/// The standard result type for the request (e.g., ). +/// +/// +/// Extensions that augment request handling (such as the Tasks extension) use this type to indicate +/// that the server returned an alternate result instead of the normal one. The alternate carries its +/// own so the transport layer can serialize it without compile-time knowledge +/// of the concrete type. +/// +/// +/// Use to determine which variant was returned, then access either +/// for the immediate result or for the alternate. +/// +/// +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public class ResultOrAlternate where TResult : Result +{ + private readonly TResult? _result; + private readonly Result? _alternate; + private readonly JsonTypeInfo? _alternateTypeInfo; + + /// + /// Initializes a new instance of with an immediate result. + /// + /// The standard result returned by the server. + public ResultOrAlternate(TResult result) + { + Throw.IfNull(result); + _result = result; + } + + /// + /// Initializes a new instance of with an alternate result. + /// + /// The alternate result. + /// The used to serialize the alternate result. + private ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) + { + Throw.IfNull(alternate); + Throw.IfNull(alternateTypeInfo); + _alternate = alternate; + _alternateTypeInfo = alternateTypeInfo; + } + + /// + /// Creates a that carries an alternate subtype + /// (for example an InputRequiredResult or a task-creation result) in place of the standard result. + /// + /// The concrete alternate result type. + /// The alternate result to return instead of the standard result. + /// + /// The used to serialize . Requiring the strongly-typed + /// contract keeps the alternate value paired with matching serializer metadata rather than an unrelated type. + /// + /// A that wraps the alternate result. + public static ResultOrAlternate FromAlternate(TAlternate alternate, JsonTypeInfo alternateTypeInfo) + where TAlternate : Result + => new(alternate, alternateTypeInfo); + + /// + /// Gets a value indicating whether the server returned an alternate result instead of the standard result. + /// + public bool IsAlternate => _alternate is not null; + + /// + /// Gets the immediate result, or if the server returned an alternate. + /// + public TResult? Result => _result; + + /// + /// Gets the alternate result, or if the server returned the standard result. + /// + public Result? Alternate => _alternate; + + /// + /// Gets the for serializing the alternate result, or + /// if the server returned the standard result. + /// + public JsonTypeInfo? AlternateTypeInfo => _alternateTypeInfo; + + /// + /// Implicitly converts a to a + /// wrapping the immediate result. + /// + /// The result to wrap. + public static implicit operator ResultOrAlternate(TResult result) => new(result); +} diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index f15ce316c..2a21008f4 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -46,48 +46,35 @@ public void Set( }; } +#pragma warning disable MCPEXP002 // SetWithAlternate consumes the experimental ResultOrAlternate seam /// - /// Registers a handler that may return either a standard result or a - /// for task-augmented execution. + /// Registers a handler that may return either a standard result or an alternate + /// subtype for scenarios like task-augmented execution. /// - public void SetTaskAugmented( + public void SetWithAlternate( string method, - Func>> handler, + Func>> handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) + JsonTypeInfo responseTypeInfo) where TResult : Result { Throw.IfNull(method); Throw.IfNull(handler); Throw.IfNull(requestTypeInfo); Throw.IfNull(responseTypeInfo); - Throw.IfNull(taskResultTypeInfo); this[method] = async (request, cancellationToken) => { TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); - if (augmented.IsTask) + if (augmented.IsAlternate) { - // Guard against a misconfiguration where a handler opts into task-augmented - // execution but the server has no task lifecycle handlers wired up. Without - // tasks/get, a client that received a CreateTaskResult would have no way to - // poll the task to completion. Configure McpServerOptions.TaskStore or set - // the task handlers explicitly via McpServerOptions.Handlers. - if (!ContainsKey(RequestMethods.TasksGet)) - { - throw new InvalidOperationException( - $"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " + - $"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " + - "or set the task handlers explicitly in McpServerOptions.Handlers before starting the server."); - } - - return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo); + return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!); } return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); }; } +#pragma warning restore MCPEXP002 } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b8f96237a..7aab34826 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,18 +5,66 @@ namespace ModelContextProtocol.Server; #pragma warning disable MCPEXP002 -internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer +internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer #pragma warning restore MCPEXP002 { + private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext); + private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities; + private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo; + public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; - public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; - public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public override LoggingLevel? LoggingLevel => server.LoggingLevel; + public override ClientCapabilities? ClientCapabilities + { + get + { + // In stateless transport mode, a single request does not have a persistent bidirectional channel. + // Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the + // capability gates rely on a null ClientCapabilities value to report that unsupported-state path. + if (!server.HasStatefulTransport()) + { + return null; + } + + // On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request) + // and must not be inferred from prior requests. Missing per-request capabilities therefore means + // "no declared capabilities for this request", represented by an empty object. A fresh instance is + // returned deliberately: ClientCapabilities is a mutable DTO handed to user handlers, so a shared + // static empty instance could be mutated and leak across requests. + if (_isJuly2026OrLaterRequest) + { + return _requestClientCapabilities ?? new ClientCapabilities(); + } + + // Legacy protocol behavior uses session-scoped capabilities established during initialize (or + // pre-populated migration data), so ignore per-request values and return the server session state. + return server.ClientCapabilities; + } + } + + public override Implementation? ClientInfo + { + get + { + // On protocol revision 2026-07-28+, client info is request-scoped (carried in each request's _meta), + // mirroring how ClientCapabilities is resolved above. Return only this request's declared value and + // do not fall back to shared session state, which under a stateful transport could belong to a + // different concurrent request. + if (_isJuly2026OrLaterRequest) + { + return _requestClientInfo; + } + + // Legacy protocol behavior uses session-scoped client info established during initialize. + return server.ClientInfo; + } + } + /// /// Gets or sets the MRTR context for the current request, if any. /// Set by when an MRTR-aware handler invocation is in progress. diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index 9d9774e8b..cb8c9db23 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Server; @@ -42,8 +43,10 @@ public IList> ListTool /// requests. The handler should implement logic to execute the requested tool and return appropriate results. /// /// - /// Cannot be used together with . If both are non-empty at configuration time, - /// an will be thrown. + /// Cannot be used together with . If both are non-empty at configuration time, + /// an will be thrown. This can happen indirectly when combining features that + /// register different tool-call filter styles, such as authorization filters (which use this collection) and the + /// tasks extension (which uses ). /// /// public IList> CallToolFilters @@ -56,22 +59,26 @@ public IList> CallToolFi } } +#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam /// - /// Gets or sets the filters for the call-tool handler pipeline with task support. + /// Gets or sets the filters for the call-tool handler pipeline with alternate result support. /// /// /// - /// These filters wrap the task-augmented call-tool handler whose return type is - /// . Use these filters when the server's tool pipeline - /// supports returning either an immediate or a - /// for asynchronous execution. + /// These filters wrap the alternate-result call-tool handler whose return type is + /// . Use these filters when the server's tool pipeline + /// supports returning either an immediate or an alternate + /// subtype for asynchronous execution. /// /// /// Cannot be used together with . If both are non-empty at configuration time, - /// an will be thrown. + /// an will be thrown. This can happen indirectly when combining features that + /// register different tool-call filter styles, such as the tasks extension (which uses this collection) and + /// authorization filters (which use ). /// /// - public IList>> CallToolWithTaskFilters + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IList>> CallToolWithAlternateFilters { get => field ??= []; set @@ -80,6 +87,7 @@ public IList /// Gets or sets the filters for the list-prompts handler pipeline. diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 4f739c28a..6e3c94f6a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -23,6 +23,52 @@ public abstract partial class McpServer : McpSession private static Dictionary>? s_elicitAllowedProperties = null; + /// + /// Ambient interceptor that, when installed, redirects server-initiated requests + /// (, + /// , and + /// ) away from the + /// transport. The interceptor receives the request method and pre-serialized parameters and + /// returns the serialized result. Used by extensions (such as the Tasks extension) to surface + /// these requests through an alternate channel during background execution. + /// + private static readonly AsyncLocal>?> s_outgoingRequestInterceptor = new(); + + /// + /// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any. + /// + internal static Func>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value; + + /// + /// Installs an interceptor that redirects server-initiated requests for the duration of the + /// returned scope on the current asynchronous execution context. + /// + /// + /// The interceptor invoked for each outgoing request. It receives the request method, the + /// pre-serialized request parameters (or ), and a cancellation token, and + /// returns the serialized result (or to indicate no result). + /// + /// An that restores the previous interceptor when disposed. + /// is . + /// + /// While an interceptor is installed, the redirected methods skip their client-capability checks, + /// because the alternate channel is responsible for delivering the request to the client. + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IDisposable InterceptOutgoingRequests(Func> interceptor) + { + Throw.IfNull(interceptor); + + var previous = s_outgoingRequestInterceptor.Value; + s_outgoingRequestInterceptor.Value = interceptor; + return new OutgoingRequestInterceptorScope(previous); + } + + private sealed class OutgoingRequestInterceptorScope(Func>? previous) : IDisposable + { + public void Dispose() => s_outgoingRequestInterceptor.Value = previous; + } + /// /// Creates a new instance of an . /// @@ -61,11 +107,6 @@ public static McpServer Create( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// - /// - /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for the client response, - /// then returns to when the response is received. - /// /// [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ValueTask SampleAsync( @@ -74,14 +115,13 @@ public ValueTask SampleAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect sampling through the task store. - // Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are + // intentionally skipped because the interceptor's alternate channel is responsible for + // delivering the request to the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams, + return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult, cancellationToken); @@ -282,14 +322,13 @@ public ValueTask RequestRootsAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect through the task store. - // Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped + // because the interceptor's alternate channel is responsible for delivering the request to + // the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams, + return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsResult, cancellationToken); @@ -322,11 +361,6 @@ public ValueTask RequestRootsAsync( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// - /// - /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for user input, - /// then returns to when the response is received. - /// /// public async ValueTask ElicitAsync( ElicitRequestParams requestParams, @@ -334,18 +368,15 @@ public async ValueTask ElicitAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect elicitation through the task store. - // Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because - // the client opted into the tasks extension when submitting the originating request, and - // input requests are delivered through the tasks/get response channel rather than as - // direct server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) - { - var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.ElicitResult, - cancellationToken).ConfigureAwait(false); - return taskResult ?? new ElicitResult { Action = "cancel" }; + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are + // intentionally skipped because the interceptor's alternate channel is responsible for + // delivering the request to the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) + { + var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams); + var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false); + return resultNode?.Deserialize(McpJsonUtilities.JsonContext.Default.ElicitResult) ?? new ElicitResult { Action = "cancel" }; } ThrowIfElicitationUnsupported(requestParams); @@ -422,26 +453,6 @@ public async ValueTask> ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } - /// - /// Sends a task status notification to the connected client. - /// - /// The task status notification parameters to send. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous send operation. - /// is . - public Task SendTaskStatusNotificationAsync( - TaskStatusNotificationParams notificationParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(notificationParams); - - return SendNotificationAsync( - NotificationMethods.TaskStatusNotification, - notificationParams, - McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams, - cancellationToken); - } - /// /// Builds a request schema for elicitation based on the public serializable properties of . /// @@ -592,84 +603,37 @@ private void ThrowIfRootsUnsupported() } /// - /// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through - /// the task store as input requests for the duration of the scope. Use this when executing tool logic - /// in the background as a task, so that any server-to-client requests are surfaced to the client via - /// the task's state instead of direct JSON-RPC messages. - /// - /// The task ID in the store. - /// The task store to write input requests to. - /// An that restores the previous context when disposed. - public IDisposable CreateMcpTaskScope( - string taskId, - IMcpTaskStore store) - { - Throw.IfNull(taskId); - Throw.IfNull(store); - - var previous = McpTaskExecutionContext.Current.Value; - McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext - { - TaskId = taskId, - Store = store, - }; - return new McpTaskExecutionContext.Scope(previous); - } - - /// - /// Sends a server-initiated request through the task store as an input request, then awaits the response. + /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response. /// /// - /// When executing inside a task scope, capability negotiation checks (such as + /// When an interceptor is installed, capability negotiation checks (such as /// , , and /// ) are intentionally skipped by the callers - /// of this helper. The task channel itself is the negotiated capability: the client opted - /// in to the tasks extension when it submitted the originating request, and is responsible - /// for handling or rejecting the input requests surfaced through tasks/get. + /// of this helper. The interceptor's alternate channel is the negotiated capability and is + /// responsible for delivering the request to the client or rejecting it. /// - private async ValueTask SendRequestViaTaskAsync( - McpTaskExecutionContext taskContext, + private async ValueTask SendRequestViaInterceptorAsync( + Func> interceptor, string method, TRequest request, JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo, CancellationToken cancellationToken) { - var requestId = Guid.NewGuid().ToString("N"); - var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo); - - var inputRequest = new InputRequest + var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo); + var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false); + if (resultNode is null) { - Method = method, - Params = paramsJson, - }; - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - void handler(InputResponseReceivedEventArgs args) - { - if (args.TaskId == taskContext.TaskId && args.RequestId == requestId) - { - tcs.TrySetResult(args.Response); - } + // A null result cannot be deserialized into a concrete TResponse (e.g. SampleAsync's + // CreateMessageResult or RequestRootsAsync's ListRootsResult). Returning default! here + // would hand callers a null response typed as non-null, deferring the failure to a + // confusing NullReferenceException at the use site. Fail fast with a clear message. + // Callers that can tolerate no result (such as ElicitAsync) do not route through this + // helper and handle null themselves. + throw new McpException($"The outgoing-request interceptor returned no result for the '{method}' request."); } - taskContext.Store.InputResponseReceived += handler; - try - { - await taskContext.Store.SetInputRequestsAsync( - taskContext.TaskId, - new Dictionary { [requestId] = inputRequest }, - cancellationToken).ConfigureAwait(false); - - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - return response.Deserialize(responseTypeInfo)!; - } - finally - { - taskContext.Store.InputResponseReceived -= handler; - } + return resultNode.Deserialize(responseTypeInfo)!; } private void ThrowIfElicitationUnsupported(ElicitRequestParams request) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 5f8ebf69a..a51010dc0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -21,9 +21,19 @@ protected McpServer() /// /// /// - /// These capabilities are established during the initialization handshake and indicate - /// which features the client supports, such as sampling, roots, and other - /// protocol-specific functionality. + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier), these + /// capabilities are established once during initialization and are session-scoped: they are available both + /// on the root and on the server exposed to request handlers. + /// + /// + /// On the 2026-07-28 revision and later (SEP-2575) there is no initialize handshake; the client + /// declares its capabilities per-request in _meta, and the server MUST NOT infer them from previous + /// requests. In that mode this property is only meaningful on the request-scoped server accessed via + /// the Server property of the passed to a handler; on the + /// root (for example one constructed manually over a + /// ) it is . + /// It is also in stateless transport mode, where server-to-client requests are + /// unsupported. /// /// /// Server implementations can check these capabilities to determine which features @@ -38,7 +48,14 @@ protected McpServer() /// /// /// This property contains identification information about the client that has connected to this server, - /// including its name and version. This information is provided by the client during initialization. + /// including its name and version. + /// + /// + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier) this + /// information is provided once during initialization and is session-scoped. On the 2026-07-28 + /// revision and later it is carried per-request in _meta, so read it from the request-scoped server + /// accessed via the Server property of the passed to a handler + /// rather than from the root . /// /// /// Server implementations can use this information for logging, tracking client versions, diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 4f9509b9d..7e655603a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Server; @@ -36,25 +37,26 @@ public sealed class McpServerHandlers /// public McpRequestHandler? ListToolsHandler { get; set; } +#pragma warning disable MCPEXP002 // CallToolHandler and CallToolWithAlternateHandler reference the experimental ResultOrAlternate seam /// /// Gets or sets the handler for requests. /// /// /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. - /// Use instead if the tool may return a - /// for asynchronous execution. + /// Use instead if the tool may return an alternate result + /// for the caller to handle. /// - /// is already set. + /// is already set. public McpRequestHandler? CallToolHandler { get; set { - if (value is not null && CallToolWithTaskHandler is not null) + if (value is not null && CallToolWithAlternateHandler is not null) { throw new InvalidOperationException( - $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured."); + $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithAlternateHandler)} is already set. Only one call tool handler may be configured."); } field = value; @@ -62,20 +64,20 @@ public McpRequestHandler? CallToolHandler } /// - /// Gets or sets the handler for requests with task support. + /// Gets or sets the handler for requests with alternate result support. /// /// /// /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either - /// a for immediate results or a for - /// long-running asynchronous operations. + /// a for immediate results or an alternate subtype. /// /// /// Cannot be set if is already set. /// /// /// is already set. - public McpRequestHandler>? CallToolWithTaskHandler + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public McpRequestHandler>? CallToolWithAlternateHandler { get; set @@ -83,12 +85,13 @@ public McpRequestHandler /// Gets or sets the handler for requests. @@ -202,74 +205,6 @@ public McpRequestHandler? SetLoggingLevelHandler { get; set; } - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client polls for the current state of a task. - /// The handler should return the appropriate subtype - /// based on the task's current status (for example, , - /// , , - /// , or ). - /// - /// - /// Setting is the recommended way to wire all three - /// task lifecycle handlers (, , - /// and ) from a single source while still allowing explicit - /// handlers to override any slot. If can return a - /// but no is configured (either - /// directly or via a task store), the server throws - /// when processing the request so misconfigured deployments fail loudly instead of producing - /// unpollable tasks. - /// - /// - public McpRequestHandler? GetTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client provides input responses for a task - /// that is in the state. Responses keyed - /// by an identifier that does not currently correspond to an outstanding input request - /// (including responses for tasks in a terminal state) should be silently ignored per - /// SEP-2663. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store dispatches to - /// and raises - /// to wake any pending - /// - /// or - /// calls executing inside a task scope. - /// - /// - public McpRequestHandler? UpdateTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client requests cancellation of an in-progress task. - /// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should - /// always acknowledge the request with , even if the task is - /// unknown, already terminal, or cannot actually be stopped. Whether the task transitions - /// to is up to the implementation. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store calls - /// and signals the per-task - /// so the tool's - /// observes cancellation. - /// - /// - public McpRequestHandler? CancelTaskHandler { get; set; } - /// Gets or sets notification handlers to register with the server. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..8b38421c4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -27,11 +27,21 @@ internal sealed partial class McpServerImpl : McpServer private readonly NotificationHandlers _notificationHandlers; private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; + private readonly string[] _supportedProtocolVersions; + private readonly string[] _initializeHandshakeProtocolVersions; + private readonly string[] _perRequestMetadataProtocolVersions; private readonly SemaphoreSlim _disposeLock = new(1, 1); - private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); + private static readonly string[] s_perRequestMetadataKeys = + [ + MetaKeys.ProtocolVersion, + MetaKeys.ClientInfo, + MetaKeys.ClientCapabilities, + MetaKeys.LogLevel, + ]; + // Track MRTR handler tasks using the same inFlightCount + TCS pattern as // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. private int _mrtrInFlightCount = 1; @@ -72,6 +82,9 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact _sessionTransport = transport; ServerOptions = options; Services = serviceProvider; + _supportedProtocolVersions = GetConfiguredSupportedProtocolVersions(options.ProtocolVersion); + _initializeHandshakeProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.SupportsInitializeHandshake)]; + _perRequestMetadataProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata)]; _serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})"; _endpointName = _serverOnlyEndpointName; _servicesScopePerRequest = options.ScopeRequests; @@ -95,8 +108,8 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); - ConfigureTasks(options); ConfigureMrtr(); + ConfigureCustomRequestHandlers(options); // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) @@ -152,56 +165,111 @@ void Register(McpServerPrimitiveCollection? collection, /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// synchronizes server-side state (, - /// , ) from the per-request _meta - /// values projected onto and validates the per-request protocol - /// version, before delegating to the user-supplied incoming filters. + /// synchronizes server-side state (, ) + /// from the per-request _meta values projected onto and + /// validates the per-request protocol version, before delegating to the user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values - /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in + /// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped + /// by and are not read from server-wide state by request handlers. The + /// shared write below is best-effort and used only to derive the session endpoint + /// name for logging/telemetry. For initialize-handshake clients the per-request values are absent and the built-in /// filter is a no-op (the values were captured during the initialize handler). /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { - if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) + if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest) + { + ValidateInitializeRequestBoundary(initializeRequest); + } + else if (message is JsonRpcRequest request) { + var context = request.Context; bool endpointNameNeedsRefresh = false; + bool hasProtocolVersionMeta = HasMetaKey(request, MetaKeys.ProtocolVersion); + bool hasReservedPerRequestMeta = TryGetPerRequestMetadataKey(request, out var reservedPerRequestMetaKey); - if (context.ProtocolVersion is { } protocolVersion) + if (context?.ProtocolVersion is { } protocolVersion) { + bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; + if (protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(protocolVersion); + } + // Per SEP-2575, the server MUST reject any request whose per-request // _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions // with an UnsupportedProtocolVersionError (-32022) carrying the supported list. - if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) + if (!_supportedProtocolVersions.Contains(protocolVersion)) { throw new UnsupportedProtocolVersionException( requested: protocolVersion, - supported: McpSessionHandler.SupportedProtocolVersions); + supported: _supportedProtocolVersions); } - SetNegotiatedProtocolVersion(protocolVersion); + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) + { + ValidateRequiredPerRequestMetadata( + protocolVersion, + hasProtocolVersionMeta, + context.ClientInfo is not null, + context.ClientCapabilities is not null); + } + else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) + { + if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: _perRequestMetadataProtocolVersions, + message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata."); + } + + if (hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey); + } + } + + if (!protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(protocolVersion); + } } + else if (_negotiatedProtocolVersion is null) + { + if (request.Method == RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", + McpErrorCode.InvalidParams); + } - if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) + if (hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); + } + } + else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) { - // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL - // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate - // makes any legacy per-request envelope a no-op (legacy capabilities stay as the - // initialize handshake established them); the HasStatefulTransport() gate keeps - // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } - // (where the same server instance handles every request, so persisting per-request - // capability state would both leak across requests and break the StatelessServerTests - // invariant that surfaces the "X is not supported in stateless mode" errors). - _clientCapabilities = clientCapabilities; + ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); } - if (context.ClientInfo is { } clientInfo && + ValidateRequestMethodBoundary(request); + + if (context?.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { + // This shared write is best-effort and used only to derive the session endpoint name for + // logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions: + // DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from + // the per-request _meta so concurrent requests never observe each other's values. Under a + // draft stateful session with differing per-request client info, the last writer wins here, + // which only affects the logged endpoint name and never the request-scoped values handlers see. _clientInfo = clientInfo; endpointNameNeedsRefresh = true; } @@ -212,6 +280,10 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner _sessionHandler.EndpointName = _endpointName; } } + else if (message is JsonRpcNotification notification) + { + ValidateNotificationBoundary(notification); + } await next(message, cancellationToken).ConfigureAwait(false); }; @@ -219,6 +291,139 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner return next => metaReadingFilter(inner(next)); } + private static void ValidateRequiredPerRequestMetadata( + string protocolVersion, + bool hasProtocolVersionMeta, + bool hasClientInfoMeta, + bool hasClientCapabilitiesMeta) + { + if (!hasProtocolVersionMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); + } + + if (!hasClientInfoMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo); + } + + if (!hasClientCapabilitiesMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + } + } + + private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => + throw new McpProtocolException( + $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", + McpErrorCode.InvalidParams); + + private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) => + throw new McpProtocolException( + requestedProtocolVersion is null + ? $"The reserved per-request metadata key '_meta/{key}' requires a protocol version that uses per-request metadata." + : $"The reserved per-request metadata key '_meta/{key}' is not valid with protocol version '{requestedProtocolVersion}'.", + McpErrorCode.InvalidRequest); + + private static bool TryGetPerRequestMetadataKey(JsonRpcRequest request, out string key) + { + foreach (var candidate in s_perRequestMetadataKeys) + { + if (HasMetaKey(request, candidate)) + { + key = candidate; + return true; + } + } + + key = ""; + return false; + } + + private static bool HasMetaKey(JsonRpcRequest request, string key) => + request.Params is JsonObject paramsObj && + paramsObj["_meta"] is JsonObject metaObj && + metaObj.ContainsKey(key); + + private void ValidateInitializeRequestBoundary(JsonRpcRequest request) + { + if (request.Context?.ProtocolVersion is { } protocolVersion && + !McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: _initializeHandshakeProtocolVersions, + message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake."); + } + + if (TryGetPerRequestMetadataKey(request, out var key)) + { + ThrowReservedPerRequestMetadata(TryGetStringParam(request, "protocolVersion"), key); + } + } + + private static string? TryGetStringParam(JsonRpcRequest request, string propertyName) + { + if (request.Params is JsonObject paramsObj && + paramsObj[propertyName] is JsonValue value && + value.TryGetValue(out string? result)) + { + return result; + } + + return null; + } + + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return McpProtocolVersions.SupportedProtocolVersions; + } + + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; + } + + private void ValidateNotificationBoundary(JsonRpcNotification notification) + { + if (notification.Method == NotificationMethods.InitializedNotification && + McpProtocolVersions.RequiresPerRequestMetadata(notification.Context?.ProtocolVersion ?? _negotiatedProtocolVersion)) + { + throw new McpProtocolException( + $"The notification '{NotificationMethods.InitializedNotification}' is only valid after the initialize handshake.", + McpErrorCode.InvalidRequest); + } + } + + private void ValidateRequestMethodBoundary(JsonRpcRequest request) + { + bool usesPerRequestMetadata = IsJuly2026OrLaterProtocolRequest(request); + + if (!usesPerRequestMetadata && + request.Method is RequestMethods.SubscriptionsListen + or RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The method '{request.Method}' requires a newer protocol revision that supports per-request metadata; " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + + if (usesPerRequestMetadata && request.Method == RequestMethods.LoggingSetLevel) + { + throw new McpProtocolException( + $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + McpErrorCode.MethodNotFound); + } + } + /// public override string? SessionId => _sessionTransport.SessionId; @@ -312,13 +517,6 @@ public override async ValueTask DisposeAsync() _disposed = true; - foreach (var kvp in _taskCancellationSources) - { - kvp.Value.Cancel(); - kvp.Value.Dispose(); - } - _taskCancellationSources.Clear(); - // Dispose the session handler - cancels message processing and waits for all // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. // After this returns, no new requests can be processed and no new MRTR continuations @@ -361,29 +559,58 @@ private void ConfigureInitialize(McpServerOptions options) UpdateEndpointNameWithClientInfo(); _sessionHandler.EndpointName = _endpointName; - // Negotiate a protocol version. If the server options provide one, use that. - // Otherwise, try to use whatever the client requested as long as it's supported. - // If it's not supported, fall back to the latest supported version. + // Negotiate an initialize-handshake protocol version. initialize is not available in the 2026-07-28 + // and later protocol revisions, so those versions must use server/discover with + // per-request _meta instead. string? protocolVersion = options.ProtocolVersion; - protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && - McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? - clientProtocolVersion : - McpHttpHeaders.November2025ProtocolVersion; + if (protocolVersion is { } configuredProtocolVersion && + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(configuredProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + configuredProtocolVersion, + _initializeHandshakeProtocolVersions, + $"Protocol version '{configuredProtocolVersion}' is not available through the initialize handshake."); + } - // The legacy initialize handshake is authoritative: it may supersede a protocol version - // a prior server/discover probe established on the same connection (the dual-era + if (protocolVersion is null) + { + if (request?.ProtocolVersion is string clientProtocolVersion) + { + if (McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(clientProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + clientProtocolVersion, + _initializeHandshakeProtocolVersions, + $"Protocol version '{clientProtocolVersion}' is not available through the initialize handshake."); + } + + protocolVersion = McpProtocolVersions.SupportsInitializeHandshake(clientProtocolVersion) ? + clientProtocolVersion : + McpProtocolVersions.November2025ProtocolVersion; + } + else + { + protocolVersion = McpProtocolVersions.November2025ProtocolVersion; + } + } + + string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; + + // The initialize handshake is authoritative: it may supersede a protocol version + // a prior server/discover probe established on the same connection (the dual-path // fallback path a permissive client takes against an unknown server). Unlike the // per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated - // initialize force-sets the version. - _negotiatedProtocolVersion = protocolVersion; - _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + _negotiatedProtocolVersion = negotiatedProtocolVersion; + _sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion; return new InitializeResult { - ProtocolVersion = protocolVersion, + ProtocolVersion = negotiatedProtocolVersion, Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), + ResultType = "complete", }; }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, @@ -394,9 +621,9 @@ private void ConfigureInitialize(McpServerOptions options) /// Registers the server/discover request handler introduced by the 2026-07-28 protocol revision (SEP-2575). /// /// - /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's - /// supported protocol versions (), server - /// capabilities, server info, and optional instructions. + /// The handler is registered unconditionally so requests can be routed to the protocol boundary filters. Successful + /// server/discover responses advertise only protocol versions available through per-request metadata; versions + /// that require the initialize handshake are negotiated through initialize instead. /// private void ConfigureDiscover(McpServerOptions options) { @@ -405,7 +632,7 @@ private void ConfigureDiscover(McpServerOptions options) { return new ValueTask(new DiscoverResult { - SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions], + SupportedVersions = [.. _perRequestMetadataProtocolVersions], Capabilities = ServerCapabilities ?? new(), ServerInfo = options.ServerInfo ?? DefaultImplementation, Instructions = options.ServerInstructions, @@ -414,6 +641,7 @@ private void ConfigureDiscover(McpServerOptions options) // their "do not cache" behavior while satisfying the wire requirement. TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, + ResultType = "complete", }); }, McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, @@ -440,6 +668,14 @@ private void ConfigureSubscriptions(McpServerOptions options) _requestHandlers.Set(RequestMethods.SubscriptionsListen, async (request, jsonRpcRequest, cancellationToken) => { + if (!IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{RequestMethods.SubscriptionsListen}' requires a newer protocol revision that supports per-request subscriptions; " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); // A stateless session (Streamable HTTP with no session) cannot deliver out-of-band @@ -447,7 +683,7 @@ private void ConfigureSubscriptions(McpServerOptions options) // changes back to the client (tracked by #1662). Rather than hold the POST open forever only // to deliver nothing - pinning the connection and its request scope - acknowledge the listen // request granting no notifications and complete immediately. This runs after protocol - // negotiation, so it is not a legacy-server signal and never triggers a client fallback to the + // negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the // initialize handshake. if (!HasStatefulTransport()) { @@ -458,7 +694,7 @@ private void ConfigureSubscriptions(McpServerOptions options) await SendSubscriptionAckAsync(statelessSubscription, cancellationToken).ConfigureAwait(false); - return new EmptyResult(); + return EmptyResult.Instance; } // Filter the requested notifications against what the server actually supports. @@ -498,7 +734,7 @@ private void ConfigureSubscriptions(McpServerOptions options) _activeSubscriptions.TryRemove(jsonRpcRequest.Id, out _); } - return new EmptyResult(); + return EmptyResult.Instance; }, McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); @@ -527,7 +763,7 @@ private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifi /// private async Task SendListChangedNotificationAsync(string notificationMethod) { - // Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. + // Initialize-handshake clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. // subscriptions/listen is a SEP-2575 feature, so clients on the 2026-07-28 or later revision instead get // a fan-out limited to the notification types they explicitly subscribed to. if (!IsJuly2026OrLaterProtocol()) @@ -754,115 +990,47 @@ private void ConfigureCompletion(McpServerOptions options) return result; } - private void ConfigureTasks(McpServerOptions options) + private void ConfigureExperimentalAndExtensions(McpServerOptions options) { - var getTaskHandler = options.Handlers.GetTaskHandler; - var updateTaskHandler = options.Handlers.UpdateTaskHandler; - var cancelTaskHandler = options.Handlers.CancelTaskHandler; - var taskStore = options.TaskStore; - - // If a task store is provided, wire up handlers from it for any that aren't explicitly set. - if (taskStore is not null) - { - getTaskHandler ??= async (request, cancellationToken) => - { - var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false); - return info is null - ? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams) - : ToGetTaskResult(info); - }; - - updateTaskHandler ??= async (request, cancellationToken) => - { - var inputResponses = request.Params!.InputResponses ?? new Dictionary(); - await taskStore.ResolveInputRequestsAsync(request.Params.TaskId, inputResponses, cancellationToken).ConfigureAwait(false); - - return new UpdateTaskResult(); - }; - - cancelTaskHandler ??= async (request, cancellationToken) => - { - // Idempotent ack per SEP-2663: always return CancelTaskResult regardless of whether - // the task was known/cancellable. The store's SetCancelledAsync no-ops for unknown - // or already-terminal tasks; we still surface a success response to the client. - await taskStore.SetCancelledAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false); - - // Signal the task's CancellationTokenSource if one exists. Whichever side - // (this handler or the background runner's finally block) wins TryRemove owns disposal, - // which prevents the runner from observing ObjectDisposedException through cts.Token. - if (_taskCancellationSources.TryRemove(request.Params.TaskId, out var cts)) - { - cts.Cancel(); - cts.Dispose(); - } - - return new CancelTaskResult(); - }; - } + ServerCapabilities.Experimental = options.Capabilities?.Experimental; + ServerCapabilities.Extensions = options.Capabilities?.Extensions; + } - if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null) + private void ConfigureCustomRequestHandlers(McpServerOptions options) + { +#pragma warning disable MCPEXP002 + if (options.RequestHandlers is not { Count: > 0 } customHandlers) { return; } - getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - - // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with - // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for 2026-07-28 requests. - getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); - updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); - cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); - - // Advertise tasks extension in server capabilities. - ServerCapabilities.Extensions ??= new Dictionary(); - ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); - - SetHandler( - RequestMethods.TasksGet, - getTaskHandler, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult); - - SetHandler( - RequestMethods.TasksUpdate, - updateTaskHandler, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult); - - SetHandler( - RequestMethods.TasksCancel, - cancelTaskHandler, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult); - } - - /// - /// Wraps a tasks/* request handler so it throws unless the - /// request was negotiated under the 2026-07-28 or later revision. The tasks extension (SEP-2663) only - /// interoperates on the 2026-07-28 revision, and these methods don't exist on older peers. - /// - private McpRequestHandler GateTaskMethodToJuly2026OrLaterProtocol( - McpRequestHandler inner, string method) - => (request, cancellationToken) => + foreach (var entry in customHandlers) { - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) + if (string.IsNullOrEmpty(entry.Method)) { - throw new McpProtocolException( - $"The method '{method}' requires a newer protocol revision that supports tasks; " + - $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", - McpErrorCode.MethodNotFound); + throw new InvalidOperationException( + $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has a null or empty {nameof(McpServerRequestHandler.Method)}."); } - return inner(request, cancellationToken); - }; + // Custom handlers are registered after all built-in handlers, so a method already present + // belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler. + // Silently overwriting it would bypass the built-in handler's filters and protocol gating, + // so reject the collision instead. + if (_requestHandlers.ContainsKey(entry.Method)) + { + throw new InvalidOperationException( + $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} " + + $"uses the method '{entry.Method}', which is already handled by the server. Custom handlers cannot replace built-in methods or other custom handlers."); + } - private void ConfigureExperimentalAndExtensions(McpServerOptions options) + SetRawHandler(entry.Method, entry.Handler); + } +#pragma warning restore MCPEXP002 + } + + private void SetRawHandler(string method, Func> handler) { - ServerCapabilities.Experimental = options.Capabilities?.Experimental; - ServerCapabilities.Extensions = options.Capabilities?.Extensions; + _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask(); } private void ConfigureResources(McpServerOptions options) @@ -888,7 +1056,7 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); readResourceHandler ??= (static async (request, _) => { - var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) + var errorCode = McpProtocolVersions.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) ? McpErrorCode.InvalidParams : McpErrorCode.ResourceNotFound; throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode); @@ -1130,15 +1298,16 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals McpJsonUtilities.JsonContext.Default.GetPromptResult); } +#pragma warning disable MCPEXP002 // tool dispatch wires up the experimental alternate call-tool handler and filters private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; var callToolHandler = options.Handlers.CallToolHandler; - var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler; + var callToolWithAlternateHandler = options.Handlers.CallToolWithAlternateHandler; var tools = options.ToolCollection; var toolsCapability = options.Capabilities?.Tools; - if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null && + if (listToolsHandler is null && callToolHandler is null && callToolWithAlternateHandler is null && tools is null && toolsCapability is null) { return; @@ -1150,17 +1319,22 @@ private void ConfigureTools(McpServerOptions options) var listChanged = toolsCapability?.ListChanged; var callToolFilters = options.Filters.Request.CallToolFilters; - var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters; + var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters; - // Validate: cannot mix non-task filters/handler with task filters/handler. - bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0; - bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0; + // Validate: cannot mix non-alternate filters/handler with alternate filters/handler. + bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0; + bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0; - if (hasNonTaskPath && hasTaskPath) + if (hasNonAlternatePath && hasAlternatePath) { throw new InvalidOperationException( - $"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + - $"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other."); + $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + + $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " + + $"These two styles cannot currently be composed on the same server. " + + $"This most commonly happens when combining features that register different tool-call filter styles, " + + $"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " + + $"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " + + $"Configure only one style, or avoid combining features that require different styles."); } // Handle tools provided via DI by augmenting the list handler. @@ -1202,32 +1376,32 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - // Build the unified task-augmented handler from one of the two paths. - if (hasTaskPath) + // Build the unified alternate-result handler from one of the two paths. + if (hasAlternatePath) { - // Case 2: task filter + task handler - callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + // Case 2: alternate filter + alternate handler + callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. if (tools is not null) { - var originalHandler = callToolWithTaskHandler; - callToolWithTaskHandler = (request, cancellationToken) => + var originalHandler = callToolWithAlternateHandler; + callToolWithAlternateHandler = (request, cancellationToken) => { if (request.MatchedPrimitive is McpServerTool tool) { - return InvokeToolAsTask(tool, request, cancellationToken); + return InvokeToolWithAlternate(tool, request, cancellationToken); } return originalHandler(request, cancellationToken); }; } - callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools)); + callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools)); } else { - // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based + // Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. @@ -1247,114 +1421,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); - // Convert to task-based. + // Convert to alternate-based. var finalCallToolHandler = callToolHandler; - callToolWithTaskHandler = async (request, cancellationToken) => + callToolWithAlternateHandler = async (request, cancellationToken) => await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); } - - // If a task store is configured, wrap so that when the client signals task support - // the tool execution is offloaded to the background via the store. - if (options.TaskStore is { } taskStore) - { - var innerTaskHandler = callToolWithTaskHandler; - callToolWithTaskHandler = async (request, cancellationToken) => - { - // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not - // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the - // request was negotiated under the 2026-07-28 or later revision AND the client opted in; otherwise - // run the inner handler and return the direct result (best-effort downgrade, which also - // defends against a non-conformant legacy client that forges the opt-in envelope). - if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) - { - var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); - var taskId = taskInfo.TaskId; - - var cts = new CancellationTokenSource(); - _taskCancellationSources[taskId] = cts; - - // Capture the token synchronously before Task.Run dispatches the work. - // The cancel handler may race with the background runner: whichever side wins - // the TryRemove call owns disposal. If we accessed cts.Token from inside the - // lambda after the handler had already disposed cts, we'd hit ObjectDisposedException. - var taskCancellationToken = cts.Token; - - _ = Task.Run(async () => - { - using (CreateMcpTaskScope(taskId, taskStore)) - { - try - { - var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false); - if (augmented.IsTask) - { - // The handler created its own task externally, but the client already holds - // the store's taskId from the synchronous return below — we can't redirect. - // Fail the store's task so the client sees a clear error instead of polling forever. - var error = new JsonRpcErrorDetail - { - Code = (int)McpErrorCode.InternalError, - Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithTaskHandler)} returned IsTask = true. Use only one mechanism to create the task.", - }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); - await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - return; - } - - var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.JsonContext.Default.CallToolResult); - await taskStore.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); - } - catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) - { - await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch (InputRequiredException) - { - // MRTR (input requests) cannot be composed with the task-store wrapper for - // [McpServerTool] methods today: the task ID was already returned synchronously, - // so we have no way to surface InputRequiredResult to the client retroactively. - // Fail the task with a clear, actionable error instead of leaking the raw - // InputRequiredException through the generic catch below. - var error = new JsonRpcErrorDetail - { - Code = (int)McpErrorCode.InvalidRequest, - Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " + - $"use {nameof(McpServerHandlers.CallToolWithTaskHandler)} to manage the input-request loop manually within the task body.", - }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); - await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - } - catch (Exception ex) - { - // SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}. - // McpProtocolException carries a JSON-RPC ErrorCode and is documented as safe to - // propagate (Message + ErrorCode). For any other exception type, redact the message - // and use InternalError (mirrors the redaction in BuildInitialCallToolFilter). - var error = ex is McpProtocolException mcpEx - ? new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message } - : new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = "An error occurred while executing the task." }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); - await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - } - finally - { - // Only the side that wins TryRemove disposes cts. This prevents a - // double-dispose race with the default tasks/cancel handler. - if (_taskCancellationSources.TryRemove(taskId, out var registeredCts)) - { - registeredCts.Dispose(); - } - } - } - }, CancellationToken.None); - - return ToCreateTaskResult(taskInfo); - } - - return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false); - }; - } - ServerCapabilities.Tools.ListChanged = listChanged; SetHandler( @@ -1363,94 +1434,13 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); - SetTaskAugmentedHandler( + SetWithAlternateHandler( RequestMethods.ToolsCall, - callToolWithTaskHandler, + callToolWithAlternateHandler, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult, - McpJsonUtilities.JsonContext.Default.CreateTaskResult); + McpJsonUtilities.JsonContext.Default.CallToolResult); } - - private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() - { - TaskId = info.TaskId, - Status = info.Status, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "task", - }; - - private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch - { - McpTaskStatus.Working => new WorkingTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "complete", - }, - McpTaskStatus.Completed => new CompletedTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), - ResultType = "complete", - }, - McpTaskStatus.Failed => new FailedTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), - ResultType = "complete", - }, - McpTaskStatus.Cancelled => new CancelledTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "complete", - }, - McpTaskStatus.InputRequired => new InputRequiredTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store - // implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire - // DTO uses IDictionary like every other Protocol type. Most concrete stores back - // their dictionaries with a type that implements both interfaces (Dictionary, - // ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we - // only allocate a copy as a fallback. - InputRequests = info.InputRequests is IDictionary dict - ? dict - : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(), - ResultType = "complete", - }, - _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), - }; - - private static async ValueTask> InvokeToolAsTask( + private static async ValueTask> InvokeToolWithAlternate( McpServerTool tool, RequestContext request, CancellationToken cancellationToken) @@ -1501,7 +1491,7 @@ private McpRequestFilter BuildInitialCall } }; - private McpRequestFilter> BuildInitialTaskToolFilter( + private McpRequestFilter> BuildInitialAlternateToolFilter( McpServerPrimitiveCollection? tools) => handler => async (request, cancellationToken) => { @@ -1514,7 +1504,7 @@ private McpRequestFilter { + if (IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{jsonRpcRequest.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + McpErrorCode.MethodNotFound); + } + // Store the provided level. if (request is not null) { @@ -1627,7 +1625,7 @@ async ValueTask InvokeScopedAsync( private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest.Context); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { @@ -1664,25 +1662,53 @@ private void SetHandler( }; } + if (typeof(Result).IsAssignableFrom(typeof(TResult))) + { + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (result is Result protocolResult && protocolResult.ResultType is null) + { + protocolResult.ResultType = "complete"; + } + + return result; + }; + } + _requestHandlers.Set(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), requestTypeInfo, responseTypeInfo); } - private void SetTaskAugmentedHandler( +#pragma warning disable MCPEXP002 // SetWithAlternateHandler wraps the experimental ResultOrAlternate seam + private void SetWithAlternateHandler( string method, - McpRequestHandler> handler, + McpRequestHandler> handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) + JsonTypeInfo responseTypeInfo) where TResult : Result { - _requestHandlers.SetTaskAugmented(method, + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult) + { + immediateResult.ResultType = "complete"; + } + + return result; + }; + + _requestHandlers.SetWithAlternate(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), - requestTypeInfo, responseTypeInfo, taskResultTypeInfo); + requestTypeInfo, responseTypeInfo); } +#pragma warning restore MCPEXP002 private static McpRequestHandler BuildFilterPipeline( McpRequestHandler baseHandler, @@ -1704,15 +1730,6 @@ private static McpRequestHandler BuildFilterPipeline - meta is not null && - meta[MetaKeys.ClientCapabilities] is JsonObject caps && - caps["extensions"] is JsonObject exts && - exts.ContainsKey(McpExtensions.Tasks); - private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) { if (filters.Count == 0) @@ -1740,7 +1757,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList { // Ensure message has a Context so Items can be shared through the pipeline message.Context ??= new(); - var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message); + var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message.Context), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; @@ -1787,16 +1804,18 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => /// internal bool HasStatefulTransport() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; - /// /// Returns when the given request was negotiated under the 2026-07-28 or later protocol /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works /// for requests over stateless HTTP) and falling back to the session-negotiated version. - /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); + IsJuly2026OrLaterProtocolRequest(request?.Context); + + /// + internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) => + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion( + requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport(); @@ -1817,75 +1836,92 @@ private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => for (int retry = 0; ; retry++) { + InputRequiredResult inputRequiredResult; + Exception? inputRequiredException = null; + try { - return await handler(request, cancellationToken).ConfigureAwait(false); + var result = await handler(request, cancellationToken).ConfigureAwait(false); + + // A handler can surface an input-required result two ways: by throwing InputRequiredException, + // or by RETURNING an InputRequiredResult through the alternate result path (ResultOrAlternate). + // Normalize both forms so a client that doesn't natively support MRTR gets the same server-side + // resolution either way. + if (GetReturnedInputRequiredResult(result) is not { } returnedInputRequired) + { + return result; + } + + inputRequiredResult = returnedInputRequired; } catch (InputRequiredException ex) { - // If the client natively supports MRTR, serialize and return directly - - // the client will drive the retry loop. - if (ClientSupportsMrtr()) - { - return SerializeInputRequiredResult(ex.Result); - } + inputRequiredResult = ex.Result; + inputRequiredException = ex; + } - // In stateless mode without MRTR, the server can't resolve input requests via - // JSON-RPC (no persistent session for server-to-client requests), and the client - // won't recognize the InputRequiredResult. This is the one unsupported configuration. - if (!HasStatefulTransport()) - { - throw new McpException( - "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + - "MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex); - } + // If the client natively supports MRTR, serialize and return directly - + // the client will drive the retry loop. + if (ClientSupportsMrtr()) + { + return SerializeInputRequiredResult(inputRequiredResult); + } - // Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler. - if (ex.Result.InputRequests is not { Count: > 0 } inputRequests) - { - throw new McpException( - "A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex); - } + // In stateless mode without MRTR, the server can't resolve input requests via + // JSON-RPC (no persistent session for server-to-client requests), and the client + // won't recognize the InputRequiredResult. This is the one unsupported configuration. + if (!HasStatefulTransport()) + { + throw new McpException( + "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + + "MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", inputRequiredException); + } - if (retry >= MaxRetries) - { - throw new McpException( - $"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex); - } + // Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler. + if (inputRequiredResult.InputRequests is not { Count: > 0 } inputRequests) + { + throw new McpException( + "A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", inputRequiredException); + } - // Resolve each input request by sending the corresponding JSON-RPC call to the client. - // Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool - // handlers, so they go through the POST's response stream (RelatedTransport) rather than - // the session-level transport. Without this, the messages can race with the client's GET - // stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync - // when no GET request has arrived yet. - var destinationServer = CreateDestinationBoundServer(request); - var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false); - - // Reconstruct request params with inputResponses and requestState for the retry. - var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); - paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( - (IDictionary)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); - - if (ex.Result.RequestState is { } requestState) - { - paramsObj["requestState"] = requestState; - } - else - { - // Strip any stale requestState carried over from the previous round's clone so - // the next tool invocation doesn't see a continuation token the current round is not using. - paramsObj.Remove("requestState"); - } + if (retry >= MaxRetries) + { + throw new McpException( + $"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", inputRequiredException); + } - request = new JsonRpcRequest - { - Id = request.Id, - Method = request.Method, - Params = paramsObj, - Context = request.Context, - }; + // Resolve each input request by sending the corresponding JSON-RPC call to the client. + // Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool + // handlers, so they go through the POST's response stream (RelatedTransport) rather than + // the session-level transport. Without this, the messages can race with the client's GET + // stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync + // when no GET request has arrived yet. + var destinationServer = CreateDestinationBoundServer(request); + var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false); + + // Reconstruct request params with inputResponses and requestState for the retry. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + (IDictionary)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + + if (inputRequiredResult.RequestState is { } requestState) + { + paramsObj["requestState"] = requestState; + } + else + { + // Strip any stale requestState carried over from the previous round's clone so + // the next tool invocation doesn't see a continuation token the current round is not using. + paramsObj.Remove("requestState"); } + + request = new JsonRpcRequest + { + Id = request.Id, + Method = request.Method, + Params = paramsObj, + Context = request.Context, + }; } } @@ -1973,6 +2009,24 @@ private static async Task ResolveInputRequestAsync(McpServer dest private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) => JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); + /// + /// Detects an that a handler surfaced by RETURNING it through the alternate + /// result path (rather than throwing ), so both forms can be resolved + /// identically for clients that don't natively support MRTR. Returns for any other result. + /// + private static InputRequiredResult? GetReturnedInputRequiredResult(JsonNode? result) + { + if (result is JsonObject resultObject && + resultObject.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValueKind() == JsonValueKind.String && + resultTypeNode.GetValue() == "input_required") + { + return JsonSerializer.Deserialize(result, McpJsonUtilities.JsonContext.Default.InputRequiredResult); + } + + return null; + } + /// /// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync/RequestRootsAsync, /// an is returned early and the handler is suspended until the retry arrives. diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index eb99913d5..a84bd600e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Server; @@ -14,7 +14,7 @@ public sealed class McpServerOptions /// Gets or sets information about this server implementation, including its name and version. /// /// - /// This information is sent to the client during initialization to identify the server. + /// This information is sent to the client during initialization or discovery to identify the server. /// It's displayed in client logs and can be used for debugging and compatibility checks. /// public Implementation? ServerInfo { get; set; } @@ -33,11 +33,23 @@ public sealed class McpServerOptions /// Gets or sets the protocol version supported by this server, using a date-based versioning scheme. /// /// - /// The protocol version defines which features and message formats this server supports. - /// This uses a date-based versioning scheme in the format "YYYY-MM-DD". - /// If , the server will advertise to the client the version requested - /// by the client if that version is known to be supported, and otherwise will advertise the latest - /// version supported by the server. + /// + /// The protocol version defines which features and message formats this server supports. Supported + /// values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and + /// 2026-07-28. + /// + /// + /// If , the server supports all of the versions listed above. For clients using + /// the initialize handshake, the server returns the requested initialize-capable version when it + /// is supported and otherwise returns 2025-11-25. For clients using server/discover and + /// per-request metadata, the server advertises the supported per-request metadata versions; currently + /// this is 2026-07-28. + /// + /// + /// Set this property to a specific supported value to pin the server to that version. Setting it to + /// 2026-07-28 makes the server reject initialize handshakes; setting it to an earlier + /// value makes the server reject 2026-07-28 per-request metadata. + /// /// public string? ProtocolVersion { get; set; } @@ -74,12 +86,13 @@ public sealed class McpServerOptions public bool ScopeRequests { get; set; } = true; /// - /// Gets or sets preexisting knowledge about the client including its name and version to help support - /// stateless Streamable HTTP servers that encode this knowledge in the mcp-session-id header. + /// Gets or sets preexisting knowledge about the client including its name and version. /// /// /// - /// When not specified, this information is sourced from the client's initialize request. + /// When not specified, this information is sourced from the client's initialize request or, + /// for protocol versions that use per-request metadata, from the current request's _meta field. + /// This is typically set during session migration in conjunction with . /// /// public Implementation? KnownClientInfo { get; set; } @@ -90,7 +103,8 @@ public sealed class McpServerOptions /// /// /// - /// When not specified, this information is sourced from the client's initialize request. + /// When not specified, this information is sourced from the client's initialize request or, + /// for protocol versions that use per-request metadata, from the current request's _meta field. /// This is typically set during session migration in conjunction with . /// /// @@ -191,17 +205,18 @@ public McpServerFilters Filters public int MaxSamplingOutputTokens { get; set; } = 1000; /// - /// Gets or sets the task store for managing asynchronous task executions. + /// Gets or sets custom request handlers to register with the server. /// /// /// - /// When set, the server automatically enables the io.modelcontextprotocol/tasks extension - /// and wires up tasks/get, tasks/update, and tasks/cancel handlers backed by this store. - /// Tool executions from clients that signal task support will be wrapped in tasks via the store. + /// Each registers a raw JSON-RPC method handler that + /// bypasses the typed handler infrastructure. This enables extensions to register handlers + /// for methods not known to Core at compile time. /// /// - /// If explicit task handlers are also set on , the explicit handlers take precedence. + /// Handlers registered here take precedence over built-in handlers for the same method. /// /// - public IMcpTaskStore? TaskStore { get; set; } + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IList? RequestHandlers { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index e126fb13d..aa281989b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,6 +12,15 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; + /// Lock protecting and . + private readonly object _deferralLock = new(); + + /// Depth counter for active scopes. Positive means notifications are deferred. + private int _activeDeferralScopes; + + /// Whether a change occurred while notifications were deferred. + private bool _hasDeferredChangeEvents; + /// /// Initializes a new instance of the class. /// @@ -33,8 +42,85 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// Gets a value that indicates whether there are any primitives in the collection. public bool IsEmpty => _primitives.IsEmpty; + /// + /// Begins a deferred-change scope. notifications are suppressed + /// until the returned scope is disposed, at which point a single notification is raised + /// if any mutation occurred during the scope. Multiple scopes may be active simultaneously; + /// the notification fires once all active scopes have been disposed. + /// + /// An that ends the deferral scope when disposed. + /// + /// The scope is exception-safe: even if an exception is thrown inside a using block, + /// the deferral is ended on dispose. If any mutation occurred before the exception, a single + /// notification is raised. + /// + /// Mutations from any thread during an open scope are coalesced. A single + /// notification fires on the thread that disposes the last active scope, only if at least one + /// mutation occurred. All deferral state transitions are guarded by an internal lock, so + /// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope + /// instance more than once is safe and has no additional effect. + /// + /// + public IDisposable DeferChangedEvents() + { + lock (_deferralLock) + { + _activeDeferralScopes++; + } + return new ChangeDeferralScope(this); + } + /// Raises if there are registered handlers. - protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); + /// + /// If a scope is active, the notification is deferred until all + /// active scopes are disposed. Derived types that override mutation methods and call + /// will automatically participate in deferral. + /// + protected void RaiseChanged() + { + lock (_deferralLock) + { + if (_activeDeferralScopes > 0) + { + _hasDeferredChangeEvents = true; + return; + } + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + private void EndDeferral() + { + bool raise; + lock (_deferralLock) + { + raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents; + if (raise) + { + _hasDeferredChangeEvents = false; + } + } + + if (raise) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private sealed class ChangeDeferralScope : IDisposable + { + private McpServerPrimitiveCollection? _collection; + + public ChangeDeferralScope(McpServerPrimitiveCollection collection) => + _collection = collection; + + public void Dispose() + { + McpServerPrimitiveCollection? collection = Interlocked.Exchange(ref _collection, null); + collection?.EndDeferral(); + } + } /// Gets the with the specified from the collection. /// The name of the primitive to retrieve. diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs new file mode 100644 index 000000000..be845f599 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a custom request handler that can be registered with the MCP server to handle +/// arbitrary JSON-RPC methods. +/// +/// +/// +/// Custom request handlers are registered via and +/// are invoked when a JSON-RPC request with the matching is received. +/// The handler receives the raw and returns a serialized +/// response, giving extensions full control over request/response serialization. +/// +/// +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public sealed class McpServerRequestHandler +{ + /// + /// Gets the JSON-RPC method name this handler responds to. + /// + public required string Method { get; init; } + + /// + /// Gets the handler function that processes incoming requests for the specified method. + /// + /// + /// The handler receives the full and a , + /// and returns a serialized response (or for void methods). + /// + public required Func> Handler { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs deleted file mode 100644 index 749f36517..000000000 --- a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ModelContextProtocol.Server; - -/// -/// Provides ambient context when a tool is executing as a background task. -/// When established, calls to , -/// , -/// and -/// are redirected through the task store as input requests rather than sent directly to the client. -/// -internal sealed class McpTaskExecutionContext -{ - internal static readonly AsyncLocal Current = new(); - - public required string TaskId { get; init; } - public required IMcpTaskStore Store { get; init; } - - internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable - { - public void Dispose() => Current.Value = previous; - } -} diff --git a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj index 1d7d0dd8a..bd14370d5 100644 --- a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj +++ b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj @@ -5,7 +5,7 @@ true true ModelContextProtocol.Extensions.Apps - MCP Apps extension for the .NET Model Context Protocol (MCP) SDK + MCP Apps extension for building interactive UI applications that render inside MCP hosts. README.md $(NoWarn);MCPEXP001;MCPEXP003 diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs new file mode 100644 index 000000000..88055b2ad --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -0,0 +1,370 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware client operations. +/// +public static class McpTasksClientExtensions +{ + /// + /// Calls a tool and returns either an immediate result or a created task. + /// + public static async ValueTask> CallToolAsTaskAsync( + this McpClient client, + CallToolRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var paramsWithMeta = new CallToolRequestParams + { + Name = requestParams.Name, + Arguments = requestParams.Arguments, + Meta = IsJuly2026OrLaterProtocol(client) ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, + }; + + JsonRpcRequest jsonRpcRequest = new() + { + Method = RequestMethods.ToolsCall, + Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + string.Equals(resultTypeNode?.GetValue(), "task", StringComparison.Ordinal)) + { + var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult) + ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); + return new ResultOrCreatedTask(taskCreated); + } + + var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from response."); + return new ResultOrCreatedTask(callToolResult); + } + + /// + /// Calls a tool and, if needed, polls the created task to completion. + /// + public static async ValueTask CallToolWithPollingAsync( + this McpClient client, + CallToolRequestParams requestParams, + int maxConsecutiveStuckPolls = 60, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var augmented = await client.CallToolAsTaskAsync(requestParams, cancellationToken).ConfigureAwait(false); + if (!augmented.IsTask) + { + return augmented.Result!; + } + + return await PollTaskToCompletionAsync(client, augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a task by ID. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Retrieves a task using explicit request parameters. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + GetTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); + return client.SendRequestAsync( + TasksProtocol.MethodTasksGet, + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); + } + + /// + /// Updates a task with input responses. + /// + public static async ValueTask UpdateTaskAsync( + this McpClient client, + UpdateTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync)); + + // Manually construct the JSON params because InputResponses is backed by an internal + // property in Core that the extension's source-gen context cannot access for serialization. + JsonObject paramsObj = new() + { + ["taskId"] = requestParams.TaskId, + }; + + if (requestParams.InputResponses is { Count: > 0 } inputResponses) + { + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + inputResponses, + McpJsonUtilities.DefaultOptions.GetTypeInfo>()); + } + + JsonRpcRequest jsonRpcRequest = new() + { + Method = TasksProtocol.MethodTasksUpdate, + Params = paramsObj, + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult) + ?? new UpdateTaskResult(); + } + + /// + /// Requests task cancellation by ID. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Requests task cancellation using explicit request parameters. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + CancelTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); + return client.SendRequestAsync( + TasksProtocol.MethodTasksCancel, + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); + } + + private static async ValueTask PollTaskToCompletionAsync( + McpClient client, + CreateTaskResult taskCreated, + int maxConsecutiveStuckPolls, + CancellationToken cancellationToken) + { + string taskId = taskCreated.TaskId; + long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; + HashSet? resolvedRequestKeys = null; + bool isFirstPoll = true; + int consecutiveStuckPolls = 0; + + while (true) + { + if (!isFirstPoll) + { + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); + } + + isFirstPoll = false; + + var taskResult = await GetTaskAsync(client, taskId, cancellationToken).ConfigureAwait(false); + if (taskResult.PollIntervalMs is { } newInterval) + { + pollIntervalMs = newInterval; + } + + switch (taskResult) + { + case CompletedTaskResult completed: + return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); + + case FailedTaskResult failed: + throw new McpException($"Task '{taskId}' failed: {failed.Error}"); + + case CancelledTaskResult: + throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); + + case InputRequiredTaskResult inputRequired: + var newRequests = new Dictionary(); + if (inputRequired.InputRequests is { } incomingRequests) + { + foreach (var kvp in incomingRequests) + { + if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) + { + newRequests[kvp.Key] = kvp.Value; + } + } + } + + if (newRequests.Count > 0) + { + consecutiveStuckPolls = 0; + + IDictionary inputResponses; + try + { + inputResponses = await client.ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw; + } + + await UpdateTaskAsync(client, new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, cancellationToken).ConfigureAwait(false); + + resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); + foreach (var key in inputResponses.Keys) + { + resolvedRequestKeys.Add(key); + } + } + else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw new McpException( + $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + + "without publishing new input requests after all previously requested inputs were resolved."); + } + + break; + + case WorkingTaskResult: + consecutiveStuckPolls = 0; + break; + + default: + throw new McpException($"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); + } + } + } + + private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) + { + JsonObject meta = existingMeta is not null + ? (JsonObject)existingMeta.DeepClone() + : []; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) + { + capsRoot = []; + meta[MetaKeys.ClientCapabilities] = capsRoot; + } + + if (capsRoot["extensions"] is not JsonObject extensionsRoot) + { + extensionsRoot = []; + capsRoot["extensions"] = extensionsRoot; + } + + if (!extensionsRoot.ContainsKey(TasksProtocol.ExtensionId)) + { + extensionsRoot[TasksProtocol.ExtensionId] = new JsonObject(); + } + + return meta; + } + + private static bool IsJuly2026OrLaterProtocol(McpClient client) => + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(client.NegotiatedProtocolVersion); + + private static void ThrowIfTasksNotSupported(McpClient client, string operationName) + { + if (!IsJuly2026OrLaterProtocol(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires a newer protocol revision that supports tasks " + + $"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later). " + + $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs new file mode 100644 index 000000000..13c92ee14 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides source-generated JSON serialization metadata for MCP Tasks extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CreateTaskResult))] +[JsonSerializable(typeof(GetTaskRequestParams))] +[JsonSerializable(typeof(GetTaskResult))] +[JsonSerializable(typeof(WorkingTaskResult))] +[JsonSerializable(typeof(CompletedTaskResult))] +[JsonSerializable(typeof(FailedTaskResult))] +[JsonSerializable(typeof(CancelledTaskResult))] +[JsonSerializable(typeof(InputRequiredTaskResult))] +[JsonSerializable(typeof(UpdateTaskRequestParams))] +[JsonSerializable(typeof(UpdateTaskResult))] +[JsonSerializable(typeof(CancelTaskRequestParams))] +[JsonSerializable(typeof(CancelTaskResult))] +[JsonSerializable(typeof(TaskStatusNotificationParams))] +[JsonSerializable(typeof(WorkingTaskNotificationParams))] +[JsonSerializable(typeof(CompletedTaskNotificationParams))] +[JsonSerializable(typeof(FailedTaskNotificationParams))] +[JsonSerializable(typeof(CancelledTaskNotificationParams))] +[JsonSerializable(typeof(InputRequiredTaskNotificationParams))] +public sealed partial class McpTasksJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj new file mode 100644 index 000000000..0ebeb5773 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -0,0 +1,52 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Tasks + MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + false + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs similarity index 92% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs index ee458d064..f4d6ff69f 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task. diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs index 4d066862b..c9d92a06e 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/cancel request. This is an empty acknowledgement. diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs index 6d9bac23c..7cbb5d134 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs @@ -1,8 +1,9 @@ +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result returned by a server when it creates a task in lieu of a standard result. @@ -24,6 +25,14 @@ namespace ModelContextProtocol.Protocol; /// public sealed class CreateTaskResult : Result { + /// + /// Initializes a new instance of the class. + /// + public CreateTaskResult() + { + ResultType = "task"; + } + /// /// Gets or sets the stable identifier for this task. /// diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs index 52b82d902..a865bf690 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/get request to poll for task completion. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs index 3fea8cc6b..4d083d8a1 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/get request, containing the full task state. @@ -169,7 +169,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -315,7 +315,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs similarity index 94% rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs index 3b705a947..90ee12d27 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the status of an MCP task. diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs similarity index 91% rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs index 87b857470..1e9100c67 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs @@ -1,4 +1,5 @@ -namespace ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a request that supports task-augmented execution, which may be either @@ -31,7 +32,10 @@ public class ResultOrCreatedTask where TResult : Result /// The standard result returned by the server. public ResultOrCreatedTask(TResult result) { - Throw.IfNull(result); + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } _result = result; } @@ -41,7 +45,10 @@ public ResultOrCreatedTask(TResult result) /// The task creation result returned by the server. public ResultOrCreatedTask(CreateTaskResult taskCreated) { - Throw.IfNull(taskCreated); + if (taskCreated is null) + { + throw new ArgumentNullException(nameof(taskCreated)); + } _taskCreated = taskCreated; } diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs index 4e859a22c..020896a1f 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a notifications/tasks notification sent by the server @@ -171,7 +171,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -311,7 +311,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); @@ -390,4 +390,3 @@ public sealed class InputRequiredTaskNotificationParams : TaskStatusNotification [JsonPropertyName("inputRequests")] public IDictionary? InputRequests { get; set; } } - diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs similarity index 93% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs index 07b45de15..aeeb2c79a 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/update request to provide input responses diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs similarity index 88% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs index 531039c23..b9f59f395 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/update request. This is an empty acknowledgement. diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs similarity index 99% rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs index 337cb1946..6851f21ac 100644 --- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an interface for storing and managing the lifecycle of MCP tasks. diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs similarity index 73% rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs index 762598226..d1014c110 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an in-memory implementation of for development and testing scenarios. @@ -15,13 +15,21 @@ namespace ModelContextProtocol.Server; /// Tasks are not persisted across process restarts. /// /// -/// For production scenarios requiring durability, session isolation, or TTL-based cleanup, -/// implement a custom . +/// Tasks created with a are discarded once their time-to-live +/// elapses (as permitted by SEP-2663): an expired task is removed on access, and an opportunistic +/// throttled sweep reclaims expired tasks that are never polled again. Tasks created without a +/// time-to-live are retained until the process exits. +/// +/// +/// For production scenarios requiring durability, session isolation, or more advanced retention +/// policies, implement a custom . /// /// public class InMemoryMcpTaskStore : IMcpTaskStore { private readonly ConcurrentDictionary _tasks = new(); + private static readonly long s_sweepIntervalTicks = TimeSpan.FromSeconds(30).Ticks; + private long _lastSweepTicks = DateTimeOffset.UtcNow.UtcTicks; /// /// Gets or sets the default poll interval in milliseconds for new tasks. @@ -32,13 +40,19 @@ public class InMemoryMcpTaskStore : IMcpTaskStore /// /// Gets or sets the default time-to-live for new tasks, or for unlimited. /// + /// + /// When set to a positive value, tasks are discarded once this duration elapses from their + /// creation. A or non-positive value keeps tasks until the process exits. + /// public TimeSpan? DefaultTimeToLive { get; set; } /// public Task CreateTaskAsync(CancellationToken cancellationToken = default) { - var taskId = Guid.NewGuid().ToString("N"); var now = DateTimeOffset.UtcNow; + SweepExpired(now); + + var taskId = Guid.NewGuid().ToString("N"); var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs); _tasks[taskId] = info; @@ -49,8 +63,19 @@ public Task CreateTaskAsync(CancellationToken cancellationToken = d /// public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) { - _tasks.TryGetValue(taskId, out var info); - return Task.FromResult(info); + var now = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var info)) + { + if (IsExpired(info, now)) + { + _tasks.TryRemove(taskId, out _); + return Task.FromResult(null); + } + + return Task.FromResult(info); + } + + return Task.FromResult(null); } /// @@ -198,6 +223,35 @@ public Task SetInputRequestsAsync( private static bool IsTerminal(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; + private static bool IsExpired(McpTaskInfo info, DateTimeOffset now) => + info.TimeToLive is { } ttl && ttl > TimeSpan.Zero && now - info.CreatedAt >= ttl; + + private void SweepExpired(DateTimeOffset now) + { + long last = Interlocked.Read(ref _lastSweepTicks); + if (now.UtcTicks - last < s_sweepIntervalTicks) + { + return; + } + + // Ensure only one caller runs the sweep per interval; concurrent callers skip it. + if (Interlocked.CompareExchange(ref _lastSweepTicks, now.UtcTicks, last) != last) + { + return; + } + + foreach (var kvp in _tasks) + { + if (IsExpired(kvp.Value, now)) + { + // TaskId values are unique GUIDs that are never reused, and CreatedAt/TimeToLive + // are immutable after creation, so an entry judged expired here stays expired. + // Removing by key is therefore safe even if the value was concurrently updated. + _tasks.TryRemove(kvp.Key, out _); + } + } + } + private void Update(string taskId, Func transform) { SpinWait spin = default; diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs similarity index 92% rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs index 14447bd6f..c1cedf6d1 100644 --- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs @@ -1,6 +1,6 @@ using ModelContextProtocol.Protocol; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides data for the event. diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs similarity index 94% rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs index d275fb3a6..a9a86ed54 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the state of a task in an . diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs new file mode 100644 index 000000000..06073ce17 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -0,0 +1,334 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for to enable MCP Tasks support. +/// +public static class McpTasksBuilderExtensions +{ + /// + /// Enables MCP Tasks support backed by the specified task store. + /// + /// The server builder. + /// The task store. + /// The builder provided in . + public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(store); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#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 McpTasksPostConfigureOptions(store, sp.GetService())); + return builder; + } + + private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions + { + private readonly IMcpTaskStore _store = store; + private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); + + public void PostConfigure(string? name, McpServerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) throw new ArgumentNullException(nameof(options)); +#endif + + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId)) + { + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + } + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, 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.Add(next => async (request, cancellationToken) => + { + if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) + { + var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + var taskId = taskInfo.TaskId; + var cts = new CancellationTokenSource(); + _cancellationSources[taskId] = cts; + var taskCancellationToken = cts.Token; + + _ = Task.Run(async () => + { + try + { + 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); + + 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); + } + + if (_cancellationSources.TryRemove(taskId, out var leftoverCts)) + { + leftoverCts.Dispose(); + } + } + }, CancellationToken.None); + + return ResultOrAlternate.FromAlternate( + ToCreateTaskResult(taskInfo), + McpTasksJsonContext.Default.CreateTaskResult); + } + + return await next(request, cancellationToken).ConfigureAwait(false); + }); + } + + private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + if (info is null) + { + throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams); + } + + return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult); + } + + private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate); + + var taskId = request.Params?["taskId"]?.GetValue() + ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams); + + // Deserialize inputResponses using Core's options which can access the internal + // InputResponsesCore backing property on RequestParams. The extension's source-gen + // context cannot see that internal member. + var inputResponses = request.Params?["inputResponses"]?.Deserialize( + McpJsonUtilities.DefaultOptions.GetTypeInfo>()) + ?? new Dictionary(); + + await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false); + + return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult); + } + + private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + + if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + + return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult); + } + + private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method) + { + if (!IsJuly2026OrLaterProtocolRequest(request)) + { + throw new McpProtocolException( + $"The method '{method}' requires a newer protocol revision that supports tasks " + + $"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later); " + + $"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + } + + private static 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); + + private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() + { + TaskId = info.TaskId, + Status = info.Status, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + }; + + private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch + { + McpTaskStatus.Working => new WorkingTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.Completed => new CompletedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), + ResultType = "complete", + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), + ResultType = "complete", + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + InputRequests = info.InputRequests is IDictionary dict + ? dict + : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(), + ResultType = "complete", + }, + _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs new file mode 100644 index 000000000..31445fd1f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs @@ -0,0 +1,109 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware server operations. +/// +public static class McpTasksServerExtensions +{ + /// + /// Sends a task status notification to the connected client. + /// + /// The server sending the notification. + /// The notification payload. + /// The cancellation token. + /// A task representing the send operation. + public static Task SendTaskStatusNotificationAsync( + this McpServer server, + TaskStatusNotificationParams notificationParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(notificationParams); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams)); +#endif + + return server.SendNotificationAsync( + TasksProtocol.NotificationTaskStatus, + notificationParams, + McpTasksJsonContext.Default.Options, + cancellationToken); + } + + /// + /// 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); + ArgumentNullException.ThrowIfNull(taskId); + ArgumentNullException.ThrowIfNull(store); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#endif + + return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => + { + var requestId = Guid.NewGuid().ToString("N"); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsNode is null + ? default + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + +#if NET + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); +#else + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + var response = await tcs.Task.ConfigureAwait(false); + return JsonNode.Parse(response.RawValue.GetRawText()); + } +#endif + +#if NET + return JsonNode.Parse(response.RawValue.GetRawText()); +#endif + } + finally + { + store.InputResponseReceived -= handler; + } + }); + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs new file mode 100644 index 000000000..44172666a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs @@ -0,0 +1,37 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides constants for the MCP Tasks extension (SEP-2663). +/// +public static class TasksProtocol +{ + /// + /// The extension identifier for the MCP Tasks extension. + /// + public const string ExtensionId = "io.modelcontextprotocol/tasks"; + + /// + /// The name of the request method sent from the client to poll for task completion. + /// + public const string MethodTasksGet = "tasks/get"; + + /// + /// The name of the request method sent from the client to provide input responses to a task. + /// + public const string MethodTasksUpdate = "tasks/update"; + + /// + /// The name of the request method sent from the client to signal intent to cancel a task. + /// + public const string MethodTasksCancel = "tasks/cancel"; + + /// + /// The name of the notification sent by the server when a task's status changes. + /// + public const string NotificationTaskStatus = "notifications/tasks/status"; + + /// + /// The metadata key used to associate requests, responses, and notifications with a task. + /// + public const string MetaRelatedTask = "io.modelcontextprotocol/related-task"; +} diff --git a/src/PACKAGE.md b/src/PACKAGE.md index d849a8f83..749a21f47 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -1,18 +1,22 @@ # 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.0.0-preview.2) for what's new in this version. + ## Packages -This SDK consists of three main packages: +The SDK packages are: + +- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. -- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. +- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. -- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. +- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. -- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. ## Getting Started diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 45ecdcde2..abd593ffc 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -179,17 +179,35 @@ public static bool IsNodeInstalled() } /// - /// Checks whether the SEP-2243 conformance scenarios are available, by reading the - /// installed conformance package version from node_modules. - /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, - /// http-header-validation, and http-custom-header-server-validation scenarios were - /// introduced in conformance package 0.2.0. Reading the installed version (rather than - /// the pinned version in package.json) means this also returns - /// when a newer private build has been installed locally via - /// npm install --no-save <path-to-conformance>. + /// Checks whether the SEP-2243 conformance scenarios are available in the installed + /// conformance package. /// public static bool HasSep2243Scenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "http-standard-headers", + "http-invalid-tool-headers", + "http-header-validation", + "http-custom-header-server-validation"); + + /// + /// Checks whether the SEP-2575 request-metadata client conformance scenario is available + /// in the installed conformance package. + /// + public static bool HasRequestMetadataScenario() + => HasInstalledConformanceScenario("request-metadata"); + + /// + /// Checks whether the installed conformance package contains a spec-conformant + /// http-custom-headers scenario. Prereleases 0.2.0-alpha.5 through 0.2.0-alpha.7 + /// annotated a number-typed parameter with x-mcp-header, which SEP-2243 + /// forbids; a conformant client excludes that tool, so every positive check in the + /// scenario fails. Conformance PR #371 fixed the scenario and shipped it in 0.2.0-alpha.8, + /// so this gate requires at least that version. Unlike , + /// this comparison honors the semver prerelease so older 0.2.0 prereleases are skipped + /// rather than failing spuriously. + /// + public static bool HasConformantCustomHeadersScenario() + => IsInstalledConformanceVersionAtLeast("0.2.0-alpha.8"); /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance @@ -200,25 +218,70 @@ public static bool HasSep2243Scenarios() /// locally via npm install --no-save <path-to-conformance>. /// public static bool HasCachingScenario() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenario("caching"); + + /// + /// Checks whether all named conformance scenarios are present in the installed + /// @modelcontextprotocol/conformance bundle. This is intentionally based on the + /// installed scenario list rather than the package version so prerelease/private builds are + /// gated by the scenarios they actually contain. + /// + private static bool HasInstalledConformanceScenarios(params string[] scenarioNames) + => ReadInstalledConformanceBundle() is { } bundle + && scenarioNames.All(scenarioName => HasInstalledConformanceScenario(bundle, scenarioName)); + + private static bool HasInstalledConformanceScenario(string scenarioName) + => ReadInstalledConformanceBundle() is { } bundle + && HasInstalledConformanceScenario(bundle, scenarioName); + + private static bool HasInstalledConformanceScenario(string bundle, string scenarioName) + => bundle.Contains($"`{scenarioName}`", StringComparison.Ordinal) || + bundle.Contains($"\"{scenarioName}\"", StringComparison.Ordinal) || + bundle.Contains($"'{scenarioName}'", StringComparison.Ordinal); + + private static string? ReadInstalledConformanceBundle() + { + try + { + var repoRoot = FindRepoRoot(); + var bundlePath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); + + // This is a skip gate for scenario-conditional conformance tests, so it must stay + // side-effect-free. If the conformance package isn't installed, report no bundle (the + // scenario is simply gated off); the actual scenario run path restores npm dependencies + // separately via ConformanceTestStartInfo. + if (!File.Exists(bundlePath)) + { + return null; + } + + return File.ReadAllText(bundlePath); + } + catch + { + return null; + } + } /// /// Returns when the conformance package installed in node_modules - /// has a version greater than or equal to . + /// has a semver precedence greater than or equal to , + /// honoring the prerelease component (e.g. "0.2.0-alpha.8"). Returns + /// when no version can be determined. /// - private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) + private static bool IsInstalledConformanceVersionAtLeast(string minimumVersion) { - var version = GetInstalledConformanceVersion(); - return version is not null && version >= minimumVersion; + var installed = GetInstalledConformanceVersionString(); + return installed is not null && CompareSemVer(installed, minimumVersion) >= 0; } /// - /// Reads the version of the conformance package actually installed in node_modules, - /// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be - /// parsed as a . Returns if it cannot be + /// Reads the raw version string of the conformance package installed in node_modules, + /// preserving any prerelease/build suffix. Returns if it cannot be /// determined. /// - private static Version? GetInstalledConformanceVersion() + private static string? GetInstalledConformanceVersionString() { try { @@ -226,25 +289,15 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion var packageJsonPath = Path.Combine( repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); - // This is a skip gate for version-conditional conformance scenarios, so it must stay - // side-effect-free. If the conformance package isn't installed, report no version (the - // scenario is simply gated off); the actual scenario run path restores npm dependencies - // separately via ConformanceTestStartInfo. if (!File.Exists(packageJsonPath)) { return null; } using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("version", out var versionElement) && - versionElement.GetString() is { } versionStr) + if (json.RootElement.TryGetProperty("version", out var versionElement)) { - // Strip any prerelease/build suffix so System.Version can parse it. - var core = versionStr.Split('-', '+')[0]; - if (Version.TryParse(core, out var version)) - { - return version; - } + return versionElement.GetString(); } return null; @@ -256,7 +309,94 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion } /// - /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// Compares two semantic version strings by precedence, honoring the prerelease component + /// per the SemVer 2.0.0 rules used here (numeric identifiers compare numerically, a version + /// with a prerelease has lower precedence than the same version without one, and a shorter + /// set of prerelease identifiers has lower precedence when all preceding ones are equal). + /// Build metadata (after '+') is ignored. Returns a negative value when + /// precedes , zero when equal, and a positive value otherwise. + /// + private static int CompareSemVer(string a, string b) + { + var (coreA, preA) = SplitSemVer(a); + var (coreB, preB) = SplitSemVer(b); + + var coreCompare = coreA.CompareTo(coreB); + if (coreCompare != 0) + { + return coreCompare; + } + + // A version without a prerelease outranks one with a prerelease. + if (preA.Length == 0 && preB.Length == 0) + { + return 0; + } + if (preA.Length == 0) + { + return 1; + } + if (preB.Length == 0) + { + return -1; + } + + var count = Math.Min(preA.Length, preB.Length); + for (var i = 0; i < count; i++) + { + var idA = preA[i]; + var idB = preB[i]; + var numA = int.TryParse(idA, out var na); + var numB = int.TryParse(idB, out var nb); + + int cmp; + if (numA && numB) + { + cmp = na.CompareTo(nb); + } + else if (numA) + { + // Numeric identifiers always have lower precedence than alphanumeric ones. + cmp = -1; + } + else if (numB) + { + cmp = 1; + } + else + { + cmp = string.CompareOrdinal(idA, idB); + } + + if (cmp != 0) + { + return cmp; + } + } + + return preA.Length.CompareTo(preB.Length); + } + + /// + /// Splits a semver string into its numeric core (major.minor.patch) and its prerelease + /// identifiers, ignoring any build metadata after '+'. Missing core components default to 0. + /// + private static (Version Core, string[] Prerelease) SplitSemVer(string version) + { + var withoutBuild = version.Split(new[] { '+' }, 2)[0]; + var parts = withoutBuild.Split(new[] { '-' }, 2); + var prerelease = parts.Length > 1 && parts[1].Length > 0 + ? parts[1].Split('.') + : Array.Empty(); + + var coreParts = parts[0].Split('.'); + int Part(int index) => index < coreParts.Length && int.TryParse(coreParts[index], out var v) ? v : 0; + var core = new Version(Part(0), Part(1), Part(2)); + + return (core, prerelease); + } + + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) /// shared by the server-side conformance tests. @@ -315,7 +455,7 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion await process.WaitForExitAsync(cts.Token); #else // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill - // extension and surface a timeout the same way the modern path does. + // extension and surface a timeout the same way the current target-framework path does. await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); if (!process.HasExited) { @@ -376,16 +516,24 @@ private static bool ConformanceOutputIndicatesSuccess(string output) /// /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) - /// conformance scenarios are available, by reading the installed conformance - /// package version from node_modules. The incomplete-result-* scenarios were - /// introduced in conformance package 0.2.0 (see - /// https://github.com/modelcontextprotocol/conformance/pull/188). - /// Reading the installed version (rather than the pinned version in package.json) means - /// this also returns when a newer private build has been installed - /// locally via npm install --no-save <path-to-conformance>. + /// conformance scenarios are available in the installed conformance package. /// public static bool HasMrtrScenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "input-required-result-basic-elicitation", + "input-required-result-basic-sampling", + "input-required-result-basic-list-roots", + "input-required-result-request-state", + "input-required-result-multiple-input-requests", + "input-required-result-multi-round", + "input-required-result-missing-input-response", + "input-required-result-non-tool-request", + "input-required-result-result-type", + "input-required-result-unsupported-methods", + "input-required-result-tampered-state", + "input-required-result-capability-check", + "input-required-result-ignore-extra-params", + "input-required-result-validate-input"); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 38e503258..e23a097e5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -108,7 +108,7 @@ public async ValueTask DisposeAsync() /// /// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. /// It is gated on the installed conformance -/// package version (>= 0.2.0). The stateless server is +/// package's scenario list. The stateless server is /// started only after the gates pass, so a skipped run binds no port. /// public class CachingConformanceTests(ITestOutputHelper output) @@ -119,7 +119,7 @@ public async Task RunCachingConformanceTest() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasCachingScenario(), - "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); + "SEP-2549 caching conformance scenario is not available in the installed conformance package."); await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 2990b3d83..51eba1f83 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -17,6 +17,8 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); + public static bool HasRequestMetadataScenario => NodeHelpers.HasRequestMetadataScenario(); + public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario(); public ClientConformanceTests(ITestOutputHelper output) { @@ -44,7 +46,7 @@ public ClientConformanceTests(ITestOutputHelper output) [InlineData("auth/resource-mismatch")] [InlineData("auth/pre-registration")] - // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata). + // 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")] @@ -62,14 +64,20 @@ public async Task RunConformanceTest(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // Per-request metadata (SEP-2575) + [Fact(Skip = "SEP-2575 request-metadata conformance scenario is not available in the installed conformance package.", SkipUnless = nameof(HasRequestMetadataScenario))] + public async Task RunConformanceTest_RequestMetadata() + { + var result = await RunClientConformanceScenario("request-metadata"); + + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios are not available in the installed conformance package.", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-invalid-tool-headers")] - // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, - // which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers, - // failing every positive check. Re-enable once a conformant conformance package ships (#1655). - // [InlineData("http-custom-headers")] public async Task RunConformanceTest_Sep2243(string scenario) { // Run the conformance test suite @@ -80,6 +88,23 @@ public async Task RunConformanceTest_Sep2243(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // The http-custom-headers scenario needs a tighter gate than the other SEP-2243 scenarios: + // conformance 0.2.0-alpha.5 through 0.2.0-alpha.7 shipped it with an x-mcp-header on a + // number-typed parameter (forbidden by SEP-2243), which a conformant client excludes, + // failing every positive check. It was fixed upstream in 0.2.0-alpha.8 (conformance #371), + // so require at least that version to avoid spurious failures on older 0.2.0 prereleases. + [Theory(Skip = "Conformant http-custom-headers scenario not available (requires conformance package >= 0.2.0-alpha.8).", SkipUnless = nameof(HasConformantCustomHeadersScenario))] + [InlineData("http-custom-headers")] + public async Task RunConformanceTest_Sep2243_CustomHeaders(string scenario) + { + // Run the conformance test suite + var result = await RunClientConformanceScenario(scenario); + + // Report the results + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario) { // Construct an absolute path to the conformance client executable diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 4da16a3eb..32e3d00b0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -114,7 +114,7 @@ private static McpServerTool CreateUnionHeaderTestTool() public async Task Server_AcceptsUnionIntegerCanonicalForm() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body // carries the decimal form 42.0. The server must treat the union type as integer and match. @@ -135,7 +135,7 @@ public async Task Server_AcceptsUnionIntegerCanonicalForm() public async Task Server_RejectsUnionIntegerOutsideSafeRange() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); var callJson = CallTool("union_test", """{"priority":9007199254740993}"""); @@ -154,7 +154,7 @@ public async Task Server_RejectsUnionIntegerOutsideSafeRange() public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100". var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}"""); @@ -177,7 +177,7 @@ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values // and compare the trimmed value to the request body. @@ -201,7 +201,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); @@ -224,7 +224,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call with an empty string param that has an x-mcp-header. // The header should be present with an empty value, matching the body's empty string. @@ -248,7 +248,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call where the body has a non-empty value but the header is empty var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}"""); @@ -271,7 +271,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Encode a value with a newline control character using Base64 var valueWithNewline = "line1\nline2"; @@ -297,7 +297,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without // losing precision through a double conversion. @@ -326,7 +326,7 @@ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243 integer values MUST be within the JavaScript safe integer range. // A matching header and body that are both outside the range must still be rejected. @@ -355,7 +355,7 @@ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and // "4.2e1" are preserved in the body exactly as another SDK might serialize them. @@ -382,7 +382,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected // even when the header and body strings are byte-for-byte identical (it must not slip through @@ -407,7 +407,7 @@ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(strin public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Header says "99" but body says priority:42 — must reject even with numeric comparison var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}"""); @@ -427,17 +427,17 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() } [Fact] - public async Task Server_SkipsHeaderValidation_ForLegacyVersion() + public async Task Server_SkipsHeaderValidation_ForInitializeHandshakeVersion() { await StartAsync(); - await InitializeWithLegacyVersionAsync(); + await InitializeWithInitializeHandshakeVersionAsync(); - // With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched - var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + // With the initialize-handshake version, Mcp-Param-* headers are NOT validated even if mismatched. + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includePerRequestMetadata: false); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - // Send the WRONG header value. This should still succeed because the version is legacy. + // Send the WRONG header value. This should still succeed because the version uses initialize. request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); @@ -451,7 +451,7 @@ public async Task Server_SkipsHeaderValidation_ForLegacyVersion() public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers // instead of properly base64-encoding non-ASCII values. @@ -561,40 +561,39 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) + public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } #endregion #region Helpers - private async Task InitializeWithJuly2026ProtocolVersionAsync() + private async Task ProbeWithJuly2026ProtocolVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a - // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 - // so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private async Task InitializeWithLegacyVersionAsync() + private async Task InitializeWithInitializeHandshakeVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return + // Server is stateless by default (SEP-2567), so initializing with an initialize-handshake protocol does not return // a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision. } @@ -602,22 +601,24 @@ private async Task InitializeWithLegacyVersionAsync() private long _lastRequestId = 1; - private string CallTool(string toolName, string arguments = "{}") + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$$""" - {"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}} - """; + var meta = includePerRequestMetadata + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"tools/call\",\"params\":{\"name\":\"" + + toolName + "\",\"arguments\":" + arguments + meta + "}}"; } private static string InitializeRequest => """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 02bfba288..12bae02bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -14,7 +14,8 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These +/// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize +/// handshake over Streamable HTTP. These /// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's /// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body /// on a 2026-07-28 probe; vanilla Go does the same on POST /) so the client's HTTP-fallback @@ -29,15 +30,15 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// only surfaced the three error codes /// introduced by the 2026-07-28 revision (-32022, -32021, -32020) as ; -/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from an initialize-handshake server /// that doesn't understand the 2026-07-28 _meta envelope) threw /// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger -/// on ANY non-modern JSON-RPC error in a 400 body. +/// on ANY non-SEP-2575 JSON-RPC error in a 400 body. /// /// /// treated any non-2xx HTTP response as a /// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked -/// application-level errors (including the three modern codes) because the SSE GET would +/// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would /// either fail with "session id required" or succeed against a different endpoint and lose /// the actual signal. /// @@ -86,11 +87,11 @@ private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatus /// /// Mimics Python's simple-streamablehttp-stateless on a 2026-07-28 probe: returns /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the - /// initial server/discover, then performs a normal legacy initialize handshake + /// initial server/discover, then performs a normal initialize handshake /// when the client falls back. /// [Fact] - public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() { var ct = TestContext.Current.CancellationToken; @@ -107,7 +108,7 @@ await StartServerAsync(async context => return; } - // 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with + // 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). if (request.Method == RequestMethods.ServerDiscover) { @@ -115,7 +116,7 @@ await StartServerAsync(async context => return; } - // Legacy initialize: respond with the highest version the legacy server speaks. + // Initialize handshake: respond with the highest version this server speaks. if (request.Method == RequestMethods.Initialize) { var response = new JsonRpcResponse @@ -123,9 +124,9 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-06-18", + ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion, Capabilities = new() { Tools = new() }, - ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; @@ -157,11 +158,11 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); // Sanity: subsequent traffic still works post-fallback. var tools = await client.ListToolsAsync(cancellationToken: ct); @@ -170,7 +171,7 @@ await StartServerAsync(async context => /// /// Mimics vanilla Go: returns 400 + JSON-RPC -32022 with - /// data.supported[] on a 2026-07-28 probe so the client retries legacy + /// data.supported[] on a 2026-07-28 probe so the client retries /// initialize with one of the advertised versions. /// [Fact] @@ -197,8 +198,8 @@ await StartServerAsync(async context => // Use the typed payload type so the source-generated serializer can handle it. var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData { - Supported = new List { "2025-11-25" }, - Requested = "2026-07-28", + Supported = new List { McpProtocolVersions.November2025ProtocolVersion }, + Requested = McpProtocolVersions.July2026ProtocolVersion, }, GetJsonTypeInfo()); var rpcError = new JsonRpcError @@ -225,7 +226,7 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), @@ -244,16 +245,16 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } /// /// A 400 with a JSON-RPC -32020 HeaderMismatch envelope must be surfaced to the - /// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope. + /// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope. /// [Fact] public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() @@ -293,11 +294,133 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); Assert.False(initializeReceived); } + + [Fact] + public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() + { + var ct = TestContext.Current.CancellationToken; + string? toolsListSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session"; + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest) + { + toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = toolsListRequest.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + await client.ListToolsAsync(cancellationToken: ct); + + Assert.Null(client.SessionId); + Assert.Equal("", toolsListSessionId); + } + + [Fact] + public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest() + { + var ct = TestContext.Current.CancellationToken; + string? discoverSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + KnownSessionId = "legacy-session", + OwnsSession = false, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("", discoverSessionId); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 9cce9b0db..ab9d04b70 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server -/// suppresses the Mcp-Session-Id header for those requests and returns structured +/// does not issue Mcp-Session-Id for those requests and returns structured /// errors instead of plain 400s. /// public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable @@ -52,12 +52,12 @@ public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() { await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); // On a stateless server, server/discover succeeds without creating a session. var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -70,15 +70,15 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that - // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back - // to the legacy initialize handshake. + // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back + // to the initialize handshake. await StartAsync(stateless: false); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -93,22 +93,22 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers var dataElement = (JsonElement)rpcError.Error.Data!; var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); Assert.NotNull(errorData); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested); - // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version. + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, errorData.Requested); + // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version. Assert.NotEmpty(errorData.Supported); - Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported); + Assert.DoesNotContain(McpProtocolVersions.July2026ProtocolVersion, errorData.Supported); } [Fact] public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() { - await StartAsync(); + await StartAsync(stateless: true); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -128,23 +128,23 @@ public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProto } [Fact] - public async Task Request_WithMcpSessionIdHeader_IsRejected() + public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSessionId() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the - // Stateless setting. - await StartAsync(); + // a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs. + await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); } [Fact] @@ -152,11 +152,12 @@ public async Task Get_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -164,12 +165,13 @@ public async Task Get_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -177,11 +179,12 @@ public async Task Delete_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -189,11 +192,16 @@ public async Task Delete_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } + + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"July2026HttpHandlerTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index ef2d3f6aa..d593ff2f2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -14,9 +14,9 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// server that deliberately opted into sessions ( /// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports /// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. -/// The client must then auto-downgrade to the legacy initialize handshake, obtain the stateful session +/// The 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 legacy backcompat resolver. +/// resolved over the stateful session via the initialize-handshake backcompat resolver. /// public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -38,7 +38,7 @@ public async ValueTask DisposeAsync() private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { // Server→client round-trip: only works when the session is stateful, which is exactly what - // the legacy fallback re-establishes for the 2026-07-28-first client. + // the initialize fallback re-establishes for the 2026-07-28-first client. var elicitResult = await server.ElicitAsync(new ElicitRequestParams { Message = "What is your name?", @@ -77,21 +77,21 @@ private async Task ConnectDefaultClientAsync(Action }, HttpClient, LoggerFactory); // Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes - // with server/discover before falling back to a legacy initialize handshake. + // with server/discover before falling back to an initialize handshake. var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } [Fact] - public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_AndToolsWork() { await StartStatefulServerAsync(); await using var client = await ConnectDefaultClientAsync(); - // The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy. - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet", new Dictionary { ["name"] = "Alice" }, @@ -118,7 +118,7 @@ public async Task DefaultClient_AgainstStatefulServer_ServerToClientElicitation_ }); }); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet_via_elicit", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index eb036ad29..d75877bab 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 68076e292..aa724bb1b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -147,7 +147,7 @@ static CallToolResult (RequestContext context) => // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); } public async ValueTask DisposeAsync() @@ -261,7 +261,7 @@ public async Task CapabilityCheck_OnlyEmitsInputRequestsForDeclaredCapabilities( public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { // Configure a server that does NOT pin 2026-07-28 so it can negotiate the current - // protocol with a legacy client. The backcompat resolver path only runs when the + // initialize-handshake protocol. The backcompat resolver path only runs when the // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { @@ -302,7 +302,7 @@ static string (RequestContext context) => HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - // Initialize with the current legacy protocol so the server's backcompat resolver runs. + // Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs. var initJson = """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} """; @@ -337,7 +337,7 @@ static string (RequestContext context) => // the response headers arrive instead of waiting for the SSE stream to close. var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) { - Content = JsonContent(CallTool("backcompat-roots-tool")), + Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)), }; callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); @@ -456,16 +456,49 @@ private Task PostJsonRpcAsync(string json) private long _lastRequestId = 1; - private string Request(string method, string parameters = "{}") + private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$""" - {"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}} - """; + var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject(); + if (includePerRequestMetadata) + { + AddJuly2026ProtocolMeta(paramsObj); + } + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = paramsObj, + }; + + return request.ToJsonString(); + } + + private static void AddJuly2026ProtocolMeta(JsonObject paramsObj) + { + if (paramsObj["_meta"] is not JsonObject meta) + { + meta = []; + paramsObj["_meta"] = meta; + } + + meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion; + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "MrtrTestClient", + ["version"] = "1.0", + }; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } } - private string CallTool(string toolName, string arguments = "{}") => + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} - """); + """, includePerRequestMetadata); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 366e4276d..edf58e743 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -23,12 +23,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn private WebApplication? _app; - private async Task StartAsync() + private async Task StartAsync(string? protocolVersion = null) { Builder.Services .AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); @@ -82,7 +83,7 @@ private static async Task ReadJsonResponseAsync(HttpResponseMessage re return JsonNode.Parse(body)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -97,7 +98,7 @@ public async Task July2026ToolsCall_WithFullMeta_Succeeds_200() July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -119,14 +120,14 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); // 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. @@ -135,6 +136,24 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); } + [Fact] + public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + } + [Fact] public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022() { @@ -151,7 +170,7 @@ public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_W using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload - // listing the supported versions. The dual-era client uses this to switch versions without fallback. + // listing the supported versions. The dual-path client uses this to switch versions without fallback. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); @@ -160,7 +179,7 @@ public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_W Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] @@ -171,17 +190,18 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi // The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a // different (still individually supported) version. Per SEP-2575 the server MUST reject the // disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body - // checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a - // legacy one and falling back to the initialize handshake. + // checks) so a conformant client on this revision surfaces the error instead of mistaking the + // per-request-metadata server for an initialize-handshake one and falling back to initialize. var body = @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); @@ -203,7 +223,7 @@ public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_Echoes July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -214,11 +234,87 @@ public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_Echoes } [Fact] - public async Task LegacyInitialize_StillSucceeds_OnDefaultServer() + public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion() + { + await StartAsync(McpProtocolVersions.November2025ProtocolVersion); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); + + var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); + } + + [Fact] + public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + 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); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", RequestMethods.Initialize); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task InitializeHandshake_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""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -243,4 +339,3 @@ public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Retur Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 807daaefe..73d000797 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -112,7 +112,7 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protoco var body = july2026Protocol ? """ {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} - """.Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion) + """.Replace("PROTOCOL_VERSION", McpProtocolVersions.July2026ProtocolVersion) : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; var request = new HttpRequestMessage(HttpMethod.Post, "") @@ -125,7 +125,7 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protoco if (july2026Protocol) { - request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "blockingTool"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 82fb9c020..8aaacc7d6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -174,7 +174,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless // lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call @@ -196,7 +196,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); await using var server = await StatelessConformanceServer.StartAsync( TestContext.Current.CancellationToken, basePort: 3024); @@ -245,7 +245,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package."); var result = await RunStatelessConformanceTestAsync( $"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 04ebb6492..2bdd9d7e6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -951,12 +951,12 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server. await StartAsync(stateless: true); - // Initialize with the 2026-07-28 protocol version to enable header validation - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + // Probe with the 2026-07-28 protocol version to enable header validation. + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header @@ -968,11 +968,11 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method @@ -984,11 +984,11 @@ public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -998,12 +998,12 @@ public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() } [Fact] - public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() + public async Task InitializeHandshakeVersion_DoesNotRequireMcpMethodHeader() { await StartAsync(); await CallInitializeAndValidateAsync(); - // With the legacy version, Mcp-Method header is not required + // With the initialize-handshake version, Mcp-Method header is not required. using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); request.Headers.Add("MCP-Protocol-Version", "2025-03-26"); @@ -1013,25 +1013,25 @@ public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync() + private async Task CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); var rpcResponse = await AssertSingleSseResponseAsync(response); - AssertServerInfo(rpcResponse); + AssertDiscoverServerInfo(rpcResponse); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id. - // Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion @@ -1103,10 +1103,14 @@ private string Request(string method, string parameters = "{}") """; } - private string CallTool(string toolName, string arguments = "{}") => - Request("tools/call", $$""" - {"name":"{{toolName}}","arguments":{{arguments}}} - """); + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = false) + { + var meta = includePerRequestMetadata + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return Request("tools/call", "{\"name\":\"" + toolName + "\",\"arguments\":" + arguments + meta + "}"); + } private string CallToolWithProgressToken(string toolName, string arguments = "{}") => Request("tools/call", $$$""" @@ -1126,6 +1130,14 @@ private static InitializeResult AssertServerInfo(JsonRpcResponse rpcResponse) return initializeResult; } + private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse) + { + var discoverResult = AssertType(rpcResponse.Result); + Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name); + Assert.Equal("73", discoverResult.ServerInfo.Version); + return discoverResult; + } + private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse) { var callToolResponse = AssertType(rpcResponse.Result); diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 13af5d170..60d07d46e 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -37,11 +37,11 @@ }; // The default client now prefers the 2026-07-28 protocol (probing with server/discover and -// falling back to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios -// specifically exercise the legacy initialize handshake and SSE resumability (removed in the +// 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, so pin them to the // latest stable version. Other scenarios run on the 2026-07-28 default and exercise the -// server/discover probe plus the transparent legacy fallback. +// server/discover probe plus the transparent initialize-handshake fallback. if (scenario is "initialize" or "sse-retry") { options.ProtocolVersion = "2025-11-25"; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index a06548a4f..e5ae0474d 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -9,12 +9,12 @@ namespace ModelContextProtocol.Tests.Client; /// /// Connection-flow tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) /// on . A client that requests -/// calls server/discover rather than +/// calls server/discover rather than /// initialize. /// public class July2026ProtocolConnectionTests : ClientServerTestBase { - private const string LatestStableVersion = "2025-11-25"; + private const string LatestStableVersion = McpProtocolVersions.November2025ProtocolVersion; public July2026ProtocolConnectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) @@ -34,42 +34,40 @@ public async Task Client_RequestingJuly2026Protocol_NegotiatesIt() { StartServer(); - var options = new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }; + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; await using var client = await CreateMcpClientForServer(options); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); Assert.NotNull(client.ServerCapabilities); Assert.Equal(nameof(July2026ProtocolConnectionTests), client.ServerInfo.Name); } [Fact] - public async Task Client_RequestingLegacyVersion_NegotiatesLegacy() + public async Task Client_RequestingInitializeHandshakeVersion_NegotiatesIt() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - Assert.NotEqual(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] - public async Task LegacyClient_CanCallServerDiscover() + public async Task InitializeHandshakeClient_CannotCallServerDiscover() { - // server/discover is registered unconditionally, so a legacy client can probe it - // (e.g., to learn capabilities without doing a second initialize). + // server/discover is registered unconditionally so the protocol boundary filter can return a structured + // error, but initialize-handshake clients cannot use it after negotiating an older protocol version. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - var response = await client.SendRequestAsync( - new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, - TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync(async () => + await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken)); - var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); - Assert.NotNull(discoverResult); - Assert.NotEmpty(discoverResult.SupportedVersions); - Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); - Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name); + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + Assert.Contains(RequestMethods.ServerDiscover, exception.Message, StringComparison.Ordinal); } [Fact] @@ -85,6 +83,7 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); + Assert.Equal("complete", discoverResult.ResultType); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index f2bc24cb5..03f8f5b33 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -8,17 +8,17 @@ namespace ModelContextProtocol.Tests.Client; /// -/// Regression tests for the fallback from the 2026-07-28 protocol revision to a legacy protocol in +/// Regression tests for the fallback from the 2026-07-28 protocol revision to an initialize-handshake protocol in /// . With default options (ProtocolVersion = null) the client prefers -/// 2026-07-28 but probes with server/discover, falls back to the legacy initialize -/// handshake when the server is legacy, and accepts whatever supported protocol version the legacy +/// 2026-07-28 but probes with server/discover, falls back to the initialize +/// handshake when the server only supports that path, and accepts whatever supported protocol version the /// server negotiates. Pinning ProtocolVersion to 2026-07-28 instead makes it the /// minimum too, so the client refuses to fall back. /// /// -/// The originally shipped logic in PerformLegacyInitializeAsync compared the server's response -/// against the requested version and threw when a legacy server downgraded to (say) "2025-06-18", -/// even though the legacy negotiation succeeded. These tests guard against that regression. +/// The originally shipped initialize-handshake fallback logic compared the server's response +/// against the requested version and threw when an initialize-handshake server downgraded to (say) +/// "2025-06-18", even though negotiation succeeded. These tests guard against that regression. /// public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { @@ -26,45 +26,66 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] public async Task Client_OnInvalidParams_FallsBackTo_Initialize() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.InvalidParams); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] - public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer() + public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializeResponse() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.July2026ProtocolVersion); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToInitializeHandshakeServer() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { // Pinning the version makes it the minimum too, so the client refuses to fall back. - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -73,17 +94,17 @@ public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServe } [Fact] - public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() + public async Task InitializeHandshakeClient_WithExplicitPin_StillRequires_ExactVersionMatch() { var ct = TestContext.Current.CancellationToken; // Server responds with a DIFFERENT version than the one the user pinned. - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.March2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -94,36 +115,50 @@ public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() [Fact] public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() { - // The peer is modern (returns the spec-defined -32020 HeaderMismatch on the probe). - // Falling back to legacy initialize would just produce another malformed envelope. + // The peer uses per-request metadata (returns the spec-defined -32020 HeaderMismatch on the probe). + // Falling back to initialize would just produce another malformed envelope. // Verify the connect-time logic surfaces the error to the caller instead of falling back. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.HeaderMismatch); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.True(transport.ServerDiscoverProbed); - Assert.False(transport.LegacyInitializeReceived); + Assert.False(transport.InitializeReceived); Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); } + [Fact] + public async Task Client_OnUnsupportedProtocolVersion_WithPerRequestMetadataVersion_RetriesDiscover() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new PerRequestMetadataRetryTestTransport(); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal(2, transport.ServerDiscoverRequests); + Assert.False(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Fact] public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() { - // Simulate a legacy server that silently drops the unknown server/discover method (it never - // responds to the probe). The client must fall back to legacy initialize once the configured + // Simulate an initialize-handshake server that silently drops the unknown server/discover method (it never + // responds to the probe). The client must fall back to initialize once the configured // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, silentDiscoverProbe: true); var stopwatch = Stopwatch.StartNew(); @@ -136,8 +171,9 @@ public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredPro stopwatch.Stop(); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. Assert.True( @@ -171,23 +207,25 @@ public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues() } /// - /// Minimal in-memory transport that simulates a legacy server: rejects + /// Minimal in-memory transport that simulates an initialize-handshake server: rejects /// server/discover (with a configurable JSON-RPC error code, or by /// silently dropping the request) and responds to initialize with a /// configurable protocol version. /// - private sealed class LegacyServerTestTransport( + private sealed class InitializeHandshakeServerTestTransport( string serverNegotiatedVersion, int probeErrorCode = (int)McpErrorCode.MethodNotFound, bool silentDiscoverProbe = false) : IClientTransport { private readonly Channel _incomingToClient = Channel.CreateUnbounded(); - public string Name => "legacy-server-test-transport"; + public string Name => "initialize-handshake-server-test-transport"; public bool ServerDiscoverProbed { get; private set; } - public bool LegacyInitializeReceived { get; private set; } + public bool InitializeReceived { get; private set; } + + public string? InitializeProtocolVersion { get; private set; } public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -205,7 +243,7 @@ private void HandleOutgoingMessage(JsonRpcMessage message) ServerDiscoverProbed = true; if (silentDiscoverProbe) { - // Model a legacy server that drops the unknown method without replying. + // Model an initialize-handshake server that drops the unknown method without replying. break; } @@ -223,7 +261,9 @@ private void HandleOutgoingMessage(JsonRpcMessage message) break; case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: - LegacyInitializeReceived = true; + InitializeReceived = true; + var initializeRequest = JsonSerializer.Deserialize(initReq.Params, McpJsonUtilities.DefaultOptions); + InitializeProtocolVersion = initializeRequest?.ProtocolVersion; _ = WriteAsync(new JsonRpcResponse { Id = initReq.Id, @@ -231,7 +271,7 @@ private void HandleOutgoingMessage(JsonRpcMessage message) { ProtocolVersion = serverNegotiatedVersion, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake-test-server", Version = "1.0.0" }, }, McpJsonUtilities.DefaultOptions), }); break; @@ -243,7 +283,105 @@ private Task WriteAsync(JsonRpcMessage message) private sealed class TransportChannel( Channel incoming, - LegacyServerTestTransport parent) : ITransport + InitializeHandshakeServerTestTransport parent) : ITransport + { + public ChannelReader MessageReader => incoming.Reader; + public bool IsConnected { get; private set; } = true; + public string? SessionId => null; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + parent.HandleOutgoingMessage(message); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + incoming.Writer.TryComplete(); + IsConnected = false; + return default; + } + } + } + + private sealed class PerRequestMetadataRetryTestTransport : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "per-request-metadata-retry-test-transport"; + + public int ServerDiscoverRequests { get; private set; } + + public bool InitializeReceived { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + ITransport transport = new TransportChannel(_incomingToClient, this); + return Task.FromResult(transport); + } + + public ValueTask DisposeAsync() => default; + + private void HandleOutgoingMessage(JsonRpcMessage message) + { + switch (message) + { + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq: + ServerDiscoverRequests++; + + if (ServerDiscoverRequests == 1) + { + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = CreateUnsupportedProtocolVersionData(), + }, + }); + } + else + { + _ = WriteAsync(new JsonRpcResponse + { + Id = discoverReq.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + } + + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize }: + InitializeReceived = true; + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private static JsonElement CreateUnsupportedProtocolVersionData() + { + var json = JsonSerializer.Serialize(new UnsupportedProtocolVersionErrorData + { + Requested = McpProtocolVersions.July2026ProtocolVersion, + Supported = [McpProtocolVersions.July2026ProtocolVersion], + }, McpJsonUtilities.DefaultOptions); + + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class TransportChannel( + Channel incoming, + PerRequestMetadataRetryTestTransport parent) : ITransport { public ChannelReader MessageReader => incoming.Reader; public bool IsConnected { get; private set; } = true; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs index 19ff40287..dd7ba7b29 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs @@ -76,7 +76,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -87,7 +87,7 @@ public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -98,7 +98,7 @@ public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -109,7 +109,7 @@ public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() public async Task Client_ListResourceTemplates_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -122,7 +122,7 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, // which still flows through the client's per-request _meta injector. StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); // Hook the server-side handler invocation via a notification handler is awkward here; assert // instead by sending the request and parsing the wire-shape echo from the response context. @@ -136,7 +136,7 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() Assert.NotNull(response.Result); var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discover.SupportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, discover.SupportedVersions); // The server enforces the per-request envelope shape; if the client had omitted _meta, the // request would have failed with -32602 / -32021 rather than returning a DiscoverResult. The @@ -144,11 +144,11 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() } [Fact] - public async Task LegacyClient_ListTools_DoesNotEmitMeta() + public async Task InitializeHandshakeClient_ListTools_DoesNotEmitMeta() { - // Sanity guard: a client on the session-supporting (legacy) protocol must NOT emit the SEP-2575 + // Sanity guard: a client on the session-supporting initialize-handshake protocol must NOT emit the SEP-2575 // envelope. The injector is gated on the negotiated protocol version; if it ever started writing - // those keys on a legacy request, every legacy server would reject it. + // those keys on an initialize-handshake request, every initialize-handshake server would reject it. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); @@ -175,6 +175,6 @@ private void AssertRequiredMetaPresent(string method) $"Missing clientCapabilities key on {method} _meta envelope"); // The protocolVersion value must match the negotiated 2026-07-28 protocol version. - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 42af028b2..48f4e66e1 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -178,7 +178,7 @@ public virtual Task SendMessageAsync(JsonRpcMessage message, CancellationToken c Result = JsonSerializer.SerializeToNode(new DiscoverResult { Capabilities = new ServerCapabilities(), - SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], ServerInfo = new Implementation { Name = "NopTransport", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e1e9a08db..3afbb52eb 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -15,6 +16,9 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); + private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) { @@ -39,6 +43,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // Capture the _meta the server receives on the initialize request so tests can // assert that McpClientOptions.InitializeMeta is threaded through the handshake. mcpServerBuilder.WithMessageFilters(filters => + { filters.AddIncomingFilter(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request) @@ -47,7 +52,23 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } await next(context, cancellationToken); - })); + }); + + // Capture the request-scoped client info/capabilities observed while an outgoing response flows + // through the outgoing filter pipeline. Gated on a unique client name so only the dedicated test + // triggers it. This exercises that DestinationBoundMcpServer resolves per-request _meta for + // responses (whose Context is the originating request's Context), not just requests. + filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse && + context.Server.ClientInfo is { Name: "outgoing-filter-client" } info) + { + _outgoingFilterObserved.TrySetResult((info, context.Server.ClientCapabilities)); + } + + await next(context, cancellationToken); + }); + }); } [Fact] @@ -116,6 +137,177 @@ public async Task ToolCallWithMetaFields() Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() + { + var withSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var withoutSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowSamplingChecks = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + async (string requestId, RequestContext context, CancellationToken cancellationToken) => + { + if (requestId == "with") + { + withSamplingReady.TrySetResult(true); + } + else if (requestId == "without") + { + withoutSamplingReady.TrySetResult(true); + } + else + { + throw new ArgumentException($"Unexpected request id '{requestId}'."); + } + + await allowSamplingChecks.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + + return context.Server.ClientCapabilities?.Sampling is null ? + $"{requestId}:sampling-absent" : + $"{requestId}:sampling-present"; + }, + new() { Name = "meta_sampling_tool" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var withSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"with\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }; + + var withoutSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities(), + McpJsonUtilities.DefaultOptions), + }, + }; + + Task withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + Task withoutSamplingTask = client.CallToolAsync(withoutSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + + await Task.WhenAll(withSamplingReady.Task, withoutSamplingReady.Task).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + allowSamplingChecks.TrySetResult(true); + + CallToolResult withSamplingResult = await withSamplingTask; + CallToolResult withoutSamplingResult = await withoutSamplingTask; + + var withSamplingText = Assert.IsType(Assert.Single(withSamplingResult.Content)).Text; + var withoutSamplingText = Assert.IsType(Assert.Single(withoutSamplingResult.Content)).Text; + + Assert.Equal("with:sampling-present", withSamplingText); + Assert.Equal("without:sampling-absent", withoutSamplingText); + } + + [Fact] + public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + var clientInfo = context.Server.ClientInfo; + return clientInfo is null ? + "client-info-absent" : + $"{clientInfo.Name}:{clientInfo.Version}"; + }, + new() { Name = "client_info_tool" })); + + // The 2026-07-28+ client stamps its ClientInfo onto every request's _meta, so the tool must observe + // the per-request value resolved by DestinationBoundMcpServer rather than server-only session state. + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "request-scoped-client", Version = "9.9.9" }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("client_info_tool", cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("request-scoped-client:9.9.9", text); + } + + [Fact] + public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHandlerObservesThem() + { + ClientCapabilities? handlerObservedCapabilities = null; + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + handlerObservedCapabilities = context.Server.ClientCapabilities; + return "ok"; + }, + new() { Name = "capability_probe_tool" })); + + var clientOptions = new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + // Under the 2026-07-28 revision capabilities are request-scoped, so the root server (outside any + // request) never exposes them, whereas a request handler observes the per-request _meta values. + Assert.Null(Server.ClientCapabilities); + + await client.CallToolAsync("capability_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(handlerObservedCapabilities); + Assert.NotNull(handlerObservedCapabilities!.Elicitation); + Assert.Null(Server.ClientCapabilities); + } + + [Fact] + public async Task OutgoingMessageFilter_UnderJuly2026Protocol_ObservesRequestScopedClientInfoAndCapabilities() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + () => "ok", + new() { Name = "outgoing_probe_tool" })); + + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "outgoing-filter-client", Version = "3.2.1" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("outgoing_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + var (info, capabilities) = await _outgoingFilterObserved.Task + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.NotNull(info); + Assert.Equal("outgoing-filter-client", info!.Name); + Assert.Equal("3.2.1", info.Version); + Assert.NotNull(capabilities); + Assert.NotNull(capabilities!.Elicitation); + } + [Fact] public async Task ResourceReadWithMetaFields() { diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index 879173819..af1334fab 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client; /// /// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync, -/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync. +/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync. /// public class McpClientTaskMethodsTests : ClientServerTestBase { @@ -25,15 +26,12 @@ public McpClientTaskMethodsTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -60,7 +58,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -97,12 +95,12 @@ await Assert.ThrowsAsync(async () => } [Fact] - public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask() + public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask() { await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -122,12 +120,12 @@ public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "hello"), - }, ct); + }, cancellationToken: ct); Assert.NotNull(result); Assert.NotEmpty(result.Content); @@ -141,7 +139,7 @@ public async Task CancelTaskAsync_ForWorkingTask_Succeeds() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -172,7 +170,7 @@ public async Task CancelTaskAsync_NullTaskId_Throws() await using var client = await CreateMcpClientForServer(); await Assert.ThrowsAsync(async () => - await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken)); + await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -194,7 +192,7 @@ public async Task GetTaskAsync_AfterCompletion_ReturnsCompletedResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -235,7 +233,7 @@ public async Task MultipleTasks_CreatedConcurrently_HaveUniqueIds() for (int i = 0; i < 5; i++) { - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 4dda7bc38..aefe6a962 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -483,7 +483,7 @@ private sealed class SynchronousProgress(Action callb [Fact] public async Task AsClientLoggerProvider_MessagesSentToClient() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); ILoggerProvider loggerProvider = Server.AsClientLoggerProvider(); Assert.Throws("categoryName", () => loggerProvider.CreateLogger(null!)); @@ -765,7 +765,7 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task SetLoggingLevelAsync_WithRequestParams_SetsLevel() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); // Should not throw await client.SetLoggingLevelAsync( @@ -795,9 +795,9 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task ServerCanPingClient() { - // ping is a legacy-only RPC (removed in the 2026-07-28 protocol per SEP-2575), so pin the client - // to a legacy protocol version to exercise the server-initiated ping round-trip. - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); + // ping is available only on initialize-handshake revisions (removed in the 2026-07-28 protocol + // per SEP-2575), so pin the client to exercise the server-initiated ping round-trip. + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping }; var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 5868c3c63..be629ecd2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -28,8 +28,9 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected) + public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } + } diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 7690a75f9..9c63f12ce 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,10 +33,10 @@ public async Task ConnectAndPing_Stdio(string clientId) // Arrange // Act - // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest stable - // protocol version to keep exercising the legacy ping RPC. The 2026-07-28 protocol relies on - // the transport/request lifecycle instead of an explicit ping. - await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" }); + // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest + // initialize-handshake revision to keep exercising the ping RPC. The 2026-07-28 protocol + // relies on the transport/request lifecycle instead of an explicit ping. + await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert @@ -558,6 +558,7 @@ public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId) TaskCompletionSource receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 4182957cf..96647fab2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -130,10 +130,10 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); @@ -173,6 +173,50 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt"); } + [Fact] + public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverPrompts = serverOptions.PromptCollection; + Assert.NotNull(serverPrompts); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverPrompts.DeferChangedEvents()) + { + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1")); + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2")); + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(prompts, t => t.Name == "BatchPrompt1"); + Assert.Contains(prompts, t => t.Name == "BatchPrompt2"); + Assert.Contains(prompts, t => t.Name == "BatchPrompt3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task AttributeProperties_Propagated() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index 0c4783b28..d3de3a23e 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -280,7 +280,7 @@ public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromRes [Fact] public async Task AddSetLoggingLevelFilter_Logs_When_SetLoggingLevel_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d8fd0a231..ff3b9114a 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -164,10 +164,10 @@ public async Task Can_Be_Notified_Of_Resource_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); @@ -207,6 +207,50 @@ public async Task Can_Be_Notified_Of_Resource_Changes() Assert.DoesNotContain(resources, t => t.Name == "NewResource"); } + [Fact] + public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverResources = serverOptions.ResourceCollection; + Assert.NotNull(serverResources); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverResources.DeferChangedEvents()) + { + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1")); + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2")); + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(resources, t => t.Name == "BatchResource1"); + Assert.Contains(resources, t => t.Name == "BatchResource2"); + Assert.Contains(resources, t => t.Name == "BatchResource3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task AttributeProperties_Propagated() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 5359ec73c..a05084a86 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -190,10 +190,10 @@ public async Task Can_Be_Notified_Of_Tool_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -232,6 +232,50 @@ public async Task Can_Be_Notified_Of_Tool_Changes() Assert.DoesNotContain(tools, t => t.Name == "NewTool"); } + [Fact] + public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverTools = serverOptions.ToolCollection; + Assert.NotNull(serverTools); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverTools.DeferChangedEvents()) + { + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1")); + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2")); + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == "BatchTool1"); + Assert.Contains(tools, t => t.Name == "BatchTool2"); + Assert.Contains(tools, t => t.Name == "BatchTool3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task Can_Call_Registered_Tool() { diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 4b782cd64..677d77357 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -36,6 +36,7 @@ + @@ -83,6 +84,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs index 14f0d497e..4a5873df6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -43,7 +43,7 @@ public async Task DraftServerOmittingBothHints_LogsWarning(string method) { var (call, result) = GetScenario(method, ttl: null, scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, method, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, method, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains(method) && m.Message.Contains("SEP-2549")); @@ -56,7 +56,7 @@ public async Task DraftServerOmittingOnlyCacheScope_WarnsAboutCacheScope() { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -69,7 +69,7 @@ public async Task DraftServerProvidingBothHints_DoesNotWarn() { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: CacheScope.Public); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -81,7 +81,7 @@ public async Task OlderServerOmittingHints_DoesNotWarn() // A server on an older protocol version may legitimately omit the fields; no warning should fire. var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: null, scope: null); - await RunScenarioAsync(OlderProtocolVersion, useModernLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(OlderProtocolVersion, usePerRequestMetadataLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -98,7 +98,7 @@ public async Task AutoPaginatingOverload_DraftServerOmittingHints_LogsWarning() await RunScenarioAsync( July2026ProtocolVersion, - useModernLifecycle: true, + usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, (c, ct) => c.ListToolsAsync(cancellationToken: ct).AsTask(), @@ -130,7 +130,7 @@ public async Task AutoPaginatingOverload_MultiplePages_WarnsOnlyOncePerMethod() var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, useModernLifecycle: true, TestContext.Current.CancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, TestContext.Current.CancellationToken); await using var client = await clientTask; @@ -208,7 +208,7 @@ private static (Func Call, JsonNode Result) private async Task RunScenarioAsync( string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, string method, JsonNode resultNode, Func clientCall, @@ -217,8 +217,8 @@ private async Task RunScenarioAsync( var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Pin the protocol version so the client deterministically takes the modern (server/discover) - // lifecycle for 2026-07-28 and the legacy (initialize) lifecycle for older versions. + // Pin the protocol version so the client deterministically takes the per-request metadata + // (server/discover) lifecycle for 2026-07-28 and the initialize lifecycle for older versions. var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -231,7 +231,7 @@ private async Task RunScenarioAsync( var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, useModernLifecycle, cancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, usePerRequestMetadataLifecycle, cancellationToken); await using var client = await clientTask; Assert.Equal(serverProtocolVersion, client.NegotiatedProtocolVersion); @@ -271,7 +271,7 @@ private static async Task PerformHandshakeAsync( StreamReader serverReader, Stream serverWriter, string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, CancellationToken cancellationToken) { var requestLine = await serverReader.ReadLineAsync(cancellationToken); @@ -279,9 +279,9 @@ private static async Task PerformHandshakeAsync( var request = JsonSerializer.Deserialize(requestLine, McpJsonUtilities.DefaultOptions); Assert.NotNull(request); - if (useModernLifecycle) + if (usePerRequestMetadataLifecycle) { - // Modern 2026-07-28 lifecycle (SEP-2575): no initialize handshake. The client probes + // Per-request metadata lifecycle (SEP-2575): no initialize handshake. The client probes // server/discover to learn capabilities, then sends normal RPCs carrying per-request _meta. Assert.Equal(RequestMethods.ServerDiscover, request.Method); @@ -298,7 +298,7 @@ private static async Task PerformHandshakeAsync( } else { - // Legacy initialize handshake for older protocol versions. + // Initialize handshake for older protocol versions. Assert.Equal(RequestMethods.Initialize, request.Method); await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs index 248bf7768..4a2d7e6df 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -17,7 +17,7 @@ public static class DiscoverResultCacheableTests { private static DiscoverResult NewDiscoverResult() => new() { - SupportedVersions = [McpHttpHeaders.November2025ProtocolVersion, McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 86acc57f6..da4afb62f 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; @@ -27,8 +28,8 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie Meta = new JsonObject { ["key"] = "value" } }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(deserialized); Assert.Equal("task-123", deserialized.TaskId); @@ -57,7 +58,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames() ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); // Must use camelCase wire names Assert.Contains("\"ttlMs\":", json); @@ -82,7 +83,7 @@ public static void CreateTaskResult_ResultType_SerializesAsTask() ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("task", (string)node["resultType"]!); @@ -104,8 +105,8 @@ public static void GetTaskResult_Working_RoundTrip() PollIntervalMs = 2000, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("w1", working.TaskId); @@ -126,8 +127,8 @@ public static void GetTaskResult_Completed_RoundTrip_IncludesResult() Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("c1", completed.TaskId); @@ -147,8 +148,8 @@ public static void GetTaskResult_Failed_RoundTrip_IncludesError() Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("f1", failed.TaskId); @@ -167,8 +168,8 @@ public static void GetTaskResult_Cancelled_RoundTrip() StatusMessage = "User cancelled", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var cancelled = Assert.IsType(deserialized); Assert.Equal("x1", cancelled.TaskId); @@ -195,8 +196,8 @@ public static void GetTaskResult_InputRequired_RoundTrip_IncludesInputRequests() InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.Equal("i1", inputRequired.TaskId); @@ -228,7 +229,7 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() _ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""", }; - var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var result = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(result); Assert.IsType(expectedType, result); } @@ -238,42 +239,42 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() public static void GetTaskResult_MissingTaskId_ThrowsJsonException() { var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_MissingStatus_ThrowsJsonException() { var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_UnknownStatus_ThrowsJsonException() { var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException() { var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_FailedMissingError_ThrowsJsonException() { var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException() { var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Theory] @@ -303,14 +304,14 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) ["k"] = new InputRequest { Method = "test/method", - Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions), + Params = JsonSerializer.SerializeToElement("ask", McpTasksJsonContext.Default.Options), }, }, }, _ => throw new InvalidOperationException() }; - string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(value, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -328,10 +329,10 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) [InlineData(McpTaskStatus.Failed, "failed")] public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue) { - string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(status, McpTasksJsonContext.Default.Options); Assert.Equal($"\"{expectedWireValue}\"", json); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.Equal(status, deserialized); } @@ -350,8 +351,8 @@ public static void TaskStatusNotificationParams_Working_RoundTrip() StatusMessage = "Working on it", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("n1", working.TaskId); @@ -370,8 +371,8 @@ public static void TaskStatusNotificationParams_Completed_RoundTrip() Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("n2", completed.TaskId); @@ -390,8 +391,8 @@ public static void TaskStatusNotificationParams_Failed_RoundTrip() Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("n3", failed.TaskId); @@ -408,8 +409,8 @@ public static void TaskStatusNotificationParams_Cancelled_RoundTrip() LastUpdatedAt = DateTimeOffset.UtcNow, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.IsType(deserialized); } @@ -429,8 +430,8 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip() InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.NotNull(inputRequired.InputRequests); @@ -439,10 +440,10 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip() #endregion - #region ResultOrCreatedTask + #region ResultOrAlternate [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromResult() + public static void ResultOrAlternate_ImplicitConversion_FromResult() { CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] }; @@ -454,7 +455,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromResult() } [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() + public static void ResultOrAlternate_ImplicitConversion_FromCreateTaskResult() { CreateTaskResult taskCreated = new() { @@ -472,7 +473,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() } [Fact] - public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask() + public static void ResultOrAlternate_IsTask_FalseForResult_TrueForTask() { var result = new ResultOrCreatedTask(new CallToolResult()); var task = new ResultOrCreatedTask(new CreateTaskResult @@ -496,7 +497,7 @@ public static void UpdateTaskResult_WireResultType_IsComplete_WhenSet() { // SEP-2663: tasks/update responses use resultType="complete". var result = new UpdateTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -507,7 +508,7 @@ public static void CancelTaskResult_WireResultType_IsComplete_WhenSet() { // SEP-2663: tasks/cancel responses use resultType="complete". var result = new CancelTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs index bf4c67d21..8d97eb52d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -190,6 +190,16 @@ await request.Server.ElicitAsync(new() }); } + // These tests assert on the root server's ClientCapabilities (see AssertServerElicitationCapability), + // which is only session-scoped under the initialize-handshake revisions. Pin to the latest such revision + // so the capabilities negotiated during initialize are observable on the root McpServer. Request-scoped + // capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests. + private Task CreateLegacyClientForServer(McpClientOptions clientOptions) + { + clientOptions.ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; + return CreateMcpClientForServer(clientOptions); + } + [Fact] public async Task Can_Elicit_OutOfBand_With_Url() { @@ -198,7 +208,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() string? capturedMessage = null; var completionNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -283,7 +293,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() [Fact] public async Task UrlElicitation_User_Can_Decline() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -322,7 +332,7 @@ public async Task UrlElicitation_User_Can_Decline() [Fact] public async Task UrlElicitation_User_Can_Cancel() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -360,7 +370,7 @@ public async Task UrlElicitation_User_Can_Cancel() [Fact] public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -385,7 +395,7 @@ public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() [Fact] public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -406,7 +416,7 @@ public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() [Fact] public async Task UrlElicitation_BlankCapability_Allows_Only_Form() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -435,7 +445,7 @@ public async Task UrlElicitation_BlankCapability_Allows_Only_Form() [Fact] public async Task FormElicitation_UrlOnlyCapability_NotSupported() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -474,7 +484,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -504,7 +514,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() [Fact] public async Task UrlElicitationRequired_Exception_Propagates_To_Client() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -532,7 +542,7 @@ public async Task FormElicitation_Requires_RequestedSchema() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs new file mode 100644 index 000000000..3c9b8bb9d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs @@ -0,0 +1,60 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that combining the non-alternate with the +/// alternate fails at configuration time +/// with an actionable message. +/// +public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateFilters seam + private static McpRequestFilter PassThroughCallToolFilter => + next => next; + + private static McpRequestFilter> PassThroughAlternateFilter => + next => next; + + [Fact] + 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); + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message); + Assert.Contains(nameof(McpRequestFilters.CallToolWithAlternateFilters), ex.Message); + Assert.Contains("AddAuthorizationFilters", ex.Message); + Assert.Contains("WithTasks", ex.Message); + } + + [Fact] + public async Task CallToolFiltersAlone_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } + + [Fact] + public async Task AlternateFiltersAlone_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } +#pragma warning restore MCPEXP002 +} diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs new file mode 100644 index 000000000..424537461 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs @@ -0,0 +1,79 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that custom request handlers registered through +/// cannot silently replace a built-in method +/// or another custom handler. +/// +public class CustomRequestHandlerCollisionTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ +#pragma warning disable MCPEXP002 + private static McpServerRequestHandler CreateHandler(string method) => new() + { + Method = method, + Handler = (request, cancellationToken) => new ValueTask((JsonNode?)null), + }; + + [Fact] + public async Task CustomHandler_CollidingWithBuiltInMethod_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + Capabilities = new() { Tools = new() }, + RequestHandlers = [CreateHandler("tools/call")], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("tools/call", ex.Message); + } + + [Fact] + public async Task CustomHandler_CollidingWithInitialize_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("initialize")], + }; + + Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + } + + [Fact] + public async Task CustomHandler_DuplicateCustomMethod_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("custom/method"), CreateHandler("custom/method")], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("custom/method", ex.Message); + } + + [Fact] + public async Task CustomHandler_UniqueMethod_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("custom/method")], + }; + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } +#pragma warning restore MCPEXP002 +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 83afbfe23..236740f61 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; @@ -87,6 +88,59 @@ public async Task GetTaskAsync_ReturnsNullForUnknownId() Assert.Null(result); } + [Fact] + public async Task GetTaskAsync_WithinTimeToLive_ReturnsTask() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMinutes(10) }; + var created = await store.CreateTaskAsync(CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + + [Fact] + public async Task GetTaskAsync_AfterTimeToLiveElapsed_ReturnsNull() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMilliseconds(100) }; + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(500), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.Null(result); + } + + [Fact] + public async Task GetTaskAsync_WithoutTimeToLive_DoesNotExpire() + { + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(200), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + + [Fact] + public async Task GetTaskAsync_WithZeroTimeToLive_DoesNotExpire() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.Zero }; + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(200), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + [Fact] public async Task SetCompletedAsync_TransitionsToCompleted() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs new file mode 100644 index 000000000..7acac660e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -0,0 +1,595 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerPrimitiveCollectionTests +{ + private static McpServerTool CreateTool(string name) => + McpServerTool.Create(() => name, new() { Name = name }); + + private static McpServerPrompt CreatePrompt(string name) => + McpServerPrompt.Create(() => new ChatMessage(ChatRole.User, name), new() { Name = name }); + + // ------------------------------------------------------------------------- + // Changed event without DeferChangedEvents + // ------------------------------------------------------------------------- + + [Fact] + public void TryAdd_NewTool_ReturnsTrue_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool added = collection.TryAdd(CreateTool("tool1")); + + Assert.True(added); + Assert.Equal(1, changeCount); + } + + [Fact] + public void TryAdd_DuplicateName_ReturnsFalse_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool added = collection.TryAdd(CreateTool("tool1")); + + Assert.False(added); + Assert.Equal(0, changeCount); + } + + [Fact] + public void TryAdd_SameTool_TwiceInSequence_FiresOnlyOnFirst() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool first = collection.TryAdd(CreateTool("tool1")); + bool second = collection.TryAdd(CreateTool("tool1")); + + Assert.True(first); + Assert.False(second); + Assert.Equal(1, changeCount); + } + + [Fact] + public void Remove_ExistingTool_ReturnsTrue_FiresChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool removed = collection.Remove(tool); + + Assert.True(removed); + Assert.Equal(1, changeCount); + } + + [Fact] + public void Remove_NonExistentTool_ReturnsFalse_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool removed = collection.Remove(CreateTool("tool1")); + + Assert.False(removed); + Assert.Equal(0, changeCount); + } + + [Fact] + public void Clear_NonEmptyCollection_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void Clear_EmptyCollection_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- basic deferral behavior + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_NoMutation_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + // no mutations + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_SingleMutation_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(0, changeCount); // not fired yet + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_MixedAddAndRemove_FiresOneChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool2")); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_AddThenRemoveSameTool_FiresOneChanged() + { + // Net effect is no change in contents, but a Changed notification still fires + // because mutations occurred during the scope. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + var tool = CreateTool("tool1"); + collection.TryAdd(tool); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void DeferChangedEvents_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() + { + // The first TryAdd succeeds (mutation), the second fails (no mutation). + // Exactly one Changed fires on dispose. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); // succeeds + collection.TryAdd(CreateTool("tool1")); // fails -- duplicate name + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OnlyFailedTryAdds_DoesNotFireChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); // fails -- already present + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_WithClear_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.Clear(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_Nested_FiresOnceWhenAllScopesDisposed() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); // inner scope disposed, but outer still active + collection.TryAdd(CreateTool("tool3")); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OutOfOrderDisposal_FiresOnceWhenAllScopesDisposed() + { + // Scopes created in order 1, 2 but disposed in reverse order 2, 1. + // Changed should NOT fire when scope 2 is disposed (scope 1 still active). + // Changed SHOULD fire when scope 1 is disposed (last active scope gone). + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + Assert.Equal(0, changeCount); + scope2.Dispose(); // out-of-order dispose; scope1 still active + Assert.Equal(0, changeCount); + + scope1.Dispose(); // last scope disposed; Changed fires now + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_DoubleDisposeSingleScope_DoesNotDecrementCountTwice() + { + // Double-disposing scope1 must not decrement _activeDeferralScopes more than once, + // which would cause Changed to fire while scope2 is still active. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + scope1.Dispose(); + Assert.Equal(0, changeCount); // scope2 still active + + scope1.Dispose(); // second dispose of scope1 -- must be a no-op + Assert.Equal(0, changeCount); // scope2 is still active; Changed must NOT fire yet + + scope2.Dispose(); // now all scopes are disposed; Changed fires + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OutOfOrderDisposalNoMutation_DoesNotFireChanged() + { + // Same out-of-order pattern but with no mutations -- verifies no spurious Changed. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + + scope2.Dispose(); + scope1.Dispose(); + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_AfterScope_ResumesImmediateNotifications() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Equal(1, changeCount); + + // After the scope, each mutation fires immediately + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + [Fact] + public void DeferChangedEvents_DisposeIdempotent_DoesNotFireTwice() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + scope.Dispose(); + Assert.Equal(1, changeCount); + + scope.Dispose(); // second dispose should be a no-op + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_ScopeWithNoHandlers_DoesNotThrow() + { + var collection = new McpServerPrimitiveCollection(); + // no Changed handler registered + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Single(collection); + } + + [Fact] + public void WithoutDeferChangedEvents_EachMutationFiresImmediately() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(1, changeCount); + + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- concurrency + // ------------------------------------------------------------------------- + + [Fact] + public async Task DeferChangedEvents_ConcurrentMutations_FiresExactlyOneChanged() + { + const int threadCount = 10; + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + using (collection.DeferChangedEvents()) + { + await Task.WhenAll(Enumerable.Range(0, threadCount).Select(i => + Task.Run(() => collection.TryAdd(CreateTool($"tool{i}")), TestContext.Current.CancellationToken))); + } + + Assert.Equal(1, changeCount); + Assert.Equal(threadCount, collection.Count); + } + + [Fact] + public async Task DeferChangedEvents_MutationRacingWithDispose_NotificationNotLost() + { + // Run many iterations to reliably exercise the race between a mutation + // and disposal of the outermost scope. With the lock-free implementation + // the race could cause the notification to be lost; the lock-based + // implementation must always fire exactly one notification. + for (int iteration = 0; iteration < 200; iteration++) + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + var scope = collection.DeferChangedEvents(); + + // Run the mutation and the dispose concurrently. + var addTask = Task.Run(() => collection.TryAdd(CreateTool("tool1")), TestContext.Current.CancellationToken); + var disposeTask = Task.Run(() => scope.Dispose(), TestContext.Current.CancellationToken); + + await Task.WhenAll(addTask, disposeTask); + + // Regardless of ordering: exactly one notification must have fired. + // - If TryAdd runs before Dispose: the mutation marks _pendingChange; + // Dispose sees depth -> 0 with a pending change and fires. + // - If Dispose runs before TryAdd: depth is already 0 when TryAdd + // calls RaiseChanged, so it fires immediately. + // The lock prevents the third (buggy) interleaving where Dispose + // sees no pending change and TryAdd sees depth > 0, dropping the event. + Assert.Equal(1, changeCount); + } + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- derived-type coalescing + // ------------------------------------------------------------------------- + + private sealed class TrackingCollection : McpServerPrimitiveCollection + { + public void RaiseChangedDirectly() => RaiseChanged(); + } + + [Fact] + public void DeferChangedEvents_DerivedTypeCallsRaiseChanged_Coalesces() + { + // Verify that derived types calling RaiseChanged() directly (the path + // McpServerResourceCollection and other subclasses rely on) are gated + // by the same deferral check. + var collection = new TrackingCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.RaiseChangedDirectly(); + collection.RaiseChangedDirectly(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately() + { + var collection = new TrackingCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.RaiseChangedDirectly(); + Assert.Equal(1, changeCount); + + collection.RaiseChangedDirectly(); + Assert.Equal(2, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- exception safety + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_ExceptionDuringScope_StillFiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_ExceptionDuringScope_ResumesImmediateNotificationsAfterward() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + + // Deferral must be fully reset: mutations outside the scope fire immediately. + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- prompt collection coverage + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_PromptCollection_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreatePrompt("prompt1")); + collection.TryAdd(CreatePrompt("prompt2")); + collection.TryAdd(CreatePrompt("prompt3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Equal(3, collection.Count); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9708722f3..669499c12 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Microsoft.Extensions.DependencyInjection; @@ -5,6 +6,8 @@ using System.Text.Json; using System.Text.Json.Nodes; +#pragma warning disable MCPEXP001, MCPEXP002 + namespace ModelContextProtocol.Tests.Server; /// @@ -29,74 +32,97 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); - options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { _capturedMeta = context.Params?.Meta; var store = context.Server.Services!.GetRequiredService(); var toolName = context.Params!.Name; - if (toolName == "immediate-tool") + ResultOrAlternate result = toolName switch { - return new CallToolResult() + "immediate-tool" => new(new CallToolResult { Content = [new TextContentBlock { Text = "immediate result" }], - }; - } + }), + "async-tool" => ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = store.CreateTask(), + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + "input-required-tool" => ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = store.CreateTask(McpTaskStatus.InputRequired), + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + _ => throw new McpException($"Unknown tool: {toolName}"), + }; - if (toolName == "async-tool") - { - var taskId = store.CreateTask(); - return new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + return new ValueTask>(result); + }; - if (toolName == "input-required-tool") + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - var taskId = store.CreateTask(McpTaskStatus.InputRequired); - return new CreateTaskResult + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + GetTaskResult result; + try { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + result = _taskStore.GetTask(requestParams.TaskId); + } + catch (McpException ex) + { + throw new McpProtocolException(ex.Message, McpErrorCode.InvalidParams); + } - throw new McpException($"Unknown tool: {toolName}"); - }; + return new ValueTask(JsonSerializer.SerializeToNode(result, McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.GetTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - return store.GetTask(taskId); - }; + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/update", McpErrorCode.InvalidParams); - options.Handlers.UpdateTaskHandler = async (context, cancellationToken) => - { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary()); - return new UpdateTaskResult(); - }; + _taskStore.ProvideInput(requestParams.TaskId, requestParams.InputResponses ?? new Dictionary()); + return new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.CancelTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.CancelTask(taskId); - return new CancelTaskResult(); - }; + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + _taskStore.CancelTask(requestParams.TaskId); + return new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); }); } @@ -105,9 +131,9 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() { await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -115,11 +141,11 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() } [Fact] - public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() + public async Task CallToolAsTaskAsync_ImmediateResult_ReturnsResultNotTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -130,11 +156,11 @@ public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() } [Fact] - public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated() + public async Task CallToolAsTaskAsync_AsyncTool_ReturnsTaskCreated() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -154,7 +180,7 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() // Complete the task after a brief delay so polling finds it. _ = Task.Run(async () => { - await Task.Delay(100, ct); + await Task.Delay(100, cancellationToken: ct); // The store should have exactly one task by now var taskId = _taskStore.GetAllTaskIds().Single(); _taskStore.CompleteTask(taskId, new CallToolResult @@ -163,9 +189,8 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Single(result.Content); @@ -192,9 +217,8 @@ public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException() }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await failedTask.Task); } @@ -219,9 +243,8 @@ public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await cancelledTask.Task); } @@ -231,10 +254,10 @@ public async Task GetTaskAsync_ReturnsCurrentState() { await using var client = await CreateMcpClientForServer(); - // Create a task via CallToolRawAsync - var augmented = await client.CallToolRawAsync( + // Create a task via CallToolAsTaskAsync + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); var taskId = augmented.TaskCreated!.TaskId; @@ -250,7 +273,7 @@ public async Task CancelTaskAsync_CancelsTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -276,7 +299,7 @@ public async Task ConfigureTasks_AdvertisesExtensionInCapabilities() var extensions = client.ServerCapabilities.Extensions; #pragma warning restore MCP_EXTENSIONS Assert.NotNull(extensions); - Assert.True(extensions.ContainsKey(McpExtensions.Tasks)); + Assert.True(extensions.ContainsKey(TasksProtocol.ExtensionId)); } [Fact] @@ -284,7 +307,7 @@ public async Task CreateTaskResult_HasResultTypeTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -298,7 +321,7 @@ public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves() // Strong consistency: tasks/get immediately after CreateTaskResult must resolve. await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -328,7 +351,7 @@ public async Task CancelTask_AlreadyTerminal_AcknowledgesIdempotently() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -347,7 +370,7 @@ public async Task UpdateTaskAsync_TransitionsFromInputRequired() var ct = TestContext.Current.CancellationToken; // Create an input-required task - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "input-required-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -373,15 +396,15 @@ await client.UpdateTaskAsync(new UpdateTaskRequestParams } [Fact] - public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() + public async Task CallToolAsTaskAsync_InjectsTaskCapabilityInMeta() { // Verify the server receives the task extension in _meta by intercepting - // the handler. The CallToolWithTaskHandler already receives the request, + // the handler. The CallToolWithAlternateHandler already receives the request, // so we can observe the meta there. We test the client-side injection indirectly // by confirming the server returns a task result (which requires the capability signal). await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -390,14 +413,14 @@ public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() } [Fact] - public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope() + public async Task CallToolAsTaskAsync_OptIn_UsesSep2575CapabilitiesEnvelope() { // SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope: // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} // This test pins the literal wire path so future refactors can't regress. await using var client = await CreateMcpClientForServer(); - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -413,7 +436,7 @@ await client.CallToolRawAsync( } [Fact] - public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() + public async Task CallToolAsTaskAsync_OptIn_PreservesExistingMetaSiblings() { // User-supplied _meta entries at the root must not be clobbered, and the SEP-2575 // envelope must be added alongside them, not in place of them. @@ -431,7 +454,7 @@ public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() }, }; - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -452,13 +475,13 @@ await client.CallToolRawAsync( } [Fact] - public async Task CallToolRawAsync_PreservesExistingUserMeta() + public async Task CallToolAsTaskAsync_PreservesExistingUserMeta() { // Verify that user-supplied meta fields are not clobbered await using var client = await CreateMcpClientForServer(); var userMeta = new JsonObject { ["customKey"] = "customValue" }; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -494,8 +517,8 @@ public async Task CallToolAsync_RespectsServerPollInterval() }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); var elapsed = DateTime.UtcNow - startTime; @@ -506,25 +529,25 @@ public async Task CallToolAsync_RespectsServerPollInterval() } [Fact] - public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult() + public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallToolResult() { - // Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works + // Verify that the implicit conversion from CallToolResult to ResultOrAlternate works // in the handler context — this is already tested by "immediate-tool" working correctly. await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text); } [Fact] - public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive() + public async Task CallToolHandler_And_CallToolWithAlternateHandler_AreMutuallyExclusive() { var handlers = new McpServerHandlers(); - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => handlers.CallToolHandler = async (ctx, ct) => new CallToolResult()); @@ -532,7 +555,7 @@ public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusi handlers.CallToolHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult()); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult()); } [Fact] @@ -544,8 +567,8 @@ public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet() handlers.CallToolHandler = null; // Now setting the other should work - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); - Assert.NotNull(handlers.CallToolWithTaskHandler); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); + Assert.NotNull(handlers.CallToolWithAlternateHandler); } /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs index 9e264af78..db051d19f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -10,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server -/// has neither an nor a +/// has neither nor a /// configured. The expected behavior is a silent synchronous fallback: the server returns the normal /// with no Task envelope and no exception. /// @@ -25,7 +26,7 @@ public McpServerTasksNoStoreTests(ITestOutputHelper testOutputHelper) : base(tes protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Intentionally do NOT configure TaskStore or CallToolWithTaskHandler. + // Intentionally do NOT configure TaskStore or CallToolWithAlternateHandler. mcpServerBuilder.WithTools(); } @@ -35,8 +36,8 @@ public async Task ClientOptIn_NoTaskStore_FallsBackToSyncResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - // CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta. - var augmented = await client.CallToolRawAsync( + // CallToolAsTaskAsync always writes the SEP-2575 tasks opt-in into _meta. + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "sync-tool" }, ct); // With no task store configured, the server must complete synchronously and return @@ -54,8 +55,8 @@ public async Task ClientOptIn_NoTaskStore_CallToolAsync_StillReturnsResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sync-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sync-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sync result", Assert.IsType(result.Content[0]).Text); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 87f363f03..c0f85a94a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -26,7 +26,7 @@ private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = { return new McpServerOptions { - ProtocolVersion = "2024", + ProtocolVersion = "2024-11-05", InitializationTimeout = TimeSpan.FromSeconds(30), Capabilities = capabilities, }; @@ -283,13 +283,95 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + 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", result.ProtocolVersion); - Assert.Equal("2024", server.NegotiatedProtocolVersion); + Assert.Equal("2024-11-05", result.ProtocolVersion); + Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); }); } + [Fact] + public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new TestServerTransport(); + var options = CreateOptions(); + options.ProtocolVersion = null; + + await using var server = McpServer.Create(transport, options, LoggerFactory); + var runTask = server.RunAsync(ct); + + var rejectedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + transport.OnMessageSent = message => + { + if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1") + { + 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.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + }, + }, + Context = new JsonRpcMessageContext + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, + }, ct); + + 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 + { + 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; + } + [Fact] public async Task Initialize_IncludesExtensionsInResponse() { @@ -304,6 +386,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -323,6 +406,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -354,6 +438,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + 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 @@ -400,6 +485,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -443,6 +529,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -486,6 +573,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -535,6 +623,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -590,6 +679,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + 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); @@ -637,6 +727,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -675,6 +766,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -704,6 +796,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -739,6 +832,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -776,6 +870,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -805,6 +900,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.Equal("test", result.Description); }); } @@ -839,6 +935,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -874,6 +971,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Content); Assert.Equal("test", Assert.IsType(result.Content[0]).Text); }); @@ -885,6 +983,34 @@ public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_A await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); } + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests() + { + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: options => + { + // logging/setLevel is a legacy (2025-06-18) method whose result must serialize as an + // empty object {}. The custom handler returns a bare result and the server must not + // add a resultType, otherwise the MCP conformance suite rejects the response. + options.Handlers.SetLoggingLevelHandler = async (request, ct) => new EmptyResult(); + }, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); + }); + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_With_McpException() { @@ -907,6 +1033,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -935,6 +1062,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -970,6 +1098,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + 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]); @@ -1230,6 +1359,7 @@ await transport.SendClientMessageAsync(new JsonRpcNotification Assert.NotNull(response.Result); var initResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(initResult); + 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 09a501ab8..a904c87ed 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -13,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the -based auto-wiring of tools/call into tasks. -/// Verifies that setting enables task support +/// Verifies that enables task support /// for -based tools. /// public class McpTaskStoreTests : ClientServerTestBase @@ -27,23 +28,20 @@ public McpTaskStoreTests(ITestOutputHelper testOutputHelper) : base(testOutputHe protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.WithTools(); - - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTools() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); + }); } [Fact] - public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult() + public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, TestContext.Current.CancellationToken); @@ -60,9 +58,9 @@ public async Task CallToolAsync_WithTaskStore_PollsToCompletion() await using var client = await CreateMcpClientForServer(); // CallToolAsync should poll until the background execution completes. - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "slow-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -75,7 +73,7 @@ public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask() await using var client = await CreateMcpClientForServer(); // Even a fast tool should go through the task store when the client signals capability. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, TestContext.Current.CancellationToken); @@ -88,8 +86,8 @@ public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( - new CallToolRequestParams { Name = "fast-tool" }, ct); + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "fast-tool" }, cancellationToken: ct); var taskId = augmented.TaskCreated!.TaskId; @@ -115,7 +113,7 @@ public async Task CancelTaskAsync_ViaStore_TransitionsToCancelled() var ct = TestContext.Current.CancellationToken; // Create a slow task that won't complete on its own - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -145,7 +143,7 @@ public async Task ToolExecution_Failure_StoresAsCompletedWithError() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -176,7 +174,7 @@ public async Task McpProtocolException_FromTool_StoresAsFailedWithJsonRpcErrorSh await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -210,7 +208,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "mrtr-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -233,7 +231,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains("MRTR", message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains("tasks", message); } [Fact] @@ -253,8 +251,8 @@ public async Task ElicitTool_ViaTask_RedirectsThroughStore() var ct = TestContext.Current.CancellationToken; // CallToolAsync will poll and resolve input requests automatically. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text); @@ -279,8 +277,8 @@ public async Task SampleTool_ViaTask_RedirectsThroughStore() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sample-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sample-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sampled response", Assert.IsType(result.Content[0]).Text); @@ -309,8 +307,8 @@ public async Task RootsTool_ViaTask_RedirectsThroughStore() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "roots-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "roots-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("file:///workspace,file:///other", Assert.IsType(result.Content[0]).Text); @@ -328,12 +326,10 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is not null) { notifications.Writer.TryWrite(typed); @@ -342,8 +338,8 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica return default; }); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "notifying-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "notifying-tool" }, cancellationToken: ct); Assert.Equal("notified", Assert.IsType(result.Content[0]).Text); @@ -377,12 +373,10 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is FailedTaskNotificationParams) { notifications.Writer.TryWrite(typed); @@ -393,8 +387,8 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati // The tool emits a Failed notification then returns a normal result, so we isolate the // notification round-trip from the task-store's own failure handling. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "failing-notify-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "failing-notify-tool" }, cancellationToken: ct); Assert.Equal("emitted-failed", Assert.IsType(result.Content[0]).Text); @@ -426,8 +420,8 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); // The handler should be called exactly once despite potential multiple polls Assert.Equal(1, elicitCallCount); @@ -435,7 +429,7 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() } [Fact] - public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() + public async Task CallToolAsTaskAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() { await using var client = await CreateMcpClientForServer(new McpClientOptions { @@ -447,7 +441,7 @@ public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequ }); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "elicit-tool" }, ct); Assert.True(augmented.IsTask); @@ -476,7 +470,7 @@ public async Task CancelTaskAsync_AlreadyCompleted_AcknowledgesIdempotently_AndD var ct = TestContext.Current.CancellationToken; // Run a fast tool to completion via the task store. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -516,7 +510,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli var sw = System.Diagnostics.Stopwatch.StartNew(); var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct)); sw.Stop(); Assert.Equal("handler-failed", ex.Message); @@ -531,7 +525,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately() { // SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the - // extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw + // extension capability." We bypass CallToolAsTaskAsync (which injects the marker) and send a raw // tools/call request without the io.modelcontextprotocol/tasks key in _meta. await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; @@ -561,7 +555,7 @@ public async Task ToolReturnsCallToolResultWithIsError_AsTask_StoresAsCompleted_ await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "iserror-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -602,8 +596,8 @@ public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_Acros }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "multi-elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "multi-elicit-tool" }, cancellationToken: ct); // Exactly two handler invocations — one per unique input request key. Assert.Equal(2, elicitCount); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index a263e289b..3d927b163 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; @@ -59,3 +61,88 @@ public async Task InputRequiredException_WithoutInputRequests_ExhaustsRetries() Assert.Contains("more than", exception.Message); } } + +/// +/// Companion to covering a native (MRTR-capable) round-trip where the +/// server RETURNS an through the alternate result path +/// () rather than throwing . The MRTR +/// client drives the round-trip and receives the final result. +/// +public class MrtrReturnedInputRequiredResultNativeTests : ClientServerTestBase +{ + private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = + (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); + + private int _attempt; + + public MrtrReturnedInputRequiredResultNativeTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "2026-07-28"; + + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + { + Interlocked.Increment(ref _attempt); + + // Retry round: the MRTR client re-sent the request with its responses and our requestState. + if (context.Params?.RequestState is not null) + { + return new ValueTask>(new CallToolResult + { + Content = [new TextContentBlock { Text = "resolved" }], + }); + } + + // First round: RETURN an InputRequiredResult through the alternate path. An MRTR client + // understands it natively and drives the round-trip. + var inputRequired = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "need-input", + RequestedSchema = new(), + }), + }, + RequestState = "round1", + }; + + return new ValueTask>( + ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); + }; + }); + } +#pragma warning restore MCPEXP002 + + [Fact] + public async Task ReturnedInputRequiredResult_MrtrClient_RoundTripsToFinalResult() + { + StartServer(); + + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "return-form", + cancellationToken: TestContext.Current.CancellationToken); + + // Two handler invocations: initial (returned InputRequiredResult) + client-driven retry (final result). + Assert.Equal(2, _attempt); + var content = Assert.Single(result.Content); + Assert.Equal("resolved", Assert.IsType(content).Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index 221a2ffb4..3c5a52d92 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -111,3 +113,89 @@ public async Task InputRequiredException_TransitioningRequestStateToNull_DoesNot Assert.Equal("final-state:", text); } } + +/// +/// Companion to covering the other way a handler can surface an +/// input-required result to a non-MRTR client: by RETURNING an through the +/// alternate result path () instead of throwing +/// . The legacy backcompat resolver must normalize both forms so a non-MRTR +/// stateful client gets the same server-side resolution either way. +/// +public class MrtrReturnedInputRequiredResultBackcompatTests : ClientServerTestBase +{ + private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = + (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); + + private int _attempt; + + public MrtrReturnedInputRequiredResultBackcompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + { + Interlocked.Increment(ref _attempt); + + // Retry round: the backcompat resolver re-invoked us with the client's responses. + if (context.Params?.RequestState is not null) + { + return new ValueTask>(new CallToolResult + { + Content = [new TextContentBlock { Text = "resolved" }], + }); + } + + // First round: RETURN an InputRequiredResult through the alternate path rather than throwing. + var inputRequired = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "need-input", + RequestedSchema = new(), + }), + }, + RequestState = "round1", + }; + + return new ValueTask>( + ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); + }; + }); + } +#pragma warning restore MCPEXP002 + + [Fact] + public async Task ReturnedInputRequiredResult_NonMrtrStatefulClient_ResolvedServerSide() + { + StartServer(); + + // Non-MRTR client → server falls into the legacy backcompat resolver path, which must handle a + // RETURNED InputRequiredResult exactly like a thrown InputRequiredException. + var clientOptions = new McpClientOptions + { + ProtocolVersion = "2025-06-18", + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "return-form", + cancellationToken: TestContext.Current.CancellationToken); + + // Two handler invocations: initial (returned InputRequiredResult) + retry (final result). + Assert.Equal(2, _attempt); + var content = Assert.Single(result.Content); + Assert.Equal("resolved", Assert.IsType(content).Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 29ae7823f..d3e6f5e69 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -54,38 +54,224 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha var ct = TestContext.Current.CancellationToken; // The first request establishes the 2026-07-28 version for the stateful session (null -> 2026-07-28). - Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); // Re-sending the same version is an idempotent no-op, not an error. - Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); // Switching to a different (still-supported) version mid-session is rejected. - var error = Assert.IsType(await RoundTripAsync(id: 3, McpHttpHeaders.November2025ProtocolVersion, ct)); + var error = Assert.IsType(await RoundTripAsync(id: 3, McpProtocolVersions.November2025ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); // The rejected request must not have mutated the negotiated version: the original 2026-07-28 version still works. - Assert.IsType(await RoundTripAsync(id: 4, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 4, McpProtocolVersions.July2026ProtocolVersion, ct)); } - private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) + [Fact] + public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + 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 rejected initialize-handshake _meta request must not have established session state. + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + } + + [Fact] + public async Task PerRequestMetadata_RejectsRequestMissingRequiredMetadata() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripAsync( + id: 1, + McpProtocolVersions.July2026ProtocolVersion, + ct, + includeClientInfo: false)); + + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ServerDiscover_WithoutPerRequestMetadata_IsRejectedBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "per-request-meta-client", + ["version"] = "1.0.0", + }, + }, + }, McpJsonUtilities.DefaultOptions), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SubscriptionsListen_WithInitializeProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.SubscriptionsListen, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(RequestMethods.SubscriptionsListen, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task LoggingSetLevel_WithPerRequestMetadataProtocolVersion_IsRejected() { - // tools/list is available under both the legacy and 2026-07-28 revisions (unlike ping/initialize, + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + 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(RequestMethods.LoggingSetLevel, error.Error.Message, StringComparison.Ordinal); + } + + private async Task RoundTripAsync( + long id, + string protocolVersion, + CancellationToken cancellationToken, + bool includeClientInfo = true, + bool includeClientCapabilities = true) + { + // tools/list is available under both the initialize-handshake and 2026-07-28 revisions (unlike ping/initialize, // which the 2026-07-28 protocol removed), so it exercises the version guard rather than the // per-method availability gate. + var meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = protocolVersion, + }; + + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) + { + if (includeClientInfo) + { + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }; + } + + if (includeClientCapabilities) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } + } + var request = new JsonRpcRequest { Id = new RequestId(id), Method = RequestMethods.ToolsList, Params = new JsonObject { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = protocolVersion, - }, + ["_meta"] = meta, }, }; + return await SendAndReceiveAsync(request, cancellationToken); + } + + private static JsonObject PerRequestMetadata() => new() + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + + private async Task RoundTripInitializeAsync(long id, string protocolVersion, CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Id = new RequestId(id), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = protocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + return await SendAndReceiveAsync(request, cancellationToken); + } + + private async Task SendAndReceiveAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); #if NET await _writer.WriteLineAsync(json.AsMemory(), cancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs index 9e5a9e11f..d5735f105 100644 --- a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that the built-in ping handler is gated by protocol version. /// SEP-2575 (the 2026-07-28 revision) removes ping; servers must -/// respond with -32601 MethodNotFound. Legacy protocol versions still -/// support ping per the spec. +/// respond with -32601 MethodNotFound. Initialize-handshake protocol +/// versions still support ping per the spec. /// public sealed class PingProtocolGatingTests : ClientServerTestBase { @@ -28,7 +28,7 @@ public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }); var ex = await Assert.ThrowsAsync(async () => @@ -38,13 +38,13 @@ public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() } [Fact] - public async Task Ping_OnLegacySession_StillSucceeds() + public async Task Ping_OnInitializeHandshakeSession_StillSucceeds() { // Default server config; client pinned to 2025-11-25. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 818bd2b4b..5ece0f6b1 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -76,7 +76,7 @@ private async Task ReadAsync() return JsonNode.Parse(line!)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -96,7 +96,7 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingJuly2026Protoc var supportedVersions = result!["supportedVersions"]!.AsArray() .Select(n => n!.GetValue()) .ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions); // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. Assert.NotNull(result["capabilities"]); @@ -146,15 +146,15 @@ await SendAsync( Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] - public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() + public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer() { - // Dual-era: a server defaulting to the 2026-07-28 protocol (ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion in McpServerOptions) must still - // accept the legacy initialize handshake from clients that don't speak the new protocol. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + // Dual-path: a default server must still accept the initialize handshake from clients that + // don't speak the 2026-07-28 per-request metadata path. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var response = await ReadAsync(); Assert.Equal(1, response["id"]!.GetValue()); @@ -166,13 +166,14 @@ public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() [Fact] public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-era servers must accept 2026-07-28 and legacy traffic on the same connection. The exact mix below - // is what a permissive client running against an unknown server would emit while probing. + // 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.NotNull(discover["result"]); - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); Assert.NotNull(init["result"]); Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs index d0dd2a796..d727e00f7 100644 --- a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -15,7 +15,7 @@ namespace ModelContextProtocol.Tests.Server; /// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an /// in-memory stream transport (the stdio-shaped path exercised by ). /// Validates that a client on the 2026-07-28 protocol receives only the change notifications it subscribed to, each tagged -/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. +/// with the subscription id, and that initialize-handshake sessions keep receiving the session-wide broadcast. /// public class SubscriptionsListenTests : ClientServerTestBase { @@ -104,11 +104,11 @@ public async Task July2026Protocol_WithoutSubscription_DoesNotBroadcastListChang } [Fact] - public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() + public async Task InitializeHandshake_ListChanged_IsBroadcast_WithoutSubscription() { await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var toolsChannel = Channel.CreateUnbounded(); @@ -118,7 +118,7 @@ public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() var serverOptions = ServiceProvider.GetRequiredService>().Value; serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); - // Legacy sessions keep the session-wide broadcast and the notification carries no subscription id. + // Initialize-handshake sessions keep the session-wide broadcast and the notification carries no subscription id. var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); Assert.Null(GetSubscriptionId(notification)); } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index 0e8693353..e751bdcdc 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -29,16 +30,13 @@ public TaskCancellationIntegrationTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, DefaultTimeToLive = TimeSpan.FromSeconds(5), - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (CancellationToken ct) => { _toolStarted.TrySetResult(true); @@ -66,7 +64,7 @@ public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -93,7 +91,7 @@ public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -130,15 +128,12 @@ public TaskCancellationConcurrencyTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string marker, CancellationToken ct) => { TaskCompletionSource startTcs; @@ -219,14 +214,14 @@ public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks() RegisterMarker("task2"); // Start two tasks - var result1 = await client.CallToolRawAsync( + var result1 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", Arguments = CreateMarkerArgs("task1"), }, ct); - var result2 = await client.CallToolRawAsync( + var result2 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", @@ -273,15 +268,12 @@ public TerminalTaskStatusTransitionTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([ + }) + .WithTools([ McpServerTool.Create( async (CancellationToken ct) => { @@ -316,7 +308,7 @@ public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchange await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "quick-tool" }, ct); Assert.True(augmented.IsTask); @@ -346,7 +338,7 @@ public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); Assert.True(augmented.IsTask); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 8cd2f7839..621acfddb 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -1,17 +1,23 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// -/// Verifies the runtime validation that fires when a handler opts into task-augmented execution -/// (returns a ) without the server having any tasks/get -/// handler registered. +/// Verifies behavior when a handler returns a alternate without +/// the server having any tasks/get handler registered. After the ResultOrAlternate +/// generalization, the Core server no longer guards against this -- the extension is responsible +/// for ensuring lifecycle handlers are registered. /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -19,41 +25,40 @@ public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelpe #endif } +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); - // Intentionally configure a task-augmented handler without TaskStore or any of the - // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "orphan-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + // Configure a task-augmented handler without TaskStore or any of the + // task lifecycle request handlers. + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>( + ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = "orphan-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } +#pragma warning restore MCPEXP002 [Fact] - public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime() + public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError() { + // The Core guard that previously threw InvalidOperationException at request time when + // a CallToolWithAlternateHandler returned a CreateTaskResult without tasks/get being + // registered has been removed. The extension is now responsible for that guarantee. + // This test verifies the server starts and connects successfully with such configuration. await using var client = await CreateMcpClientForServer(); - // Client surfaces a generic protocol error (the server intentionally redacts the message - // on the wire), so use the base McpException type and confirm via server-side logs that - // the originating exception was the misconfiguration guard. - await Assert.ThrowsAnyAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "anything" }, - cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains(MockLoggerProvider.LogMessages, log => - log.Exception is InvalidOperationException ioe && - ioe.Message.Contains("tasks/get", StringComparison.Ordinal) && - ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal)); + // If we get here, the server accepted the handler config without error. + Assert.NotNull(client); } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index 1d17be223..c2552abd3 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -1,10 +1,13 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Tests.Server; @@ -29,49 +32,66 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); // CallTool always returns a CreateTaskResult with a tiny poll interval so the // test exercises the threshold in well under a second. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { var taskId = Guid.NewGuid().ToString("N"); - return new ValueTask>(new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - ResultType = "task", - }); + return new ValueTask>( + ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult)); }; // GetTask always reports InputRequired with NO outstanding input requests — the // misbehaving-server condition the stuck-detector exists to break out of. - options.Handlers.GetTaskHandler = (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - Interlocked.Increment(ref _pollCount); - - return new ValueTask(new InputRequiredTaskResult + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - TaskId = context.Params!.TaskId, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - InputRequests = new Dictionary(), - ResultType = "complete", - }); - }; - - // CancelTask must succeed since the client issues a best-effort cancel when it - // gives up; otherwise the cancel failure would mask the real exception. - options.Handlers.CancelTaskHandler = (context, cancellationToken) => - new ValueTask(new CancelTaskResult { ResultType = "complete" }); + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + Interlocked.Increment(ref _pollCount); + + return new ValueTask(JsonSerializer.SerializeToNode(new InputRequiredTaskResult + { + TaskId = requestParams.TaskId, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + InputRequests = new Dictionary(), + ResultType = "complete", + }, McpTasksJsonContext.Default.Options)); + }, + }); + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); - // UpdateTask is never invoked in this scenario (there are no input requests to resolve) - // but must be present so the handler-configuration validation passes. - options.Handlers.UpdateTaskHandler = (context, cancellationToken) => - new ValueTask(new UpdateTaskResult { ResultType = "complete" }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); }); } @@ -82,7 +102,7 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, cancellationToken: ct)); Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message); Assert.Contains("consecutive polls", ex.Message); @@ -93,18 +113,15 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro [Fact] public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() { - // Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync: + // Verifies CallToolWithPollingAsync plumbs the explicit threshold into PollTaskToCompletionAsync: // a smaller configured threshold is surfaced verbatim in the McpException message. const int CustomThreshold = 3; - await using var client = await CreateMcpClientForServer(new McpClientOptions - { - MaxConsecutiveStuckPolls = CustomThreshold, - }); + await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, maxConsecutiveStuckPolls: CustomThreshold, cancellationToken: ct)); // The message embeds the configured threshold, which is the strongest signal that the // option value (not the 60-default constant) is what governed the loop. @@ -112,19 +129,4 @@ public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() Assert.Equal(CustomThreshold, _pollCount); } - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(int.MinValue)] - public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value) - { - var options = new McpClientOptions(); - Assert.Throws(() => options.MaxConsecutiveStuckPolls = value); - } - - [Fact] - public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60() - { - Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls); - } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 7f9790526..6fa9fa215 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -6,8 +7,6 @@ using System.Text.Json; using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 - namespace ModelContextProtocol.Tests.Server; /// @@ -32,15 +31,12 @@ public TaskProtocolGatingTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -68,7 +64,7 @@ private static JsonObject CreateForgedTaskOptInMeta() => { [ExtensionsKey] = new JsonObject { - [McpExtensions.Tasks] = new JsonObject(), + [TasksProtocol.ExtensionId] = new JsonObject(), }, }, }; @@ -115,7 +111,7 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -127,24 +123,23 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy - // request. The server must still refuse to create a task because the per-request protocol - // version is not the 2026-07-28 protocol. - var result = await client.CallToolRawAsync( + // 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] @@ -157,10 +152,9 @@ public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() // gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound. var request = new JsonRpcRequest { - Method = RequestMethods.TasksGet, + Method = TasksProtocol.MethodTasksGet, Params = JsonSerializer.SerializeToNode( - new GetTaskRequestParams { TaskId = "some-task-id" }, - McpJsonUtilities.DefaultOptions), + new GetTaskRequestParams { TaskId = "some-task-id" }, McpTasksJsonContext.Default.Options), }; var ex = await Assert.ThrowsAsync(async () => @@ -176,7 +170,7 @@ public async Task July2026ProtocolClient_CallToolRaw_CreatesTask() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 303d16e17..144b08476 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -1,22 +1,25 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; using System.Text.Json; - -#pragma warning disable MCPEXP001 +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that when both and -/// are configured and the handler returns +/// Verifies that when both and +/// are configured and the handler returns /// (IsTask = true), the store's pre-created task is failed with a /// clear error rather than being orphaned in forever. /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -26,21 +29,25 @@ public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(tes protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { + mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore()); + mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); - options.TaskStore = new InMemoryMcpTaskStore(); - // Returning IsTask = true here while TaskStore is also configured is the + // Returning IsTask = true here while the tasks extension is also configured is the // misconfiguration the server must guard against. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "user-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>( + ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = "user-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } @@ -51,7 +58,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var ct = TestContext.Current.CancellationToken; // The store's task is created synchronously and its taskId returned to the client. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "anything" }, ct); Assert.True(augmented.IsTask); @@ -76,7 +83,8 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); - Assert.Contains(nameof(McpServerOptions.TaskStore), message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains(nameof(IMcpTaskStore), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } +#pragma warning restore MCPEXP002 }