From 5f5d5eac50d825633ea68ecf41ea180e83095454 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Sat, 25 Jul 2026 22:30:22 -0700 Subject: [PATCH 01/58] Resolve test server paths deterministically (#1735) Copilot-Session: ad8afb00-2809-4785-8781-014a90c8abf1 --- .../ClientIntegrationTestFixture.cs | 6 ++++-- .../Transport/StdioClientTransportTests.cs | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs index 6f0e8e4..726a3fb 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTestFixture.cs @@ -16,6 +16,8 @@ public class ClientIntegrationTestFixture public ClientIntegrationTestFixture() { const string ServerEverythingVersion = "2026.1.26"; + string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); + string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); EverythingServerTransportOptions = new() { @@ -27,14 +29,14 @@ public class ClientIntegrationTestFixture TestServerTransportOptions = new() { - Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "TestServer.exe" : PlatformDetection.IsMonoRuntime ? "mono" : "dotnet", + Command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? testServerExecutable : PlatformDetection.IsMonoRuntime ? "mono" : "dotnet", Name = "TestServer", }; if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // Change to Arguments to "mcp-server-everything" if you want to run the server locally after creating a symlink - TestServerTransportOptions.Arguments = [PlatformDetection.IsMonoRuntime ? "TestServer.exe" : "TestServer.dll"]; + TestServerTransportOptions.Arguments = [PlatformDetection.IsMonoRuntime ? testServerExecutable : testServerDll]; } } diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 8b78762..a709688 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -154,6 +154,8 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log } string cliArgument = $"--cli-arg={cliArgumentValue}"; + string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); + string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); StdioClientTransportOptions options = new() { @@ -161,14 +163,14 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { (true, _) => "mono", - (_, true) => "TestServer.exe", + (_, true) => testServerExecutable, _ => "dotnet", }, Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { - (true, _) => ["TestServer.exe", cliArgument], + (true, _) => [testServerExecutable, cliArgument], (_, true) => [cliArgument], - _ => ["TestServer.dll", cliArgument], + _ => [testServerDll, cliArgument], }, }; From 4c3774930d2b51dbb9d7bc913d201e7f331adcbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:37:30 -0700 Subject: [PATCH 02/58] Bump actions/setup-node from 6.4.0 to 7.0.0 (#1717) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index faab32f..c692237 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -50,7 +50,7 @@ jobs: 9.0.x - name: 🔧 Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' From 90969efdf9b30c9e0ac576d6ecfeb0f6f81fd781 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:41:30 -0700 Subject: [PATCH 03/58] Bump coverlet.collector from 10.0.0 to 10.0.1 (#1582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Cantú --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 498e2a7..8d27b5d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,7 +64,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 5bf61c72ebf1542e006118304a4ca7e82b499c86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:45:03 -0700 Subject: [PATCH 04/58] Bump qs from 6.14.2 to 6.15.2 in the npm_and_yarn group across 1 directory (#1597) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c0ebc5..4eeb85e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1146,9 +1146,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" From 6386d810d630626f2732b3d5f262031c09e984a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:00:28 -0700 Subject: [PATCH 05/58] Bump actions/setup-dotnet from 5.2.0 to 6.0.0 (#1716) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jeff Handley --- .github/workflows/ci-build-test.yml | 2 +- .github/workflows/ci-code-coverage.yml | 2 +- .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index c692237..4619405 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -43,7 +43,7 @@ jobs: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: 🔧 Set up .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index 4852801..0f4db82 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 6d0ed95..93e0d88 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install .NET SDK - uses: actions/setup-dotnet@v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e16e203..f582ac2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -52,7 +52,7 @@ jobs: fetch-depth: 0 - name: .NET Setup - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eca7a31..fd74f1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x @@ -87,7 +87,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 10.0.x @@ -117,7 +117,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x @@ -172,7 +172,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x From 371a759cc45f0c38b7afbd221e81437666406b76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:10:47 -0700 Subject: [PATCH 06/58] Bump danielpalme/ReportGenerator-GitHub-Action from 5.5.5 to 5.5.10 (#1569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Cantú Co-authored-by: Jeff Handley --- .github/workflows/ci-code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index 0f4db82..a52fce0 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -24,7 +24,7 @@ jobs: pattern: testresults-* - name: Combine coverage reports - uses: danielpalme/ReportGenerator-GitHub-Action@7ae927204961589fcb0b0be245c51fbbc87cbca2 # 5.5.5 + uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # 5.5.10 with: reports: "**/*.cobertura.xml" targetdir: "${{ github.workspace }}/report" From 9f845f8ca51ea26043464207db37a45319e45bc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:12:29 -0700 Subject: [PATCH 07/58] Bump actions/checkout from 6 to 7 (#1665) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jeff Handley --- .github/workflows/ci-build-test.yml | 2 +- .github/workflows/ci-code-coverage.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/markdown-link-check.yml | 2 +- .github/workflows/release.yml | 10 +++++----- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index 4619405..072ac60 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -38,7 +38,7 @@ jobs: steps: - name: 📥 Clone the repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index a52fce0..cac9486 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -10,7 +10,7 @@ jobs: publish-coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f072844..cce14e1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 93e0d88..e8e34fc 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -11,7 +11,7 @@ jobs: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f582ac2..f995a74 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout docs orchestration - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # A release event otherwise checks out the released tag. Keep the # orchestration scripts and picker assets current with main. diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index dd907de..e83edcd 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Markup Link Checker (mlc) uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd74f1d..81ef721 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Clone the repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis @@ -84,7 +84,7 @@ jobs: version_suffix_args: ${{ github.event_name != 'release' && format('--version-suffix "{0}"', inputs.version_suffix_override || format('ci.{0}', github.run_number)) || '' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -114,7 +114,7 @@ jobs: packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -149,7 +149,7 @@ jobs: packages: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -169,7 +169,7 @@ jobs: permissions: { } steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 From dfb2205cacf5ed3f668b16495499964c744b4e26 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Sun, 26 Jul 2026 13:40:04 +0700 Subject: [PATCH 08/58] Add MCP Apps to NativeAOT coverage (#1711) Co-authored-by: Loi Nguyen Co-authored-by: Jeff Handley --- ...xtProtocol.AotCompatibility.TestApp.csproj | 6 ++- .../Program.cs | 38 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index 2b505f9..a8ab66e 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -7,18 +7,20 @@ true false - - $(NoWarn);MCPEXP001 + + $(NoWarn);MCPEXP001;MCPEXP003 + + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 687d78e..708cd93 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,17 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.IO.Pipelines; Pipe clientToServerPipe = new(), serverToClientPipe = new(); -// Create a server using a stream-based transport over an in-memory pipe. -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions() - { - ToolCollection = [McpServerTool.Create((string arg) => $"Echo: {arg}", new() { Name = "Echo" })] - }); +var services = new ServiceCollection(); +services.AddMcpServer() + .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) + .WithTools() + .WithMcpApps(); + +await using var serviceProvider = services.BuildServiceProvider(); +var server = serviceProvider.GetRequiredService(); _ = server.RunAsync(); // Connect a client using a stream-based transport over the same in-memory pipe. @@ -20,13 +23,18 @@ await using McpClient client = await McpClient.CreateAsync( // List all tools. var tools = await client.ListToolsAsync(); -if (tools.Count == 0) +var echo = tools.FirstOrDefault(t => t.Name == "Echo"); +if (echo is null) { - throw new Exception("Expected at least one tool."); + throw new Exception("Expected the Echo tool."); +} + +var ui = echo.ProtocolTool.Meta?["ui"]?.AsObject(); +if (ui?["resourceUri"]?.GetValue() != "ui://aot/echo") +{ + throw new Exception($"Unexpected app UI metadata: {ui}"); } -// Invoke a tool. -var echo = tools.First(t => t.Name == "Echo"); var result = await echo.InvokeAsync(new() { ["arg"] = "Hello World" }); if (result is null || !result.ToString()!.Contains("Echo: Hello World")) { @@ -34,3 +42,11 @@ if (result is null || !result.ToString()!.Contains("Echo: Hello World")) } Console.WriteLine("Success!"); + +[McpServerToolType] +internal sealed class AotTools +{ + [McpServerTool(Name = "Echo")] + [McpAppUi(ResourceUri = "ui://aot/echo")] + public static string Echo(string arg) => $"Echo: {arg}"; +} From f2ce62ced94fa3e605f3a6101e27b74feabbfa02 Mon Sep 17 00:00:00 2001 From: dev_Hakaze Date: Sun, 26 Jul 2026 13:52:48 +0700 Subject: [PATCH 09/58] Rename MCPEXP002 Subclassing_* constants to Extensibility_* (#1739) Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- .../references/classification.md | 2 +- docs/list-of-diagnostics.md | 2 +- src/Common/Experimentals.cs | 23 ++++++++++--------- .../Client/McpClient.cs | 2 +- .../Protocol/JsonRpcMessageContext.cs | 2 +- .../Protocol/ResultOrAlternate.cs | 2 +- .../Server/McpRequestFilters.cs | 2 +- .../Server/McpRequestInvocationFilter.cs | 2 +- .../Server/McpServer.Methods.cs | 2 +- .../Server/McpServer.cs | 2 +- .../Server/McpServerHandlers.cs | 2 +- .../Server/McpServerOptions.cs | 2 +- .../Server/McpServerRequestHandler.cs | 4 ++-- 13 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.github/skills/breaking-changes/references/classification.md b/.github/skills/breaking-changes/references/classification.md index e588972..9c34c37 100644 --- a/.github/skills/breaking-changes/references/classification.md +++ b/.github/skills/breaking-changes/references/classification.md @@ -122,7 +122,7 @@ Before dismissing a potential break, review the PR description and all PR commen Every dismissed potential break must be reported to the user with enough detail for them to verify the conclusion. The audit must: 1. **Identify what would normally be breaking and why** (e.g., "CP0005 — adding abstract member `Completion` to abstract class `McpClient`") -2. **Explain the specific reason for dismissal** (e.g., "Bug fix correcting incorrect behavior per the MCP spec" or "`McpClient`'s only accessible constructor is `protected` and marked `[Experimental(MCPEXP002)]` with message 'Subclassing McpClient and McpServer is experimental and subject to change.'") +2. **Explain the specific reason for dismissal** (e.g., "Bug fix correcting incorrect behavior per the MCP spec" or "`McpClient`'s only accessible constructor is `protected` and marked `[Experimental(MCPEXP002)]` with message 'This C# SDK extensibility API is experimental and subject to change.'") 3. **Cite any supporting discussion** from the PR description or comments (e.g., "Reviewers discussed the addition and did not flag it as a breaking concern; compatibility suppressions were added for CP0005") 4. **Conclude with the dismissal and its category** (e.g., "Dismissed — bug fix correcting spec-non-compliant behavior" or "Dismissed — exclusively gated by `[Experimental]` API. Do not apply the `breaking-change` label.") diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 284f24f..a90e0e8 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -28,7 +28,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | Diagnostic ID | Description | | :------------ | :---------- | | `MCPEXP001` | Experimental APIs tied to MCP specification features. Reuse this ID for newly introduced experimental spec features, and add feature-specific messages/URLs in `Experimentals`. | -| `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). | +| `MCPEXP002` | Experimental SDK extensibility APIs used to implement features in standalone packages without requiring Core to understand those features. For example, the Tasks package uses these APIs to extend the SDK while keeping the Tasks concept out of Core. This includes `McpClient`/`McpServer` subclassing, custom request handlers, alternate handlers and filters, outgoing request interception, and `RunSessionHandler`. These APIs remain experimental until additional extensibility scenarios validate the design. | | `MCPEXP003` | Experimental MCP Apps extension APIs. MCP Apps is the first official MCP extension (`io.modelcontextprotocol/ui`), enabling servers to deliver interactive UIs inside AI clients (see [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). | ## Obsolete APIs diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index 1af5ce5..e61f841 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -13,9 +13,10 @@ namespace ModelContextProtocol; /// These APIs may change as the specification evolves. /// /// -/// MCPEXP002 covers experimental SDK APIs that are unrelated to the MCP specification, -/// such as subclassing internal types or SDK-specific extensibility hooks. These APIs may -/// change or be removed based on SDK design feedback. +/// MCPEXP002 covers SDK extensibility APIs that enable features to be implemented +/// in standalone packages without requiring Core to understand those features. The Tasks +/// package is one such consumer. These APIs remain experimental until additional +/// extensibility scenarios validate the design. /// /// /// @@ -65,31 +66,31 @@ internal static class Experimentals public const string Apps_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp003"; /// - /// Diagnostic ID for experimental SDK APIs unrelated to the MCP specification, - /// such as subclassing McpClient/McpServer or referencing RunSessionHandler. + /// Diagnostic ID for SDK extensibility APIs that enable independently packaged features, + /// such as Tasks, without requiring Core awareness of those features. /// /// /// This diagnostic ID covers experimental SDK-level extensibility APIs. All constants /// in this group share the same diagnostic ID so users need only one suppression point /// for SDK design preview features. /// - public const string Subclassing_DiagnosticId = "MCPEXP002"; + public const string Extensibility_DiagnosticId = "MCPEXP002"; /// - /// Message for experimental subclassing of McpClient and McpServer. + /// Message for experimental extensibility points in the C# SDK implementation. /// - public const string Subclassing_Message = "Subclassing McpClient and McpServer is experimental and subject to change."; + public const string Extensibility_Message = "This C# SDK extensibility API is experimental and subject to change."; /// - /// URL for experimental subclassing of McpClient and McpServer. + /// URL for experimental extensibility points in the C# SDK implementation. /// - public const string Subclassing_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; + public const string Extensibility_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; /// /// Diagnostic ID for the experimental RunSessionHandler API. /// /// - /// This uses the same diagnostic ID as because + /// This uses the same diagnostic ID as because /// both are experimental SDK APIs unrelated to the MCP specification. /// public const string RunSessionHandler_DiagnosticId = "MCPEXP002"; diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index d683172..6107201 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -12,7 +12,7 @@ public abstract partial class McpClient : McpSession /// /// Initializes a new instance of the class. /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] protected McpClient() { } diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index fae0684..def9b89 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -83,7 +83,7 @@ public sealed class JsonRpcMessageContext /// Streamable HTTP transports emit this value in the Mcp-Name header. This enables /// extension methods to identify the named resource targeted by a request. /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] [JsonIgnore] public string? RoutingName { get; set; } diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs index 0944ba1..8303978 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol; /// for the immediate result or for the alternate. /// /// -[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public class ResultOrAlternate where TResult : Result { private readonly TResult? _result; diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index df34d48..a1aad71 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -84,7 +84,7 @@ public sealed class McpRequestFilters /// asynchronously, filters registered after it execute as part of that asynchronous operation. /// /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public IList>> CallToolWithAlternateFilters { get => field ??= []; diff --git a/src/ModelContextProtocol.Core/Server/McpRequestInvocationFilter.cs b/src/ModelContextProtocol.Core/Server/McpRequestInvocationFilter.cs index f9871fc..baacf4f 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestInvocationFilter.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestInvocationFilter.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.Server; /// The next request handler in the pipeline for this invocation. /// The cancellation token for the current request. /// The result of the filtered request invocation. -[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public delegate ValueTask McpRequestInvocationFilter( RequestContext context, McpRequestHandler next, diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index a4a2450..a9a5ddd 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -39,7 +39,7 @@ public abstract partial class McpServer : McpSession /// On the returned facade, 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)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public McpServer WithOutgoingRequestInterceptor(Func> interceptor) { Throw.IfNull(interceptor); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index a51010d..4797ce1 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -11,7 +11,7 @@ public abstract partial class McpServer : McpSession /// /// Initializes a new instance of the class. /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] protected McpServer() { } diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index d7510a9..e871862 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -78,7 +78,7 @@ public sealed class McpServerHandlers /// /// /// is already set. - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public McpRequestHandler>? CallToolWithAlternateHandler { get; diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index a84bd60..f3d9b5f 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -217,6 +217,6 @@ public sealed class McpServerOptions /// Handlers registered here take precedence over built-in handlers for the same method. /// /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public IList? RequestHandlers { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs index 2da6d0e..af8a940 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -16,7 +16,7 @@ namespace ModelContextProtocol.Server; /// response, giving extensions full control over request/response serialization. /// /// -[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public sealed class McpServerRequestHandler { /// @@ -32,7 +32,7 @@ public sealed class McpServerRequestHandler /// When set, Streamable HTTP servers require the request to include an Mcp-Name /// header whose decoded value matches the string value of this parameter. /// - [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public string? RoutingNameParameter { get; init; } /// From e03acd1e7cdc276adc0b889d23271907a29eeb86 Mon Sep 17 00:00:00 2001 From: yayayouyou <166706230+yayayouyou@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:48:33 +0800 Subject: [PATCH 10/58] Add round-trip integration tests for MCP Apps _meta.ui serialization (#1698) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jeff Handley --- .../Server/McpAppsIntegrationTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs new file mode 100644 index 0000000..aadb669 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs @@ -0,0 +1,100 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Round-trip integration tests for the MCP Apps _meta.ui metadata: tools registered +/// with and processed by WithMcpApps() are listed through +/// and the structured _meta.ui object is verified +/// after serializing across the client-server transport. +/// +public class McpAppsIntegrationTests : ClientServerTestBase +{ + public McpAppsIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithTools() + .WithMcpApps(); + } + + [Fact] + public async Task ListToolsAsync_RoundTripsMetaUi_ForToolWithAppUiAttribute() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var weatherTool = Assert.Single(tools, t => t.Name == "weather_tool"); + + JsonObject? uiNode = weatherTool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); + + // Visibility was not restricted, so the property must be absent entirely + // (not merely serialized as null). + Assert.False(uiNode.ContainsKey("visibility")); + } + + [Fact] + public async Task ListToolsAsync_RoundTripsMetaUiVisibility_ForRestrictedTool() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var modelOnlyTool = Assert.Single(tools, t => t.Name == "model_only_tool"); + + JsonObject? uiNode = modelOnlyTool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://model-only/view.html", uiNode["resourceUri"]?.GetValue()); + + JsonArray? visibility = uiNode["visibility"]?.AsArray(); + Assert.NotNull(visibility); + JsonNode? single = Assert.Single(visibility); + Assert.Equal(McpUiToolVisibility.Model, single?.GetValue()); + } + + [Fact] + public async Task ListToolsAsync_HasNoUiMeta_ForToolWithoutAppUiAttribute() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var plainTool = Assert.Single(tools, t => t.Name == "plain_tool"); + + // The tool must carry no "ui" metadata at all: either no _meta object, + // or a _meta without the "ui" key (not a "ui": null entry). + JsonObject? meta = plainTool.ProtocolTool.Meta; + Assert.False(meta is not null && meta.ContainsKey("ui")); + } + + public sealed class AppUiTools + { + [McpServerTool(Name = "weather_tool")] + [McpAppUi(ResourceUri = "ui://weather/view.html")] + [Description("Get weather")] + public static string WeatherTool(string location) => $"Weather for {location}"; + + [McpServerTool(Name = "model_only_tool")] + [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])] + [Description("Model only")] + public static string ModelOnlyTool(string location) => $"Model only for {location}"; + + [McpServerTool(Name = "plain_tool")] + [Description("Plain tool without app UI")] + public static string PlainTool(string input) => input; + } +} From ec216b5202c282440dd64e4053575d33180d6119 Mon Sep 17 00:00:00 2001 From: xue-cai <54598758+xue-cai@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:57:27 -0700 Subject: [PATCH 11/58] Fix: swallow _receiveTask exceptions in SseClientSessionTransport.CloseAsync (#1432) Co-authored-by: Xue Cai Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jeff Handley --- .../Client/SseClientSessionTransport.cs | 3 +- .../HttpClientTransportAutoDetectTests.cs | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index fb91898..03969b4 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -66,7 +66,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase { LogTransportConnectFailed(Name, ex); await CloseAsync().ConfigureAwait(false); - throw new IOException("Failed to connect transport.", ex); + throw; } } @@ -198,7 +198,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase LogTransportReadMessagesFailed(Name, ex); _connectionEstablished.TrySetException(ex); - throw; } } finally diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index 68735d6..e6386ad 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -49,6 +49,60 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp Assert.NotNull(session); } + [Fact] + public async Task AutoDetectMode_WhenBothTransportsFail_PreservesStreamableHttpException() + { + // Regression test: when Streamable HTTP POST fails (e.g. 403) and the SSE GET + // fallback also fails (e.g. 405), the original Streamable HTTP error should + // be preserved. The SSE connection failure is available as its inner exception. + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + // Streamable HTTP POST fails with 403 (auth error) + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.Forbidden, + Content = new StringContent("Forbidden") + }); + } + + if (request.Method == HttpMethod.Get) + { + // SSE GET fallback fails with 405 + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.MethodNotAllowed, + Content = new StringContent("Method Not Allowed") + }); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + }; + + // ConnectAsync for AutoDetect mode just creates the transport without sending + // any HTTP request. The auto-detection is triggered lazily by the first + // SendMessageAsync call, which happens inside McpClient.CreateAsync when it + // sends the JSON-RPC "initialize" message. + var ex = await Assert.ThrowsAsync( + () => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("403", ex.Message); + Assert.IsType(ex.InnerException); + Assert.Contains("405", ex.InnerException.Message); + } + [Fact] public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails() { From 96335183f0f6841b60ac99c38a7f6aa29d78ebd4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:02:34 -0700 Subject: [PATCH 12/58] Bump the testing-frameworks group with 1 update (#1746) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8d27b5d..693dfac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,7 +75,7 @@ - + From f55149e01219998866d0c2ed23e08368fa529205 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:14:31 -0700 Subject: [PATCH 13/58] Bump the serilog-testing group with 1 update (#1748) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 693dfac..0d2f0dd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -88,7 +88,7 @@ - + From ea60c147a917d6d3be5d9989e7bd1d18fca64e84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:36:59 -0700 Subject: [PATCH 14/58] Bump actions/checkout from 7.0.0 to 7.0.1 (#1745) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-build-test.yml | 2 +- .github/workflows/ci-code-coverage.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/markdown-link-check.yml | 2 +- .github/workflows/release.yml | 10 +++++----- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index 072ac60..86e48a2 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -38,7 +38,7 @@ jobs: steps: - name: 📥 Clone the repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index cac9486..69e3d1e 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -10,7 +10,7 @@ jobs: publish-coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cce14e1..bed505d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index e8e34fc..580ac1c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -11,7 +11,7 @@ jobs: contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install .NET SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f995a74..1e187ef 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout docs orchestration - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # A release event otherwise checks out the released tag. Keep the # orchestration scripts and picker assets current with main. diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index e83edcd..1dbf83b 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Markup Link Checker (mlc) uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ef721..4c96a86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Clone the repo - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis @@ -84,7 +84,7 @@ jobs: version_suffix_args: ${{ github.event_name != 'release' && format('--version-suffix "{0}"', inputs.version_suffix_override || format('ci.{0}', github.run_number)) || '' }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -114,7 +114,7 @@ jobs: packages: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -149,7 +149,7 @@ jobs: packages: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -169,7 +169,7 @@ jobs: permissions: { } steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 From c0d3d6547dba3a1026ccf9e6080614b9499d9574 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:37:30 -0700 Subject: [PATCH 15/58] Bump Anthropic from 12.17.0 to 12.39.0 (#1750) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0d2f0dd..1674294 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -63,7 +63,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 950ac7c1bcf0a206ac3633f5a42988df4274365e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:01:45 -0700 Subject: [PATCH 16/58] Bump OpenTelemetry and 5 others (#1747) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1674294..3819d8c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -77,12 +77,12 @@ - - - - - - + + + + + + From 9c635ffed104b625ad05861270c543a144085800 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 27 Jul 2026 12:24:02 -0700 Subject: [PATCH 17/58] Validate OAuth authorization state (#1726) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/list-of-diagnostics.md | 2 +- samples/ProtectedMcpClient/Program.cs | 3 +- .../Authentication/AuthorizationResult.cs | 19 ++++- .../Authentication/ClientOAuthOptions.cs | 18 ++-- .../Authentication/ClientOAuthProvider.cs | 54 ++++++++++-- .../OAuth/AuthTests.cs | 85 +++++++++++++++++-- .../OAuth/OAuthTestBase.cs | 1 + .../Program.cs | 9 +- .../Program.cs | 5 ++ 9 files changed, 170 insertions(+), 26 deletions(-) diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index a90e0e8..4c19eba 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -45,4 +45,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | | `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information. | | `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | -| `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the RFC 9207 authorization-response issuer. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for issuer-aware authorization flows. | +| `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the authorization-response state or RFC 9207 issuer. State and issuer validation are skipped when these APIs are used. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for response-bound, issuer-aware authorization flows. | diff --git a/samples/ProtectedMcpClient/Program.cs b/samples/ProtectedMcpClient/Program.cs index 9b805ad..6cda8e7 100644 --- a/samples/ProtectedMcpClient/Program.cs +++ b/samples/ProtectedMcpClient/Program.cs @@ -98,6 +98,7 @@ static async Task HandleAuthorizationUrlAsync( var context = await listener.GetContextAsync(); var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? string.Empty); var code = query["code"]; + var state = query["state"]; var iss = query["iss"]; var error = query["error"]; @@ -121,7 +122,7 @@ static async Task HandleAuthorizationUrlAsync( } Console.WriteLine("Authorization code received successfully."); - return new AuthorizationResult { Code = code, Iss = iss }; + return new AuthorizationResult { Code = code, State = state, Iss = iss }; } catch (Exception ex) { diff --git a/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs index d5bebc5..4da082f 100644 --- a/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs +++ b/src/ModelContextProtocol.Core/Authentication/AuthorizationResult.cs @@ -1,11 +1,16 @@ namespace ModelContextProtocol.Authentication; /// -/// Represents the result of an OAuth authorization redirect, containing the authorization code -/// and optionally the issuer identifier from the authorization response. +/// Represents the result of an OAuth authorization redirect, containing the authorization code, +/// state, and optionally the issuer identifier from the authorization response. /// /// /// +/// The property must be populated from the state query parameter in the +/// redirect URI. The SDK validates it against the value sent in the authorization request to bind +/// the response to the initiating transaction and mitigate cross-site request forgery attacks. +/// +/// /// The property should be populated from the iss query parameter in the /// redirect URI when present, as specified by /// RFC 9207. @@ -20,6 +25,16 @@ public sealed class AuthorizationResult /// public string? Code { get; init; } + /// + /// Gets the state value returned in the authorization response. + /// + /// + /// Implementations of must populate this + /// property from the state query parameter of the redirect URI callback. The SDK requires an + /// exact match with the state sent in the authorization request before exchanging the authorization code. + /// + public string? State { get; init; } + /// /// Gets the issuer identifier returned in the authorization response per /// RFC 9207. diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index b536207..df1c44c 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -80,8 +80,10 @@ public sealed class ClientOAuthOptions /// /// /// Custom implementations might open a browser, start an HTTP listener, or use other mechanisms to capture - /// the authorization response. They should return both the code and iss query parameters - /// from the redirect URI callback. This enables the SDK to validate the iss parameter per + /// the authorization response. They must return the code and state query parameters, + /// and should return the iss query parameter when present, from the redirect URI callback. + /// The SDK requires an exact state match before exchanging the code. Returning iss enables + /// the SDK to validate the parameter per /// RFC 9207, which mitigates /// mix-up attacks. /// @@ -96,9 +98,10 @@ public sealed class ClientOAuthOptions /// /// /// - /// This delegate returns only the authorization code and cannot provide the iss parameter from - /// the authorization response. Consequently, RFC 9207 issuer validation is skipped when this delegate - /// is used. Use for issuer-aware authorization flows. + /// This delegate returns only the authorization code and cannot provide the state or iss + /// parameter from the authorization response. Consequently, state and RFC 9207 issuer validation are + /// skipped when this delegate is used. Use for response-bound, + /// issuer-aware authorization flows. /// /// /// This property cannot be configured together with . @@ -139,8 +142,9 @@ public sealed class ClientOAuthOptions /// /// /// - /// Parameters specified cannot override or append to any automatically set parameters like the "redirect_uri", - /// which should instead be configured via . + /// Parameters specified cannot override or append to any automatically set parameters like + /// redirect_uri or state. The redirect URI should instead be configured via + /// , while state is generated uniquely for each authorization transaction. /// /// public IDictionary AdditionalAuthorizationParameters { get; set; } = new Dictionary(); diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 632fa9e..a20ef9f 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -32,6 +32,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; private readonly Func> _authorizationCallbackHandler; + private readonly bool _validateAuthorizationResponseState; private readonly bool _validateAuthorizationResponseIssuer; private readonly Uri? _clientMetadataDocumentUri; @@ -120,6 +121,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (options.AuthorizationCallbackHandler is not null) { _authorizationCallbackHandler = options.AuthorizationCallbackHandler; + _validateAuthorizationResponseState = true; _validateAuthorizationResponseIssuer = true; } else if (authorizationRedirectDelegate is not null) @@ -131,11 +133,13 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient context.RedirectUri, cancellationToken).ConfigureAwait(false), }; + _validateAuthorizationResponseState = false; _validateAuthorizationResponseIssuer = false; } else { _authorizationCallbackHandler = DefaultAuthorizationUrlHandler; + _validateAuthorizationResponseState = true; _validateAuthorizationResponseIssuer = true; } @@ -155,11 +159,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private static Uri? DefaultAuthServerSelector(IReadOnlyList availableServers) => availableServers.FirstOrDefault(); /// - /// Default authorization URL handler that displays the URL to the user for manual input. + /// Default authorization URL handler that displays the URL to the user and parses the resulting redirect URL. /// /// The context containing the authorization and redirect URIs. /// The to monitor for cancellation requests. - /// The authorization result entered by the user, or null if none was provided. + /// The authorization result parsed from the redirect URL. private static Task DefaultAuthorizationUrlHandler( AuthorizationCallbackContext context, CancellationToken cancellationToken) @@ -179,6 +183,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return Task.FromResult(new() { Code = queryParams["code"], + State = queryParams["state"], Iss = queryParams["iss"], }); } @@ -687,10 +692,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken) { - var codeVerifier = GenerateCodeVerifier(); + var codeVerifier = GenerateRandomBase64UrlValue(); var codeChallenge = GenerateCodeChallenge(codeVerifier); + var state = GenerateRandomBase64UrlValue(); - var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge); + var authUrl = BuildAuthorizationUrl(protectedResourceMetadata, authServerMetadata, codeChallenge, state); var authResult = await _authorizationCallbackHandler( new AuthorizationCallbackContext @@ -700,9 +706,19 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient }, cancellationToken).ConfigureAwait(false); - if (authResult is null || string.IsNullOrEmpty(authResult.Code)) + if (authResult is null) { - ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null or empty authorization code."); + ThrowFailedToHandleUnauthorizedResponse($"The {nameof(ClientOAuthOptions.AuthorizationCallbackHandler)} returned a null authorization result."); + } + + if (_validateAuthorizationResponseState) + { + ValidateStateResponse(authResult!.State, state); + } + + if (string.IsNullOrEmpty(authResult.Code)) + { + ThrowFailedToHandleUnauthorizedResponse("The authorization callback returned a null or empty authorization code."); } if (_validateAuthorizationResponseIssuer) @@ -721,7 +737,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private Uri BuildAuthorizationUrl( ProtectedResourceMetadata protectedResourceMetadata, AuthorizationServerMetadata authServerMetadata, - string codeChallenge) + string codeChallenge, + string state) { var resourceUri = GetResourceUri(protectedResourceMetadata); @@ -732,6 +749,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ["response_type"] = "code", ["code_challenge"] = codeChallenge, ["code_challenge_method"] = "S256", + ["state"] = state, }; if (resourceUri is not null) @@ -1107,6 +1125,26 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return scope + " " + OfflineAccess; } + /// + /// Validates that an authorization response is bound to the transaction that initiated it. + /// + /// The state returned in the authorization response. + /// The state sent in the authorization request. + private static void ValidateStateResponse(string? state, string expectedState) + { + if (string.IsNullOrEmpty(state)) + { + ThrowFailedToHandleUnauthorizedResponse( + "The authorization response did not include the required state parameter."); + } + + if (!string.Equals(state, expectedState, StringComparison.Ordinal)) + { + ThrowFailedToHandleUnauthorizedResponse( + "The authorization response state did not match the state sent in the authorization request."); + } + } + /// /// Validates the iss parameter from an authorization response per /// RFC 9207. @@ -1378,7 +1416,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient yield return (new Uri($"{hostBase}{ProtectedResourceMetadataWellKnownPath}"), new Uri(hostBase)); } - private static string GenerateCodeVerifier() + private static string GenerateRandomBase64UrlValue() { #if NET9_0_OR_GREATER Span bytes = stackalloc byte[32]; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 2aca606..693c779 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -176,6 +176,77 @@ public class AuthTests : OAuthTestBase #pragma warning restore MCP9007 } + [Fact] + public async Task CanAuthenticate_WhenAuthorizationResponseStateMatches() + { + await using var app = await StartMcpServerAsync(); + + string? requestedState = null; + await using var transport = CreateOAuthTransport((context, cancellationToken) => + { + requestedState = QueryHelpers.ParseQuery(context.AuthorizationUri.Query)["state"]; + return HandleAuthorizationUrlAsync(context, cancellationToken); + }); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedState); + Assert.True(requestedState.Length >= 43); + Assert.Equal(1, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationResponseStateIsMissing() + { + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport( + (_, _) => Task.FromResult(new() { Code = "unused-code" })); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("did not include the required state parameter", ex.Message); + Assert.Equal(0, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task CannotAuthenticate_WhenAuthorizationResponseStateMismatches() + { + await using var app = await StartMcpServerAsync(); + await using var transport = CreateOAuthTransport( + (_, _) => Task.FromResult( + new() { Code = "unused-code", State = "unexpected-state" })); + + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("state did not match", ex.Message); + Assert.Equal(0, TestOAuthServer.AuthorizationCodeTokenRequestCount); + } + + [Fact] + public async Task AuthorizationRequests_UseUniqueStateValues() + { + await using var app = await StartMcpServerAsync(); + List requestedStates = []; + + for (var i = 0; i < 2; i++) + { + await using var transport = CreateOAuthTransport((context, cancellationToken) => + { + requestedStates.Add(QueryHelpers.ParseQuery(context.AuthorizationUri.Query)["state"].ToString()); + return HandleAuthorizationUrlAsync(context, cancellationToken); + }); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Equal(2, requestedStates.Count); + Assert.Equal(2, requestedStates.Distinct(StringComparer.Ordinal).Count()); + } + [Fact] public async Task CannotAuthenticate_WithoutOAuthConfiguration() { @@ -550,8 +621,10 @@ public class AuthTests : OAuthTestBase Assert.Contains("custom_param=custom_value", lastAuthorizationUri?.Query); } - [Fact] - public async Task CannotOverrideExistingParameters_WithExtraParams() + [Theory] + [InlineData("redirect_uri")] + [InlineData("state")] + public async Task CannotOverrideExistingParameters_WithExtraParams(string parameterName) { await using var app = await StartMcpServerAsync(); @@ -566,7 +639,7 @@ public class AuthTests : OAuthTestBase AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, AdditionalAuthorizationParameters = new Dictionary { - ["redirect_uri"] = "custom_value", + [parameterName] = "custom_value", } }, }, HttpClient, LoggerFactory); @@ -2434,7 +2507,9 @@ public class AuthTests : OAuthTestBase Assert.False(scopePresent); } - private HttpClientTransport CreateOAuthTransport() => + private HttpClientTransport CreateOAuthTransport( + Func>? + authorizationCallbackHandler = null) => new(new() { Endpoint = new(McpServerUrl), @@ -2443,7 +2518,7 @@ public class AuthTests : OAuthTestBase ClientId = "demo-client", ClientSecret = "demo-secret", RedirectUri = new Uri("http://localhost:1179/callback"), - AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + AuthorizationCallbackHandler = authorizationCallbackHandler ?? HandleAuthorizationUrlAsync, }, }, HttpClient, LoggerFactory); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 5f150e9..80167b0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -120,6 +120,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable return new ModelContextProtocol.Authentication.AuthorizationResult { Code = queryParams["code"], + State = queryParams["state"], Iss = queryParams.TryGetValue("iss", out var iss) ? (string?)iss : null, }; } diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index a52e8cf..f9beea9 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -360,9 +360,10 @@ static async Task HandleAuthorizationUrlAsync( if (location is not null && !string.IsNullOrEmpty(location.Query)) { - // Parse query string to extract "code" and "iss" parameters + // Parse query string to extract "code", "state", and "iss" parameters var query = location.Query.TrimStart('?'); string? code = null; + string? state = null; string? iss = null; foreach (var pair in query.Split('&')) { @@ -373,6 +374,10 @@ static async Task HandleAuthorizationUrlAsync( { code = HttpUtility.UrlDecode(parts[1]); } + else if (parts[0] == "state") + { + state = HttpUtility.UrlDecode(parts[1]); + } else if (parts[0] == "iss") { iss = HttpUtility.UrlDecode(parts[1]); @@ -382,7 +387,7 @@ static async Task HandleAuthorizationUrlAsync( if (code is not null) { - return new AuthorizationResult { Code = code, Iss = iss }; + return new AuthorizationResult { Code = code, State = state, Iss = iss }; } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 6bedbc4..73663dc 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -28,6 +28,7 @@ public sealed class Program private readonly ConcurrentDictionary _clients = new(); private readonly ConcurrentQueue _metadataRequests = new(); + private int _authorizationCodeTokenRequestCount; private readonly RSA _rsa; private readonly string _keyId; @@ -137,6 +138,9 @@ public sealed class Program public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); + /// Gets the number of authorization-code token exchange requests received. + public int AuthorizationCodeTokenRequestCount => Volatile.Read(ref _authorizationCodeTokenRequestCount); + /// Gets the scope field from the most recent Dynamic Client Registration request. public string? LastRegistrationScope { get; private set; } @@ -460,6 +464,7 @@ public sealed class Program if (grant_type == "authorization_code") { + Interlocked.Increment(ref _authorizationCodeTokenRequestCount); var code = form["code"].ToString(); var code_verifier = form["code_verifier"].ToString(); var redirect_uri = form["redirect_uri"].ToString(); From 7838900e8b4143fa45e730e8602842aa81443ab6 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Mon, 27 Jul 2026 12:45:25 -0700 Subject: [PATCH 18/58] Enable complete Tasks extension conformance coverage (#1741) Copilot-Session: 7c99928c-a943-49f4-9a63-8b04a0e4d18b --- .../Server/McpTaskExecutionMode.cs | 16 ++++ .../Server/McpTasksBuilderExtensions.cs | 49 ++++++++++- .../Server/McpTasksOptions.cs | 22 +++++ .../ServerConformanceTests.cs | 17 ++-- .../Program.cs | 13 ++- .../Server/McpTaskStoreTests.cs | 87 ++++++++++++++++++- 6 files changed, 190 insertions(+), 14 deletions(-) create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs new file mode 100644 index 0000000..9045cea --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskExecutionMode.cs @@ -0,0 +1,16 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Specifies how a tool call participates in the MCP Tasks extension. +/// +public enum McpTaskExecutionMode +{ + /// The tool always executes synchronously. + Synchronous, + + /// The tool executes as a task when the client declares the Tasks extension. + Optional, + + /// The tool requires the client to declare the Tasks extension. + Required, +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index f9626c7..cbdf73c 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -29,13 +29,36 @@ public static class McpTasksBuilderExtensions /// The task store. /// The builder provided in . public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) + => WithTasks(builder, store, static _ => { }); + + /// + /// Enables MCP Tasks support backed by the specified task store. + /// + /// The server builder. + /// The task store. + /// A callback that configures per-call task execution behavior. + /// The builder provided in . + public static IMcpServerBuilder WithTasks( + this IMcpServerBuilder builder, + IMcpTaskStore store, + Action configure) { #if NET ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(configure); #else if (builder is null) throw new ArgumentNullException(nameof(builder)); if (store is null) throw new ArgumentNullException(nameof(store)); + if (configure is null) throw new ArgumentNullException(nameof(configure)); +#endif + + McpTasksOptions taskOptions = new(); + configure(taskOptions); +#if NET + ArgumentNullException.ThrowIfNull(taskOptions.ExecutionModeSelector); +#else + if (taskOptions.ExecutionModeSelector is null) throw new ArgumentNullException(nameof(taskOptions.ExecutionModeSelector)); #endif // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the @@ -45,18 +68,21 @@ public static class McpTasksBuilderExtensions sp => new McpTasksConfigureOptions( store, sp.GetRequiredService(), - sp.GetService())); + sp.GetService(), + taskOptions)); return builder; } private sealed class McpTasksConfigureOptions( IMcpTaskStore store, IServiceScopeFactory serviceScopeFactory, - ILoggerFactory? loggerFactory) : IConfigureOptions + ILoggerFactory? loggerFactory, + McpTasksOptions taskOptions) : IConfigureOptions { private readonly IMcpTaskStore _store = store; private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory; private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + private readonly McpTasksOptions _taskOptions = taskOptions; private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); public void Configure(McpServerOptions options) @@ -108,9 +134,28 @@ public static class McpTasksBuilderExtensions options.Filters.Request.CallToolWithAlternateFilters.Count, async (request, next, cancellationToken) => { + var executionMode = _taskOptions.ExecutionModeSelector(request); + if (executionMode is not McpTaskExecutionMode.Synchronous and + not McpTaskExecutionMode.Optional and + not McpTaskExecutionMode.Required) + { + throw new InvalidOperationException( + $"{nameof(McpTasksOptions.ExecutionModeSelector)} returned an invalid {nameof(McpTaskExecutionMode)} value."); + } + + if (executionMode == McpTaskExecutionMode.Synchronous) + { + return await next(request, cancellationToken).ConfigureAwait(false); + } + if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.JsonRpcRequest)) { + if (executionMode == McpTaskExecutionMode.Required) + { + throw CreateMissingTasksCapabilityException(); + } + return await next(request, cancellationToken).ConfigureAwait(false); } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs new file mode 100644 index 0000000..818a53f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksOptions.cs @@ -0,0 +1,22 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Configures server-side MCP Tasks behavior. +/// +public sealed class McpTasksOptions +{ + /// + /// Gets or sets the callback that selects task execution behavior for each tool call. + /// + /// + /// The default treats every tool as task-capable, preserving the behavior of + /// WithTasks. + /// The callback may inspect to select + /// behavior from tool metadata without requiring Core to reference the Tasks extension. + /// + public Func, McpTaskExecutionMode> ExecutionModeSelector { get; set; } = + static _ => McpTaskExecutionMode.Optional; +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 059c551..2cb1d73 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -129,18 +129,15 @@ public class ServerConformanceTests( [Theory] [InlineData("tasks-wire-fields")] + [InlineData("tasks-lifecycle")] + [InlineData("tasks-capability-negotiation")] + [InlineData("tasks-request-headers")] + [InlineData("tasks-dispatch-and-envelope")] + [InlineData("tasks-status-notifications")] + [InlineData("tasks-required-task-error")] [InlineData("tasks-request-state-removal")] [InlineData("tasks-mrtr-input")] - // Most remaining scenarios require per-tool task execution configuration that the SDK - // does not currently expose; status notifications await an upstream harness rewrite. - // Keep them listed here for incremental enablement. - // [InlineData("tasks-lifecycle")] - // [InlineData("tasks-capability-negotiation")] - // [InlineData("tasks-request-headers")] - // [InlineData("tasks-dispatch-and-envelope")] - // [InlineData("tasks-status-notifications")] - // [InlineData("tasks-required-task-error")] - // [InlineData("tasks-mrtr-composition")] + [InlineData("tasks-mrtr-composition")] public async Task RunTasksExtensionConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index e195f7c..4c6c54f 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -58,7 +58,18 @@ public class Program { DefaultPollIntervalMs = 50, DefaultTimeToLive = TimeSpan.FromMinutes(5), - }) + }, + options => options.ExecutionModeSelector = request => + request.Params?.Name switch + { + "slow_compute" or "protocol_error_job" or "confirm_delete" or "multi_input" + => McpTaskExecutionMode.Optional, + "failing_job" + => McpTaskExecutionMode.Required, + "test_tool_with_task" when request.Params.InputResponses is { Count: > 0 } + => McpTaskExecutionMode.Required, + _ => McpTaskExecutionMode.Synchronous, + }) .WithTools() .WithTools() .WithTools() diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index a904c87..ebf7ca4 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -33,7 +33,13 @@ public class McpTaskStoreTests : ClientServerTestBase .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }); + }, options => options.ExecutionModeSelector = request => + request.Params?.Name switch + { + "sync-tool" => McpTaskExecutionMode.Synchronous, + "required-tool" => McpTaskExecutionMode.Required, + _ => McpTaskExecutionMode.Optional, + }); } [Fact] @@ -52,6 +58,79 @@ public class McpTaskStoreTests : ClientServerTestBase Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status); } + [Fact] + public async Task CallToolAsync_SynchronousExecutionMode_ReturnsResultDirectly() + { + await using McpClient client = await CreateMcpClientForServer(); + + CallToolResult result = await client.CallToolAsync( + "sync-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("sync", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolAsync_RequiredExecutionModeWithoutCapability_Throws() + { + await using McpClient client = await CreateMcpClientForServer(); + + MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "required-tool", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode); + } + + [Fact] + public async Task CallToolAsync_RequiredExecutionModeWithLegacyProtocol_Throws() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + MissingRequiredClientCapabilityException exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "required-tool", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, exception.ErrorCode); + } + + [Fact] + public async Task CallToolAsTaskAsync_RequiredExecutionMode_ReturnsTask() + { + await using McpClient client = await CreateMcpClientForServer(); + + ResultOrCreatedTask result = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "required-tool" }, + TestContext.Current.CancellationToken); + + Assert.True(result.IsTask); + + CallToolResult completedResult = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "required-tool" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("required", Assert.IsType(completedResult.Content[0]).Text); + } + + [Fact] + public void WithTasks_NullExecutionModeSelector_Throws() + { + ServiceCollection services = new(); + + Assert.Throws(() => + services + .AddMcpServer() + .WithStreamServerTransport(Stream.Null, Stream.Null) + .WithTasks( + new InMemoryMcpTaskStore(), + options => options.ExecutionModeSelector = null!)); + } + [Fact] public async Task CallToolAsync_WithTaskStore_PollsToCompletion() { @@ -609,6 +688,12 @@ public class McpTaskStoreTests : ClientServerTestBase [McpServerToolType] private sealed class TaskStoreTestTools { + [McpServerTool(Name = "sync-tool")] + public static string SyncTool() => "sync"; + + [McpServerTool(Name = "required-tool")] + public static string RequiredTool() => "required"; + [McpServerTool(Name = "slow-tool"), System.ComponentModel.Description("A tool that takes time")] public static async Task SlowTool(CancellationToken cancellationToken) { From 6787c0cc19d2520790f7a11110d17a83bb5c2476 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:57:50 -0700 Subject: [PATCH 19/58] Gate 2026-07-28 result fields on negotiated protocol version (#1721) (#1753) Co-authored-by: Tarek Mahmoud Sayed Copilot-Session: 80653343-5dcb-43cb-89b0-8ac8e572c4f7 --- .../Server/McpServerImpl.cs | 33 +++++- .../RawHttpConformanceTests.cs | 34 ++++++ .../Server/McpServerTests.cs | 67 +++++++---- .../ProtocolVersionResultDecorationTests.cs | 112 ++++++++++++++++++ 4 files changed, 219 insertions(+), 27 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 961100b..0827ceb 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -609,7 +609,10 @@ internal sealed partial class McpServerImpl : McpServer Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), - ResultType = "complete", + + // resultType is a 2026-07-28 result field. The initialize handshake is only available on + // 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw + // above), so InitializeResult must never carry resultType (issue #1721). }; }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, @@ -1635,8 +1638,12 @@ internal sealed partial class McpServerImpl : McpServer return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken); } - // Otherwise, consider it handled. - return new ValueTask(EmptyResult.Instance); + // Otherwise, consider it handled. logging/setLevel is a legacy (<= 2025-11-25) method + // (2026-07-28+ is rejected above), so the response must not carry the 2026-07-28 resultType + // field. Return a fresh EmptyResult rather than the shared EmptyResult.Instance, which is + // pre-stamped with resultType="complete" for the 2026-07-28-only subscriptions/listen path + // (issue #1721). + return new ValueTask(new EmptyResult()); }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); @@ -1707,7 +1714,11 @@ internal sealed partial class McpServerImpl : McpServer handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (result is ICacheableResult cacheable) + + // ttlMs and cacheScope are 2026-07-28 result fields; only stamp them when the request + // was negotiated under that revision or later. Earlier revisions (e.g. 2025-11-25) reject + // these as unrecognized keys (issue #1721). + if (result is ICacheableResult cacheable && IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) { cacheable.TimeToLive ??= TimeSpan.Zero; cacheable.CacheScope ??= CacheScope.Private; @@ -1723,7 +1734,12 @@ internal sealed partial class McpServerImpl : McpServer handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (result is Result protocolResult && protocolResult.ResultType is null) + + // resultType is a 2026-07-28 result field; only stamp it when the request was negotiated + // under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an + // unrecognized key (issue #1721). + if (result is Result protocolResult && protocolResult.ResultType is null && + IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) { protocolResult.ResultType = "complete"; } @@ -1750,7 +1766,12 @@ internal sealed partial class McpServerImpl : McpServer handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult) + + // resultType is a 2026-07-28 result field; only stamp it when the request was negotiated + // under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an + // unrecognized key (issue #1721). + if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult && + IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) { immediateResult.ResultType = "complete"; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index edf58e7..f5f848e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -322,6 +322,40 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); + + // resultType is a 2026-07-28 result field, and the initialize handshake only ever negotiates + // 2025-11-25 or earlier. It must not appear on the InitializeResult, or strict 2025-11-25 clients + // reject the handshake (issue #1721). + Assert.False(json["result"]!.AsObject().ContainsKey("resultType"), + "InitializeResult must not carry resultType on a 2025-11-25 handshake."); + } + + [Fact] + public async Task DownlevelToolsList_On2025_11_25_OmitsResultTypeAndCacheHints() + { + await StartAsync(); + + // A 2025-11-25 client completes the initialize handshake and then sends subsequent requests with + // the MCP-Protocol-Version header pinned to the negotiated revision. The server must not decorate + // the result with the 2026-07-28-exclusive resultType/ttlMs/cacheScope fields (issue #1721). + var initBody = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + using (var initRequest = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(initBody) }) + using (var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode); + } + + var body = @"{""jsonrpc"":""2.0"",""id"":2,""method"":""tools/list"",""params"":{}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var result = json["result"]!.AsObject(); + Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index c0f85a9..d1f8443 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -283,7 +283,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); @@ -386,7 +386,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -406,7 +406,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -438,7 +438,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); // Use reflection to verify every public property on ServerCapabilities is non-null. // This catches cases where new capability properties are added but not copied @@ -485,7 +485,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -529,7 +529,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -573,7 +573,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -623,7 +623,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -679,7 +679,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); // Custom handler values + auto-populated values should be combined Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values); Assert.Equal(3, result.Completion.Total); @@ -727,7 +727,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -766,7 +766,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -796,7 +796,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -832,7 +832,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -870,7 +870,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -900,7 +900,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.Equal("test", result.Description); }); } @@ -935,7 +935,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -971,7 +971,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.NotEmpty(result.Content); Assert.Equal("test", Assert.IsType(result.Content[0]).Text); }); @@ -1011,6 +1011,31 @@ public class McpServerTests : LoggedTest }); } + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests_WithoutHandler_OmitsResultType() + { + // With no custom SetLoggingLevelHandler configured, the server uses its default logging/setLevel + // handler. logging/setLevel is a legacy (<= 2025-11-25) method, so the default handler must also + // serialize its result as an empty object {} without the 2026-07-28 resultType field (issue #1721). + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: null, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); + }); + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_With_McpException() { @@ -1033,7 +1058,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1062,7 +1087,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1098,7 +1123,7 @@ public class McpServerTests : LoggedTest { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - Assert.Equal("complete", result.ResultType); + Assert.Null(result.ResultType); Assert.True(result.IsError, "Input validation errors should be returned as tool execution errors (IsError=true), not protocol errors"); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1359,7 +1384,7 @@ public class McpServerTests : LoggedTest Assert.NotNull(response.Result); var initResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(initResult); - Assert.Equal("complete", initResult.ResultType); + Assert.Null(initResult.ResultType); Assert.NotNull(initResult.ServerInfo); await transport.DisposeAsync(); diff --git a/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs new file mode 100644 index 0000000..db0f658 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/ProtocolVersionResultDecorationTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Regression tests for issue #1721: the SDK-provided resultType, ttlMs, and +/// cacheScope result decorations are exclusive to the 2026-07-28 protocol revision and +/// must be absent from result objects on a session negotiated to an earlier revision +/// (e.g. 2025-11-25). Emitting them breaks clients that strictly validate the 2025-11-25 +/// schema. +/// +/// +/// These drive the in-process client/server pair and inspect the +/// raw JSON-RPC result wire shape (via ) +/// so the assertions are about the actual serialized fields rather than deserialized objects. +/// tools/list exercises the SetHandler decoration path (its result is both an +/// and a ), while tools/call exercises the +/// SetWithAlternateHandler path. +/// +public class ProtocolVersionResultDecorationTests : ClientServerTestBase +{ + public ProtocolVersionResultDecorationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([McpServerTool.Create(() => "ok", new() { Name = "echo" })]); + } + + [Fact] + public async Task ToolsList_On2025_11_25Session_OmitsResultTypeAndCacheHints() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ToolsList }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 tools/list result."); + Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); + } + + [Fact] + public async Task ToolsCall_On2025_11_25Session_OmitsResultType() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Params = System.Text.Json.JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = "echo" }, McpJsonUtilities.DefaultOptions), + }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.False(result.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 tools/call result."); + } + + [Fact] + public async Task ToolsList_On2026_07_28Session_IncludesResultTypeAndCacheHints() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ToolsList }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.Equal("complete", result["resultType"]!.GetValue()); + Assert.True(result.ContainsKey("ttlMs"), "ttlMs must be present on a 2026-07-28 tools/list result."); + Assert.True(result.ContainsKey("cacheScope"), "cacheScope must be present on a 2026-07-28 tools/list result."); + } + + [Fact] + public async Task ToolsCall_On2026_07_28Session_IncludesResultType() + { + await using var client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Params = System.Text.Json.JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = "echo" }, McpJsonUtilities.DefaultOptions), + }, + TestContext.Current.CancellationToken); + + var result = response.Result!.AsObject(); + Assert.Equal("complete", result["resultType"]!.GetValue()); + } +} From 26f60ab7f448e8c008f81c4912823b331f199adb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 28 Jul 2026 00:08:03 +0100 Subject: [PATCH 20/58] Align the 2026-07-28 wire with spec PR #3002 and SEP-2575 HTTP statuses (#1752) Co-authored-by: Stephen Halter Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: tarekgh --- .../StreamableHttpHandler.cs | 146 ++++++++++++++++-- .../Client/McpClient.Methods.cs | 12 ++ .../Client/McpClient.cs | 6 +- .../Client/McpClientImpl.cs | 35 ++++- .../Client/McpClientTool.cs | 22 ++- .../Protocol/DiscoverResult.cs | 6 - .../Protocol/MetaKeys.cs | 10 ++ .../Server/McpServerImpl.cs | 96 +++++++++--- .../Server/McpServerOptions.cs | 3 +- .../Server/StreamableHttpPostTransport.cs | 111 ++++++++++++- .../Server/StreamableHttpServerTransport.cs | 33 +++- .../July2026ProtocolHttpFallbackTests.cs | 10 +- .../RawHttpConformanceTests.cs | 142 ++++++++++++++++- .../StreamableHttpClientConformanceTests.cs | 5 +- .../StreamableHttpServerConformanceTests.cs | 10 +- .../Program.cs | 21 +++ .../Program.cs | 1 + .../Tools/Sep2575DiagnosticTools.cs | 62 ++++++++ .../Client/July2026ProtocolConnectionTests.cs | 49 ++++++ .../Client/July2026ProtocolFallbackTests.cs | 6 +- .../Client/McpClientCreationTests.cs | 10 +- .../McpClientResourceSubscriptionTests.cs | 26 ++-- .../Client/MrtrIntegrationTests.cs | 10 +- .../ClientIntegrationTests.cs | 4 + ...rverBuilderExtensionsRequestFilterTests.cs | 10 +- .../Protocol/CacheableResultWarningTests.cs | 5 +- .../Protocol/DiscoverProtocolTests.cs | 3 - .../Protocol/DiscoverResultCacheableTests.cs | 5 +- .../Server/NegotiatedProtocolVersionTests.cs | 35 ++++- .../Server/RawStreamConformanceTests.cs | 10 +- ...ResourceSubscriptionProtocolGatingTests.cs | 91 +++++++++++ 31 files changed, 894 insertions(+), 101 deletions(-) create mode 100644 tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 75281aa..f0b0b1a 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -55,13 +55,6 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) - { - await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); - return; - } - // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. // ASP.NET Core Minimal APIs mostly try to stay out of the business of response content negotiation, // so we have to do this manually. The spec doesn't mandate that servers MUST reject these requests, @@ -104,6 +97,15 @@ internal sealed class StreamableHttpHandler( // Notifications carry no id, so this stays default (null), which is correct. var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default; + // Validated after the body parse (rather than first) so the rejection can echo the request's + // JSON-RPC id: every error response for a parseable request MUST carry its id. + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + { + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId); + return; + } + if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId); @@ -116,6 +118,33 @@ internal sealed class StreamableHttpHandler( return; } + if (!ValidateRequiredPerRequestMeta(context, message, out var requiredMetaError)) + { + await WriteJsonRpcErrorDetailAsync(context, requiredMetaError, StatusCodes.Status400BadRequest, requestId); + return; + } + + // SEP-2575 removed these RPCs from the per-request-metadata HTTP surface (initialize and + // ping in favor of server/discover, logging/setLevel in favor of _meta logLevel, and the + // resource subscription pair in favor of subscriptions/listen). A request for a method the + // server does not implement is rejected with 404 Not Found and Method not found. Rejecting + // here gives HTTP the required status; the server protocol boundary enforces the same method + // set for other transports. +#pragma warning disable MCP9005 // logging/setLevel is deprecated (SEP-2577); referenced here only to reject it as removed. + if (RequiresPerRequestMetadataProtocol(context) && + message is JsonRpcRequest + { + Method: RequestMethods.Initialize or RequestMethods.Ping or RequestMethods.LoggingSetLevel + or RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe, + } removedMethodRequest) +#pragma warning restore MCP9005 + { + await WriteJsonRpcErrorAsync(context, + $"Method '{removedMethodRequest.Method}' is not available on protocol version '{context.Request.Headers[McpProtocolVersionHeaderName]}'.", + StatusCodes.Status404NotFound, (int)McpErrorCode.MethodNotFound, requestId); + return; + } + var session = await GetOrCreateSessionAsync(context, message, requestId); if (session is null) { @@ -124,8 +153,33 @@ internal sealed class StreamableHttpHandler( await using var _ = await session.AcquireReferenceAsync(context.RequestAborted); + Func? onResponseStarting = null; + if (RequiresPerRequestMetadataProtocol(context)) + { + // SEP-2575 maps some JSON-RPC error codes onto HTTP statuses (404 for a method the server + // does not implement, 400 for missing-capability and unsupported-version rejections). The + // status line can only be chosen before the first response byte, so the transport defers + // its eager header flush and reports the first response message here. + onResponseStarting = firstMessage => + { + if (firstMessage is JsonRpcError { Error: { } errorDetail } && !context.Response.HasStarted) + { + context.Response.StatusCode = (McpErrorCode)errorDetail.Code switch + { + McpErrorCode.MethodNotFound => StatusCodes.Status404NotFound, + McpErrorCode.MissingRequiredClientCapability => StatusCodes.Status400BadRequest, + McpErrorCode.UnsupportedProtocolVersion => StatusCodes.Status400BadRequest, + McpErrorCode.HeaderMismatch => StatusCodes.Status400BadRequest, + _ => context.Response.StatusCode, + }; + } + + return default; + }; + } + InitializeSseResponse(context); - var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted); + var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, onResponseStarting, context.RequestAborted); if (!wroteResponse) { // We wound up writing nothing, so there should be no Content-Type response header. @@ -137,7 +191,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleGetRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -256,7 +310,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleDeleteRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -652,6 +706,39 @@ internal sealed class StreamableHttpHandler( internal static JsonTypeInfo GetRequiredJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + /// + /// Validates that a request riding a per-request-metadata protocol revision carries the required + /// _meta fields beyond the protocol version (which + /// already checked): io.modelcontextprotocol/clientCapabilities, which must be present and a + /// JSON object. Performed at the HTTP layer so the rejection can use 400 Bad Request as SEP-2575 + /// requires; a malformed (non-object) value would otherwise fail later during deserialization as a + /// generic internal error on a 200 response. clientInfo is optional, so its absence is not + /// rejected. + /// + private static bool ValidateRequiredPerRequestMeta( + HttpContext context, + JsonRpcMessage message, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + { + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + if (message is JsonRpcRequest { Params: var requestParams } && + McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && + (requestParams is not JsonObject paramsObj || + paramsObj["_meta"] is not JsonObject metaObj || + metaObj[MetaKeys.ClientCapabilities] is not JsonObject)) + { + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidParams, + Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ClientCapabilities}' as a JSON object.", + }; + return false; + } + + errorDetail = null; + return true; + } + /// /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility, /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the @@ -661,12 +748,30 @@ internal sealed class StreamableHttpHandler( private static bool ValidateProtocolVersionHeader( HttpContext context, IReadOnlyList supportedProtocolVersions, + bool stateless, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && !supportedProtocolVersions.Contains(protocolVersionHeader)) { + // On a stateless server, restrict the advertised list to the per-request-metadata + // revisions: those are what server/discover advertises, and SEP-2575 clients use this + // list to pick a retry version for server/discover, so the two must agree. Requests for + // the older initialize-handshake revisions are still accepted above; only the error + // payload for unknown versions narrows. Stateful servers keep the full configured list + // (their 2026-07-28 refusal advertises the session-supporting versions separately in + // WriteUnsupportedProtocolVersionErrorAsync). + IReadOnlyList advertisedVersions = supportedProtocolVersions; + if (stateless) + { + var metadataVersions = supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata).ToArray(); + if (metadataVersions.Length > 0) + { + advertisedVersions = metadataVersions; + } + } + errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, @@ -674,7 +779,7 @@ internal sealed class StreamableHttpHandler( Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = [.. supportedProtocolVersions], + Supported = [.. advertisedVersions], Requested = protocolVersionHeader, }, GetRequiredJsonTypeInfo()), @@ -749,8 +854,23 @@ internal sealed class StreamableHttpHandler( 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."); + // Notifications are exempt from the required-_meta rejection: they are fire-and-forget + // (a JSON-RPC error response has no recipient), and rejecting them silently drops + // client signals like notifications/cancelled, leaving server-side work running. + if (message is not JsonRpcRequest) + { + errorDetail = null; + return true; + } + + // A missing (rather than mismatched) per-request metadata field is an Invalid params + // rejection per SEP-2575; HeaderMismatch (-32020) is reserved for values that are + // present on both sides but disagree. + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidParams, + Message = $"Requests using protocol version '{protocolVersionHeader}' must include '_meta/{MetaKeys.ProtocolVersion}'.", + }; return false; } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index a96ba5a..bccdd7a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -733,6 +733,12 @@ public abstract partial class McpClient : McpSession /// For a simpler API that handles both subscription and notification registration in a single call, /// use . /// + /// + /// The 2026-07-28 protocol revision (SEP-2575) removed resources/subscribe in favor of + /// with resourceSubscriptions. On a session that + /// negotiated that revision or later, this method throws an with + /// . + /// /// public Task SubscribeToResourceAsync( SubscribeRequestParams requestParams, @@ -931,6 +937,12 @@ public abstract partial class McpClient : McpSession /// The result of the request. /// is . /// The request failed or the server returned an error response. + /// + /// The 2026-07-28 protocol revision (SEP-2575) removed resources/unsubscribe in favor of + /// with resourceSubscriptions. On a session that + /// negotiated that revision or later, this method throws an with + /// . + /// public Task UnsubscribeFromResourceAsync( UnsubscribeRequestParams requestParams, CancellationToken cancellationToken = default) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index 6107201..67bb21a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -29,14 +29,16 @@ public abstract partial class McpClient : McpSession /// /// /// This property provides identification details about the connected server, including its name and version. - /// It is populated during the initialization handshake and is available after a successful connection. + /// It is populated during the initialization handshake or from server/discover result metadata. /// /// /// This information can be useful for logging, debugging, compatibility checks, and displaying server /// information to users. /// /// - /// The client is not connected. + /// + /// The client is not connected, or the server omitted the optional server identity metadata. + /// public abstract Implementation ServerInfo { get; } /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 07a9f2e..07dc0d5 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -170,7 +170,8 @@ internal sealed partial class McpClientImpl : McpClient public override ServerCapabilities ServerCapabilities => _serverCapabilities ?? throw new InvalidOperationException("The client is not connected."); /// - public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException("The client is not connected."); + public override Implementation ServerInfo => _serverInfo ?? throw new InvalidOperationException( + "The client is not connected, or the connected server did not provide optional server identity metadata."); /// public override string? ServerInstructions => _serverInstructions; @@ -432,15 +433,19 @@ internal sealed partial class McpClientImpl : McpClient } else { + var discoveredServerInfo = GetServerInfoFromDiscover(discoverResult!); + if (_logger.IsEnabled(LogLevel.Information)) { LogServerCapabilitiesReceived(_endpointName, capabilities: JsonSerializer.Serialize(discoverResult!.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: JsonSerializer.Serialize(discoverResult.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + serverInfo: discoveredServerInfo is null + ? "(none)" + : JsonSerializer.Serialize(discoveredServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); } _serverCapabilities = discoverResult!.Capabilities; - _serverInfo = discoverResult.ServerInfo; + _serverInfo = discoveredServerInfo; _serverInstructions = discoverResult.Instructions; } @@ -484,6 +489,30 @@ internal sealed partial class McpClientImpl : McpClient LogClientConnected(_endpointName); } + /// + /// Resolves the server identity from a server/discover result. The 2026-07-28 revision carries + /// serverInfo in the result's _meta/io.modelcontextprotocol/serverInfo field rather than + /// the result body. Identity is optional, so a server that omits it yields . + /// + private static Implementation? GetServerInfoFromDiscover(DiscoverResult discoverResult) + { + if (discoverResult.Meta is { } meta && + meta.TryGetPropertyValue(MetaKeys.ServerInfo, out JsonNode? serverInfoNode)) + { + if (serverInfoNode is null) + { + throw new JsonException( + $"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation."); + } + + return JsonSerializer.Deserialize(serverInfoNode, McpJsonUtilities.JsonContext.Default.Implementation) + ?? throw new JsonException( + $"Discover result metadata '{MetaKeys.ServerInfo}' must contain a server implementation."); + } + + return null; + } + /// /// Performs the initialize handshake (initialize request + initialized notification), /// records the negotiated protocol version, and stores the server capabilities/info/instructions. diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index 6a378ca..f9d353e 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -135,7 +135,7 @@ public sealed class McpClientTool : AIFunction // would lose that information. So, we only do the translation if there is no additional information to preserve. if (result.IsError is not true && result.StructuredContent is null && - result.Meta is not { Count: > 0 }) + !HasApplicationResultMetadata(result.Meta)) { switch (result.Content.Count) { @@ -150,6 +150,26 @@ public sealed class McpClientTool : AIFunction return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult); } + private static bool HasApplicationResultMetadata(JsonObject? meta) + { + if (meta is null) + { + return false; + } + + foreach (var property in meta) + { + // Server identity is protocol metadata already exposed through McpClient.ServerInfo. + // It should not force an otherwise simple tool result into its JSON envelope. + if (!string.Equals(property.Key, MetaKeys.ServerInfo, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + /// /// Invokes the tool on the server. /// diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index 6ab6ced..b178a3a 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -31,12 +31,6 @@ public sealed class DiscoverResult : Result, ICacheableResult [JsonPropertyName("capabilities")] public required ServerCapabilities Capabilities { get; set; } - /// - /// Gets or sets information about the server implementation. - /// - [JsonPropertyName("serverInfo")] - public required Implementation ServerInfo { get; set; } - /// /// Gets or sets optional instructions describing how to use the server and its features. /// diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs index 29db6f5..9605bab 100644 --- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -24,6 +24,16 @@ public static class MetaKeys /// public const string ClientInfo = "io.modelcontextprotocol/clientInfo"; + /// + /// The metadata key used to identify the server software in a response's _meta field. + /// + /// + /// Introduced by the 2026-07-28 protocol revision. Servers SHOULD identify themselves by + /// carrying an under this key in every result's + /// _meta. + /// + public const string ServerInfo = "io.modelcontextprotocol/serverInfo"; + /// /// The metadata key used to declare client capabilities in a request's _meta field. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 0827ceb..f68aacd 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -146,10 +146,12 @@ internal sealed partial class McpServerImpl : McpServer ServerCapabilities.Resources.ListChanged = null; } - // And initialize the session. The built-in meta-reading filter runs ahead of any - // user-supplied incoming filters; see PrependMetaReadingFilter for what it records and why. + // And initialize the session. The built-in protocol metadata filters run ahead of any + // user-supplied message filters. var incomingMessageFilter = PrependMetaReadingFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters)); - var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); + var outgoingMessageFilter = PrependServerInfoFilter( + BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters), + options.ServerInfo ?? DefaultImplementation); _sessionHandler = new McpSessionHandler( isServer: true, @@ -169,12 +171,13 @@ internal sealed partial class McpServerImpl : McpServer /// 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. 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). + /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so the protocol + /// version and client capabilities MUST be populated per-request. Client info is optional. 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) { @@ -204,9 +207,14 @@ internal sealed partial class McpServerImpl : McpServer // with an UnsupportedProtocolVersionError (-32022) carrying the supported list. if (!_supportedProtocolVersions.Contains(protocolVersion)) { + var supportedVersions = + hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ? + _perRequestMetadataProtocolVersions : + _supportedProtocolVersions; + throw new UnsupportedProtocolVersionException( requested: protocolVersion, - supported: _supportedProtocolVersions); + supported: supportedVersions); } if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) @@ -214,7 +222,6 @@ internal sealed partial class McpServerImpl : McpServer ValidateRequiredPerRequestMetadata( protocolVersion, hasProtocolVersionMeta, - context.ClientInfo is not null, context.ClientCapabilities is not null); } else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) @@ -293,7 +300,6 @@ internal sealed partial class McpServerImpl : McpServer private static void ValidateRequiredPerRequestMetadata( string protocolVersion, bool hasProtocolVersionMeta, - bool hasClientInfoMeta, bool hasClientCapabilitiesMeta) { if (!hasProtocolVersionMeta) @@ -301,10 +307,7 @@ internal sealed partial class McpServerImpl : McpServer ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); } - if (!hasClientInfoMeta) - { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo); - } + // clientInfo is optional: requests whose _meta omits it are served, not rejected. if (!hasClientCapabilitiesMeta) { @@ -344,8 +347,46 @@ internal sealed partial class McpServerImpl : McpServer paramsObj["_meta"] is JsonObject metaObj && metaObj.ContainsKey(key); + /// + /// Adds the server identity to every successful result on per-request-metadata protocol revisions. + /// The filter runs before application filters so they can inspect or intentionally remove the metadata. + /// + private JsonRpcMessageFilter PrependServerInfoFilter(JsonRpcMessageFilter inner, Implementation serverInfo) + { + JsonRpcMessageFilter serverInfoFilter = next => async (message, cancellationToken) => + { + if (message is JsonRpcResponse { Result: JsonObject result } && + McpProtocolVersions.RequiresPerRequestMetadata( + message.Context?.ProtocolVersion ?? _negotiatedProtocolVersion)) + { + if (result["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + result["_meta"] = meta; + } + + meta[MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode( + serverInfo, + McpJsonUtilities.JsonContext.Default.Implementation); + } + + await next(message, cancellationToken).ConfigureAwait(false); + }; + + return next => serverInfoFilter(inner(next)); + } + private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { + // Per-request-metadata revisions (SEP-2575) removed the initialize handshake entirely: + // the request is for a method the server does not implement on that revision. + if (McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion)) + { + throw new McpProtocolException( + $"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", + McpErrorCode.MethodNotFound); + } + if (request.Context?.ProtocolVersion is { } protocolVersion && !McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) { @@ -415,14 +456,32 @@ internal sealed partial class McpServerImpl : McpServer McpErrorCode.MethodNotFound); } - if (usesPerRequestMetadata && request.Method == RequestMethods.LoggingSetLevel) + if (usesPerRequestMetadata && + request.Method is RequestMethods.Ping or RequestMethods.LoggingSetLevel + or RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe) { + var replacement = GetRemovedMethodReplacementHint(request.Method); throw new McpProtocolException( - $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + $"The method '{request.Method}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'." + + (replacement is null ? "" : $" {replacement}"), McpErrorCode.MethodNotFound); } } + /// + /// Returns guidance on the per-request-metadata replacement for a method that SEP-2575 removed, + /// or when the method has no direct replacement. Surfaced in the + /// error so a client that still calls the legacy RPC + /// (for example resources/subscribe) learns how to migrate. + /// + private static string? GetRemovedMethodReplacementHint(string method) => method switch + { + RequestMethods.LoggingSetLevel => $"Use the per-request '_meta/{MetaKeys.LogLevel}' field instead.", + RequestMethods.ResourcesSubscribe or RequestMethods.ResourcesUnsubscribe => + $"Use '{RequestMethods.SubscriptionsListen}' with 'resourceSubscriptions' instead.", + _ => null, + }; + /// public override string? SessionId => _sessionTransport.SessionId; @@ -636,7 +695,6 @@ internal sealed partial class McpServerImpl : McpServer { SupportedVersions = [.. _perRequestMetadataProtocolVersions], Capabilities = ServerCapabilities ?? new(), - ServerInfo = options.ServerInfo ?? DefaultImplementation, Instructions = options.ServerInstructions, // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to // the safest values (immediately stale, not shareable) so existing servers keep diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index f3d9b5f..2a26868 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -14,7 +14,8 @@ 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 or discovery to identify the server. + /// This information is sent in the initialization result on handshake-based protocol revisions and in + /// every successful result's metadata on per-request-metadata revisions. /// It's displayed in client logs and can be used for debugging and compatibility checks. /// public Implementation? ServerInfo { get; set; } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index ed8c329..95411f7 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -15,7 +15,8 @@ internal sealed partial class StreamableHttpPostTransport( StreamableHttpServerTransport parentTransport, Stream responseStream, CancellationToken sessionCancellationToken, - ILogger logger) : ITransport + ILogger logger, + Func? onResponseStarting = null) : ITransport { private readonly SemaphoreSlim _messageLock = new(1, 1); private readonly TaskCompletionSource _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -29,6 +30,7 @@ internal sealed partial class StreamableHttpPostTransport( private RequestId _pendingRequest; private bool _finalResponseMessageSent; private bool _httpResponseCompleted; + private bool _httpResponseStarted; public ChannelReader MessageReader => throw new NotSupportedException("JsonRpcMessage.Context.RelatedTransport should only be used for sending messages."); @@ -50,8 +52,13 @@ internal sealed partial class StreamableHttpPostTransport( _pendingRequest = request.Id; message.Context.RelatedTransport = this; - // Invoke the initialize request handler if applicable. - if (request.Method == RequestMethods.Initialize) + // Invoke the initialize request handler if applicable. On a per-request-metadata + // protocol revision (2026-07-28+) initialize is a removed method: skip the eager + // params deserialization (whose required properties would throw on an arbitrary + // payload) and let the session's protocol boundary reject the request with + // Method not found. + if (request.Method == RequestMethods.Initialize && + !McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion)) { var initializeRequest = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.JsonContext.Default.InitializeRequestParams); await parentTransport.HandleInitializeRequestAsync(initializeRequest).ConfigureAwait(false); @@ -69,32 +76,117 @@ internal sealed partial class StreamableHttpPostTransport( return false; } + CancellationTokenSource? deferredFlushCts = null; + Task? deferredFlushTask = null; + bool deferHeaderFlush = false; using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false)) { var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false); if (primingItem.HasValue) { + await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false); } - else + else if (onResponseStarting is null) { // If there's no priming write, flush the stream to ensure HTTP response headers are // sent to the client now that the server is ready to process the request. // This prevents HttpClient timeout for long-running requests. await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } + else + { + deferHeaderFlush = true; + } // Ensure that we've sent the priming event before processing the incoming request. await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false); } - // Wait for the response to be written before returning from the handler. - // This keeps the HTTP response open until the final response message is sent. - await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + if (deferHeaderFlush) + { + // Defer the flush (and the header commit it implies) so the callback can still choose + // the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period + // only after the request has been queued for dispatch. + deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token); + } + + try + { + // Wait for the response to be written before returning from the handler. + // This keeps the HTTP response open until the final response message is sent. + await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + if (deferredFlushCts is not null) + { + deferredFlushCts.Cancel(); + await deferredFlushTask!.ConfigureAwait(false); + deferredFlushCts.Dispose(); + } + } return true; } + /// + /// Bounds the deferred header flush: after a short grace window, flushes the response headers + /// if no response message has been written yet. Immediate rejections land well inside the + /// window, so the response-starting callback can still map their JSON-RPC error codes onto the + /// HTTP status line; a handler that runs longer commits the headers here so clients see them + /// promptly (long-running tool calls must not trip HttpClient's response timeout). + /// + private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false); + using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false); + if (!_httpResponseStarted && !_httpResponseCompleted) + { + await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); + await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The response was written or the request ended before the grace window elapsed. + } + catch (Exception ex) + { + // Surface the failure to the awaiting HandlePostAsync when possible. If the response + // future has already been resolved (the response started or completed on another path), + // TrySetException is a no-op, so log here to keep the deferred-flush failure diagnosable. + if (!_httpResponseTcs.TrySetException(ex)) + { + LogDeferredHeaderFlushFailed(ex); + } + } + } + + /// How long the response-header flush may be deferred waiting for the first response message. + internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250); + + /// + /// Invokes the response-starting callback exactly once, immediately before the first write to + /// the HTTP response stream, so the HTTP application can still set the response status line. + /// + private async ValueTask NotifyResponseStartingAsync(JsonRpcMessage? firstMessage) + { + if (_httpResponseStarted) + { + return; + } + + _httpResponseStarted = true; + if (onResponseStarting is not null) + { + await onResponseStarting(firstMessage).ConfigureAwait(false); + } + } + public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { Throw.IfNull(message); @@ -130,6 +222,7 @@ internal sealed partial class StreamableHttpPostTransport( try { + await NotifyResponseStartingAsync(message).ConfigureAwait(false); await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) @@ -173,6 +266,7 @@ internal sealed partial class StreamableHttpPostTransport( // Write to the response stream if it still exists. if (!_httpResponseCompleted) { + await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false); } @@ -248,4 +342,7 @@ internal sealed partial class StreamableHttpPostTransport( [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")] private partial void LogStoreStreamDisposalFailed(Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")] + private partial void LogDeferredHeaderFlushFailed(Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 131c836..f143eaa 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -125,7 +125,7 @@ public sealed partial class StreamableHttpServerTransport : ITransport /// to the SSE response stream until cancellation is requested or the transport is disposed. /// /// The response stream to write MCP JSON-RPC messages as SSE events to. - /// The to monitor for cancellation requests. The default is . + /// The to monitor for cancellation requests. /// A task representing the send loop that writes JSON-RPC messages to the SSE response stream. /// is . /// @@ -207,12 +207,39 @@ public sealed partial class StreamableHttpServerTransport : ITransport /// If an authenticated sent the message, that can be included in the . /// No other part of the context should be set. /// - public async Task HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, CancellationToken cancellationToken = default) + public Task HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, CancellationToken cancellationToken = default) + => HandlePostRequestAsync(message, responseStream, onResponseStarting: null, cancellationToken); + + /// + /// Handles a Streamable HTTP POST request, processing the JSON-RPC message and writing any + /// JSON-RPC responses to the response stream. + /// This overload additionally reports the first JSON-RPC message written to the response via + /// , before any response bytes are written, so the HTTP + /// application can still choose the response status line (SEP-2575 maps some JSON-RPC error + /// codes to HTTP statuses). When is provided, the eager + /// response-header flush that normally precedes request processing is deferred until that first + /// message; the callback receives when the first write is not a JSON-RPC + /// message (e.g. a resumability priming event). + /// The status line can only be influenced by the FIRST write: when a handler streams a + /// notification (e.g. progress) before failing, or runs past the transport's bounded + /// header-flush grace window, the status is already committed and a later JSON-RPC error + /// rides the committed status. + /// + /// The JSON-RPC message to process. + /// The response stream to write any JSON-RPC responses to. + /// Callback invoked once, immediately before the first write to . + /// The to monitor for cancellation requests. The default is . + /// + /// if data was written to the response body. + /// if nothing was written because the request body did not contain any messages to respond to. + /// + /// or is . + public async Task HandlePostRequestAsync(JsonRpcMessage message, Stream responseStream, Func? onResponseStarting, CancellationToken cancellationToken) { Throw.IfNull(message); Throw.IfNull(responseStream); - var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger); + var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger, onResponseStarting); using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_transportDisposedCts.Token, cancellationToken); await using (postTransport.ConfigureAwait(false)) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 12bae02..599bdd9 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -324,7 +324,10 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : { SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), @@ -394,7 +397,10 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : { SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, }, McpJsonUtilities.DefaultOptions), diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index f5f848e..8520f92 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -32,7 +32,8 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() - .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); + .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]) + .WithTools(); _app = Builder.Build(); _app.MapMcp(); @@ -106,6 +107,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); + Assert.Equal( + nameof(RawHttpConformanceTests), + json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); // Per SEP-2567, starting with the 2026-07-28 protocol revision Streamable HTTP no longer // supports sessions: the server MUST NOT issue a Mcp-Session-Id. @@ -134,6 +138,73 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind()); Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue()); Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); + Assert.Null(json["result"]!["serverInfo"]); + Assert.Equal( + nameof(RawHttpConformanceTests), + json["result"]!["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_UnknownMethod_Returns404_WithMethodNotFound() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":17,""method"":""unknown/method"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "unknown/method"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(17, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue()); + } + + [Theory] + [InlineData("initialize")] + [InlineData("ping")] + [InlineData("logging/setLevel")] + [InlineData("resources/subscribe")] + [InlineData("resources/unsubscribe")] + public async Task July2026Post_RemovedMethod_Returns404_WithMethodNotFound(string method) + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":18,""method"":""" + method + + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", method); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(18, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MethodNotFound, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_MissingRequiredCapability_Returns400() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":19,""method"":""tools/call"",""params"":{""name"":""requires_sampling"",""arguments"":{}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "requires_sampling"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(19, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); } [Fact] @@ -179,7 +250,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] @@ -258,7 +329,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn } [Fact] - public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020() + public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602() { await StartAsync(); @@ -269,9 +340,12 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + // A missing (rather than mismatched) _meta protocol version is an Invalid params rejection + // per SEP-2575; -32020 HeaderMismatch is reserved for values present on both sides that + // disagree. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } @@ -372,4 +446,64 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } + + [Fact] + public async Task July2026Post_MissingClientCapabilities_Returns400_WithInvalidParams() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":20,""method"":""server/discover"",""params"":{""_meta"":{" + + @"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(20, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("5")] + [InlineData("\"caps\"")] + [InlineData("[]")] + [InlineData("true")] + public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvalidParams(string clientCapabilitiesJson) + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":21,""method"":""server/discover"",""params"":{""_meta"":{" + + @"""io.modelcontextprotocol/protocolVersion"":""" + McpProtocolVersions.July2026ProtocolVersion + @"""," + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":" + clientCapabilitiesJson + "}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // A present-but-wrong-shape clientCapabilities must be rejected up front with -32602 / 400, + // not surface later as a generic internal error on a 200 response. + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(21, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.InvalidParams, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ClientCapabilities, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [McpServerToolType] + private sealed class CapabilityTools + { + [McpServerTool(Name = "requires_sampling")] + public static string RequiresSampling() => + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 366053f..649286b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -766,7 +766,10 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper ["io.modelcontextprotocol/tasks"] = new JsonObject(), }, }, - ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "header-capture", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, ResultType = "complete", diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 3422405..dd051e4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -1158,8 +1158,14 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse) { var discoverResult = AssertType(rpcResponse.Result); - Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name); - Assert.Equal("73", discoverResult.ServerInfo.Version); + + // Server identity is carried in the result _meta, not the discover body. + var serverInfoNode = discoverResult.Meta?[MetaKeys.ServerInfo]; + Assert.NotNull(serverInfoNode); + var serverInfo = JsonSerializer.Deserialize(serverInfoNode, McpJsonUtilities.DefaultOptions); + Assert.NotNull(serverInfo); + Assert.Equal(nameof(StreamableHttpServerConformanceTests), serverInfo.Name); + Assert.Equal("73", serverInfo.Version); return discoverResult; } diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index f9beea9..82d720a 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -321,6 +321,27 @@ try } break; } + case "json-schema-ref-no-deref": + { + // SEP-2106: listing tools must not dereference network $refs in a tool's + // inputSchema — the scenario's canary endpoint observes any such fetch. + await mcpClient.ListToolsAsync(); + break; + } + case "sep-2322-client-request-state": + { + // SEP-2322 (MRTR): drive the client's input-required auto-loop. The mock + // inspects the raw tools/call params: requestState echoed byte-exact (and + // omitted when the server sent none), a fresh JSON-RPC id per retry, no + // MRTR params bleeding into the unrelated call, and a missing resultType + // parsing as a terminal (complete) result. + await mcpClient.ListToolsAsync(); + await mcpClient.CallToolAsync(toolName: "test_mrtr_echo_state", arguments: new Dictionary()); + await mcpClient.CallToolAsync(toolName: "test_mrtr_unrelated", arguments: new Dictionary()); + await mcpClient.CallToolAsync(toolName: "test_mrtr_no_state", arguments: new Dictionary()); + await mcpClient.CallToolAsync(toolName: "test_mrtr_no_result_type", arguments: new Dictionary()); + break; + } default: // No extra processing for other scenarios break; diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 4c6c54f..73f6382 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -73,6 +73,7 @@ public class Program .WithTools() .WithTools() .WithTools() + .WithTools() .WithTools([ConformanceTools.CreateJsonSchema202012Tool()]) .WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) => { diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs new file mode 100644 index 0000000..b95ecdd --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/Sep2575DiagnosticTools.cs @@ -0,0 +1,62 @@ +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ConformanceServer.Tools; + +/// +/// Diagnostic tools exercised by the SEP-2575 server-stateless conformance scenario. Each +/// tool exists so the harness can observe a framework behavior (capability enforcement, response +/// stream discipline, per-request log gating) that plain application tools never trigger. +/// +[McpServerToolType] +public class Sep2575DiagnosticTools +{ + /// + /// Requires the client to have declared the sampling capability in the per-request + /// _meta/io.modelcontextprotocol/clientCapabilities. Used to verify the server rejects + /// undeclared-capability calls with MissingRequiredClientCapabilityError (-32021). + /// + [McpServerTool(Name = "test_missing_capability")] + [Description("Requires the sampling client capability; used to verify MissingRequiredClientCapabilityError (-32021) (SEP-2575)")] + public static string MissingCapability(RequestContext context) + { + if (context.Server.ClientCapabilities?.Sampling is not null) + { + return "Client declared the sampling capability; tool executed."; + } + + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } + + /// + /// Returns a plain result. Per SEP-2575 the response stream must carry no independent top-level + /// JSON-RPC requests; a plain response trivially satisfies this. The scenario declares no + /// elicitation capability, so this tool must not initiate an elicitation. + /// + [McpServerTool(Name = "test_streaming_elicitation")] + [Description("Streams only result frames; used to verify response streams carry no independent JSON-RPC requests (SEP-2575)")] + public static string StreamingElicitation() + { + return "stream observed: result frames only, no top-level requests"; + } + + /// + /// Attempts to emit a log message through the client-logger pipeline, which gates on the + /// per-request _meta/io.modelcontextprotocol/logLevel. When the client did not opt in, + /// no notifications/message may be sent for the request. + /// + [McpServerTool(Name = "test_logging_tool")] + [Description("Attempts to emit a log message; the framework must drop it when the client did not set _meta.../logLevel (SEP-2575)")] + public static string LoggingTool(RequestContext context) + { +#pragma warning disable MCP9004 // AsClientLoggerProvider is deprecated with the legacy logging/setLevel flow but remains the gated client-log pipeline. + ILogger logger = context.Server.AsClientLoggerProvider().CreateLogger(nameof(Sep2575DiagnosticTools)); +#pragma warning restore MCP9004 + logger.LogInformation("test_logging_tool executed"); + return "Log attempted; framework gates on _meta.../logLevel."; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index e5ae047..e681757 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -52,6 +53,39 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } + [Fact] + public async Task Client_AllowsDiscoverResultWithoutServerInfo() + { + ConfigureDiscoverServerInfo(meta => meta.Remove(MetaKeys.ServerInfo)); + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; + await using var client = await CreateMcpClientForServer(options); + + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Throws(() => _ = client.ServerInfo); + } + + [Fact] + public async Task Client_RejectsMalformedDiscoverServerInfo() + { + ConfigureDiscoverServerInfo(meta => meta[MetaKeys.ServerInfo] = "invalid"); + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; + await Assert.ThrowsAsync(() => CreateMcpClientForServer(options)); + } + + [Fact] + public async Task Client_RejectsNullDiscoverServerInfo() + { + ConfigureDiscoverServerInfo(meta => meta[MetaKeys.ServerInfo] = null); + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; + await Assert.ThrowsAsync(() => CreateMcpClientForServer(options)); + } + [Fact] public async Task InitializeHandshakeClient_CannotCallServerDiscover() { @@ -86,4 +120,19 @@ public class July2026ProtocolConnectionTests : ClientServerTestBase Assert.Equal("complete", discoverResult.ResultType); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions); } + + private void ConfigureDiscoverServerInfo(Action configure) + { + McpServerBuilder.WithMessageFilters(filters => filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse { Result: JsonObject result } && + result["supportedVersions"] is not null && + result["_meta"] is JsonObject meta) + { + configure(meta); + } + + await next(context, cancellationToken); + })); + } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index 03f8f5b..2cccd35 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.Diagnostics; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Channels; namespace ModelContextProtocol.Tests.Client; @@ -351,7 +352,10 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : { SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }); } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 48f4e66..c6f8016 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.IO.Pipelines; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Channels; namespace ModelContextProtocol.Tests.Client; @@ -179,10 +180,13 @@ public class McpClientCreationTests(ITestOutputHelper testOutputHelper) : Logged { Capabilities = new ServerCapabilities(), SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], - ServerInfo = new Implementation + Meta = new JsonObject { - Name = "NopTransport", - Version = "1.0.0" + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation + { + Name = "NopTransport", + Version = "1.0.0" + }, McpJsonUtilities.DefaultOptions), }, }, McpJsonUtilities.DefaultOptions), }); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs index 2ee3cec..82a3bd9 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceSubscriptionTests.cs @@ -19,6 +19,12 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase mcpServerBuilder.WithResources(); } + private Task CreateLegacyMcpClientForServer() => + CreateMcpClientForServer(new() + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + [McpServerResourceType] private sealed class SubscribableResources { @@ -30,7 +36,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_ReceivesNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var notificationReceived = new TaskCompletionSource(); @@ -61,7 +67,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_FiltersNotificationsByUri() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string subscribedUri = "test://resource/1"; const string otherUri = "test://resource/2"; var notificationCount = 0; @@ -107,7 +113,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_DisposalUnsubscribes() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var notificationCount = 0; @@ -148,7 +154,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithHandler_UriOverload_ReceivesNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); var resourceUri = new Uri("test://resource/1"); var notificationReceived = new TaskCompletionSource(); @@ -179,7 +185,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithNullHandler_ThrowsArgumentNullException() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -193,7 +199,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithNullUri_ThrowsArgumentNullException() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -207,7 +213,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_WithEmptyUri_ThrowsArgumentException() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); // Act & Assert await Assert.ThrowsAsync(async () => @@ -221,7 +227,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_MultipleSubscriptions_BothReceiveNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string uri1 = "test://resource/1"; const string uri2 = "test://resource/2"; var notification1Received = new TaskCompletionSource(); @@ -278,7 +284,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_DisposalIsIdempotent() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var subscription = await client.SubscribeToResourceAsync( @@ -299,7 +305,7 @@ public class McpClientResourceSubscriptionTests : ClientServerTestBase public async Task SubscribeToResourceAsync_MultipleHandlersSameUri_BothReceiveNotifications() { // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateLegacyMcpClientForServer(); const string resourceUri = "test://resource/1"; var handler1Called = new TaskCompletionSource(); var handler2Called = new TaskCompletionSource(); diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 70a9d11..86a107f 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -195,7 +195,10 @@ public class MrtrIntegrationTests : ClientServerTestBase { SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MockMrtrServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }; await WriteJsonRpcAsync(serverWriter, discoverResponse); @@ -445,7 +448,10 @@ public class MrtrIntegrationTests : ClientServerTestBase { SupportedVersions = ["2026-07-28"], Capabilities = new ServerCapabilities { Tools = new() }, - ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MrtrServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }; await WriteJsonRpcAsync(serverWriter, discoverResponse); diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 9c63f12..d01f40a 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -277,6 +277,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture tcs = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -307,6 +308,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -727,6 +729,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture tcs = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = @@ -756,6 +759,7 @@ public partial class ClientIntegrationTests : LoggedTest, IClassFixture receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index d3de3a2..3f6a4a3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -256,7 +256,10 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddSubscribeToResourcesFilter_Logs_When_SubscribeToResources_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new() + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); await client.SubscribeToResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken); @@ -268,7 +271,10 @@ public class McpServerBuilderExtensionsRequestFilterTests(ITestOutputHelper test [Fact] public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromResources_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new() + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); await client.UnsubscribeFromResourceAsync("test://resource/123", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs index 4a5873d..20e55a7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -292,7 +292,10 @@ public class CacheableResultWarningTests : LoggedTest { SupportedVersions = [serverProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" }, + Meta = new JsonObject + { + [MetaKeys.ServerInfo] = JsonSerializer.SerializeToNode(new Implementation { Name = "MockServer", Version = "1.0" }, McpJsonUtilities.DefaultOptions), + }, }, McpJsonUtilities.DefaultOptions), }, cancellationToken); } diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs index 71afd89..b225dd6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -44,7 +44,6 @@ public static class DiscoverProtocolTests { Tools = new ToolsCapability { ListChanged = true }, }, - ServerInfo = new Implementation { Name = "test-server", Version = "2.0" }, Instructions = "Use this server for testing.", }; @@ -55,7 +54,6 @@ public static class DiscoverProtocolTests Assert.Equal(["2025-11-25", "2026-07-28"], deserialized.SupportedVersions); Assert.NotNull(deserialized.Capabilities.Tools); Assert.True(deserialized.Capabilities.Tools.ListChanged); - Assert.Equal("test-server", deserialized.ServerInfo.Name); Assert.Equal("Use this server for testing.", deserialized.Instructions); } @@ -66,7 +64,6 @@ public static class DiscoverProtocolTests { SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" }, }; var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs index 4a2d7e6..0670f61 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Tests.Protocol; /// Targeted tests for the SEP-2549 caching hints (ttlMs and cacheScope) on /// . Spec PR #2855 promotes both fields to required on the discover /// response. has required CLR properties for -/// , , and -/// , which prevents reuse of the parameterized +/// and , +/// which prevents reuse of the parameterized /// helper (it instantiates via reflection). This file covers the /// same property-shape assertions for . /// @@ -19,7 +19,6 @@ public static class DiscoverResultCacheableTests { SupportedVersions = [McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, }; [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index d3e6f5e..d8cadeb 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -82,7 +82,22 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task PerRequestMetadata_RejectsRequestMissingRequiredMetadata() + public async Task PerRequestMetadata_ServesRequestMissingClientInfo() + { + var ct = TestContext.Current.CancellationToken; + + // clientInfo is optional: a request whose _meta omits it is served. + var response = await RoundTripAsync( + id: 1, + McpProtocolVersions.July2026ProtocolVersion, + ct, + includeClientInfo: false); + + Assert.IsType(response); + } + + [Fact] + public async Task PerRequestMetadata_RejectsRequestMissingClientCapabilities() { var ct = TestContext.Current.CancellationToken; @@ -91,10 +106,10 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl id: 1, McpProtocolVersions.July2026ProtocolVersion, ct, - includeClientInfo: false)); + includeClientCapabilities: false)); Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); - Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); } [Fact] @@ -176,25 +191,29 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl Assert.Contains(RequestMethods.SubscriptionsListen, error.Error.Message, StringComparison.Ordinal); } - [Fact] - public async Task LoggingSetLevel_WithPerRequestMetadataProtocolVersion_IsRejected() + [Theory] + [InlineData("initialize")] + [InlineData("ping")] + [InlineData("logging/setLevel")] + [InlineData("resources/subscribe")] + [InlineData("resources/unsubscribe")] + public async Task RemovedMethod_WithPerRequestMetadataProtocolVersion_IsRejected(string method) { var ct = TestContext.Current.CancellationToken; var request = new JsonRpcRequest { Id = new RequestId(1), - Method = RequestMethods.LoggingSetLevel, + Method = method, 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); + Assert.Contains(method, error.Error.Message, StringComparison.Ordinal); } private async Task RoundTripAsync( diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 5ece0f6..c068d22 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -98,10 +98,10 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable .ToList(); Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions); - // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. + // Capabilities are mandatory in DiscoverResult; server identity is result metadata. Assert.NotNull(result["capabilities"]); - Assert.NotNull(result["serverInfo"]); - Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue()); + Assert.Null(result["serverInfo"]); + Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. @@ -126,6 +126,7 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var content = result!["content"]!.AsArray(); Assert.Single(content); Assert.Equal("echo:hello", content[0]!["text"]!.GetValue()); + Assert.Equal("raw-conformance-server", result["_meta"]![MetaKeys.ServerInfo]!["name"]!.GetValue()); } [Fact] @@ -146,7 +147,7 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); } [Fact] @@ -161,6 +162,7 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var result = response["result"]; Assert.NotNull(result); Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue()); + Assert.Null(result["_meta"]?[MetaKeys.ServerInfo]); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs new file mode 100644 index 0000000..6024e29 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/ResourceSubscriptionProtocolGatingTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the legacy resources/subscribe and resources/unsubscribe RPCs are +/// gated by protocol version. SEP-2575 (the 2026-07-28 revision) removes them in favor of +/// subscriptions/listen with resourceSubscriptions; servers must respond with +/// -32601 MethodNotFound. Initialize-handshake protocol versions still support the legacy +/// RPCs per the spec. +/// +public sealed class ResourceSubscriptionProtocolGatingTests : ClientServerTestBase +{ + public ResourceSubscriptionProtocolGatingTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithResources(); + } + + [McpServerResourceType] + private sealed class SubscribableResources + { + [McpServerResource(UriTemplate = "test://resource/{id}"), Description("A subscribable test resource")] + public static string GetResource(string id) => $"Resource content: {id}"; + } + + [Fact] + public async Task Subscribe_OnJuly2026ProtocolSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.SubscribeToResourceAsync( + new SubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + // The rejection must point callers at the SEP-2575 replacement. + Assert.Contains(RequestMethods.SubscriptionsListen, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Unsubscribe_OnJuly2026ProtocolSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.UnsubscribeFromResourceAsync( + new UnsubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + Assert.Contains(RequestMethods.SubscriptionsListen, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Subscribe_OnInitializeHandshakeSession_StillSucceeds() + { + // Default server config; client pinned to 2025-11-25. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + // Should complete without throwing on the initialize-handshake revision. + await client.SubscribeToResourceAsync( + new SubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken); + + await client.UnsubscribeFromResourceAsync( + new UnsubscribeRequestParams { Uri = "test://resource/1" }, + TestContext.Current.CancellationToken); + } +} From be55536a912a411388f2439f371a10314f82b47a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:41:46 -0700 Subject: [PATCH 21/58] Bump JsonSchema.Net and System.Linq.AsyncEnumerable (#1749) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3819d8c..63a80f8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,7 @@ true 8.0.22 9.0.11 - 10.0.7 + 10.0.10 10.5.2 @@ -93,6 +93,6 @@ - + From c7e4b820fef0d644d9d730bd92e060e8c5b2c57a Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Mon, 27 Jul 2026 16:47:05 -0700 Subject: [PATCH 22/58] Reauthorize tools changed by call-tool filters (#1737) Copilot-Session: 5feb0906-73fc-4e1a-8cf2-c9a99c588487 --- .../AuthorizationFilterSetup.cs | 17 ++++ .../HttpMcpServerBuilderExtensions.cs | 3 + .../Server/McpTasksBuilderExtensions.cs | 8 -- .../AuthorizeAttributeTests.cs | 85 +++++++++++++++++++ .../HttpTaskIntegrationTests.cs | 79 +++++++++++++++-- 5 files changed, 177 insertions(+), 15 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs index 5b371d9..6290dd5 100644 --- a/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs @@ -20,6 +20,7 @@ internal sealed class AuthorizationFilterSetup( public void Configure(McpServerOptions options) { ConfigureListToolsFilter(options); + ConfigureOrdinaryCallToolFilter(options); ConfigureListResourcesFilter(options); ConfigureListResourceTemplatesFilter(options); @@ -85,6 +86,22 @@ internal sealed class AuthorizationFilterSetup( }); } + private void ConfigureOrdinaryCallToolFilter(McpServerOptions options) + { + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); + if (!authResult.Succeeded) + { + throw new McpProtocolException("Access forbidden: This tool requires authorization.", McpErrorCode.InvalidRequest); + } + + context.Items[AuthorizationFilterInvokedKey] = true; + + return await next(context, cancellationToken); + }); + } + private void ConfigureCallToolFilter(McpServerOptions options) { #pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch. diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index 801da5f..a522683 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -58,6 +58,9 @@ public static class HttpMcpServerBuilderExtensions /// authorization attributes such as /// and . Tool authorization runs in the alternate-result pipeline before /// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task. + /// Each call to this method also adds an ordinary call-tool authorization checkpoint at that point in the filter + /// pipeline. Call this method again after any call-tool filter that changes the matched tool or user to authorize + /// the replacement using the updated context. /// public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) { diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index cbdf73c..e61466a 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -23,7 +23,6 @@ public static class McpTasksBuilderExtensions /// Tasks are implemented as an alternate-result call-tool filter. Alternate-result filters registered before /// the Tasks filter run before task creation. Filters registered after it, along with all ordinary call-tool /// filters, run in the background before the tool. - /// Register Tasks before configuring ordinary call-tool filters. /// /// The server builder. /// The task store. @@ -120,13 +119,6 @@ public static class McpTasksBuilderExtensions Handler = HandleCancelTask, }); - if (options.Filters.Request.CallToolFilters.Count > 0) - { - throw new InvalidOperationException( - $"{nameof(WithTasks)} must be configured before ordinary call-tool filters because " + - "the Tasks filter must execute outside the ordinary call-tool pipeline."); - } - // 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. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 76a7201..6ad643e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -93,6 +93,91 @@ public class AuthorizeAttributeTests(ITestOutputHelper testOutputHelper) : Kestr Assert.Equal("Authorized: test", content.Text); } + [Fact] + public async Task Authorize_Tool_ReauthorizesPrimitiveChangedByInterveningFilter() + { + await using var app = await StartServerWithAuth(builder => + { + builder.WithTools(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool)) + { + throw new InvalidOperationException("The replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + }); + + var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "anonymous_tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + } + + [Fact] + public async Task Authorize_Tool_ReauthorizesAfterEachInterveningFilter() + { + await using var app = await StartServerWithAuth(builder => + { + builder.WithTools(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized_tool", out var authorizedTool)) + { + throw new InvalidOperationException("The first replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + builder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("admin_tool", out var adminTool)) + { + throw new InvalidOperationException("The second replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = adminTool; + return await next(context, cancellationToken); + }); + }); + builder.AddAuthorizationFilters(); + }, "TestUser"); + + var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + "anonymous_tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Request failed (remote): Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode); + } + [Fact] public async Task AuthorizeWithRoles_Tool_RequiresAdminRole() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs index e7b2a55..d64a559 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Tasks; @@ -44,23 +43,41 @@ public class HttpTaskIntegrationTests(ITestOutputHelper testOutputHelper) : Kest } [Fact] - public async Task WithTasks_AfterOrdinaryFilter_ThrowsActionableError() + public async Task WithTasks_AfterOrdinaryFilter_RunsFilter() { + var filterInvocationCount = 0; Builder.Services .AddMcpServer(options => { - options.Filters.Request.CallToolFilters.Add(next => next); + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + Interlocked.Increment(ref filterInvocationCount); + return await next(context, cancellationToken); + }); }) .WithHttpTransport() .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) .WithTools(); await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); - var exception = Assert.Throws( - () => app.Services.GetRequiredService>().Value); - Assert.Contains(nameof(McpTasksBuilderExtensions.WithTasks), exception.Message); - Assert.Contains("before ordinary call-tool filters", exception.Message); + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("Hello World!", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal(1, filterInvocationCount); } [Theory] @@ -165,6 +182,54 @@ public class HttpTaskIntegrationTests(ITestOutputHelper testOutputHelper) : Kest Times.Never); } + [Fact] + public async Task WithTasks_ReauthorizesToolChangedByOrdinaryFilter() + { + var serverBuilder = Builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }) + .WithTools() + .AddAuthorizationFilters(); + + serverBuilder.Services.Configure(options => + { + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive("authorized-test", out var authorizedTool)) + { + throw new InvalidOperationException("The replacement tool was not registered."); + } + + options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => + { + context.MatchedPrimitive = authorizedTool; + return await next(context, cancellationToken); + }); + }); + serverBuilder.AddAuthorizationFilters(); + Builder.Services.AddAuthorization(); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") }, + HttpClient, + LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync(() => + client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "test" }, + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("Access forbidden: This tool requires authorization.", exception.Message); + } + [McpServerToolType] private sealed class TestTools { From 1d28d605308152ed665e001c92367ac1b6b3bca1 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Mon, 27 Jul 2026 17:58:21 -0700 Subject: [PATCH 23/58] Update Microsoft.Extensions.AI dependencies (#1755) Copilot-Session: 268e897b-5571-4b1e-b840-41697fe52dd2 --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 63a80f8..d204d87 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,10 +1,10 @@ true - 8.0.22 - 9.0.11 + 8.0.29 + 9.0.18 10.0.10 - 10.5.2 + 10.8.3 From bcd6b8cb3e34d63bbe07c7bd76e50faa307274d5 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Mon, 27 Jul 2026 19:46:45 -0700 Subject: [PATCH 24/58] Add AI disclosure guidance (#1756) Copilot-Session: faf35462-e329-4fc6-a46b-7246b5805d13 --- .github/copilot-instructions.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1e48e16..a93a1b3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,19 @@ This repository contains the official C# SDK for the Model Context Protocol (MCP), enabling .NET applications to implement and interact with MCP clients and servers. +## GitHub Interactions + +### Commits and Pushes +- Never push to an active pull request without being explicitly asked. Always wait for explicit instructions to push. +- Never chain commit and push in the same command. Commit first, report what was committed, then wait for explicit push instructions. This creates a mandatory decision point. + +### AI-Generated Content Disclosure +When posting any content to GitHub under a user's credentials — opening pull requests, creating issues, commenting on pull requests or issues, posting review comments, or taking any other public-facing action — include a concise, visible note at the bottom indicating that the content was AI/Copilot-generated. + +This disclosure is not required when: +- The account is a recognized bot or Copilot app account (for example, `github-actions[bot]` or `copilot`), where the AI origin is already apparent from the account identity. +- The user explicitly asks to omit the disclosure. + ## Critical: Always Build and Test **ALWAYS build and run tests before declaring any task complete or making a pull request.** From 6e2f285827d3ef38748a9b3344160fa3ba0030a2 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Mon, 27 Jul 2026 20:12:08 -0700 Subject: [PATCH 25/58] Release v2.0.0-rc.2 (#1757) Copilot-Session: e31bd0cf-94a1-4e19-a813-4ede39853d7a --- docs/versioning.md | 2 +- src/Directory.Build.props | 2 +- src/PACKAGE.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/versioning.md b/docs/versioning.md index 1305e5f..a080035 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -47,7 +47,7 @@ compatibility with the down-level implementation: a v2 Tasks client or server do ## Breaking changes -The 2.0.0 SDK is a stable release. The SDK follows Semantic Versioning, and breaking changes against stable releases require increments to the MAJOR version. +The 2.0.0 SDK will be a stable release after its general availability release. The SDK follows Semantic Versioning, and breaking changes against stable releases require increments to the MAJOR version. If feasible, the SDK will support all versions of the MCP spec. However, if breaking changes to the spec make this infeasible, preference will be given to the most recent version of the MCP spec. This would be considered a breaking change necessitating a new MAJOR version. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0db040c..760b419 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 - rc.1 + rc.2 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 4c9fb52..a4c9472 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -4,7 +4,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. -See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-rc.1) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-rc.2) for what's new in this version. ## Packages From 314bdb2f4912f5d29d490b2b466d68af3b49d4ad Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Mon, 27 Jul 2026 20:15:16 -0700 Subject: [PATCH 26/58] Refine 2.0.0 concept documentation (#1742) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 782b5bad-4046-49ec-93a6-72768b5b7521 Copilot-Session: 4b938335-418c-4ee7-8287-6c70d9148b89 --- docs/concepts/capabilities/capabilities.md | 4 +- docs/concepts/getting-started.md | 2 +- docs/concepts/index.md | 2 +- docs/concepts/logging/logging.md | 7 +- .../samples/server/Tools/LoggingTools.cs | 2 +- docs/concepts/mrtr/mrtr.md | 122 +++++++++++++++++- .../samples/server/Tools/LongRunningTools.cs | 4 +- docs/concepts/roots/roots.md | 5 +- docs/concepts/sampling/sampling.md | 5 +- docs/concepts/tasks/tasks.md | 38 +++--- docs/concepts/toc.yml | 4 + docs/concepts/transports/transports.md | 8 +- docs/list-of-diagnostics.md | 4 +- docs/toc.yml | 2 - docs/versioning.md | 5 + samples/TasksExtension/Program.cs | 2 +- samples/TasksExtension/README.md | 25 +--- 17 files changed, 178 insertions(+), 63 deletions(-) diff --git a/docs/concepts/capabilities/capabilities.md b/docs/concepts/capabilities/capabilities.md index 76cc375..d4c7642 100644 --- a/docs/concepts/capabilities/capabilities.md +++ b/docs/concepts/capabilities/capabilities.md @@ -83,11 +83,11 @@ if (client.ServerCapabilities.Resources is { Subscribe: true }) // Check if the server supports prompts with list-changed notifications if (client.ServerCapabilities.Prompts is { ListChanged: true }) { - mcpClient.RegisterNotificationHandler( + client.RegisterNotificationHandler( NotificationMethods.PromptListChangedNotification, async (notification, ct) => { - var prompts = await mcpClient.ListPromptsAsync(cancellationToken: ct); + var prompts = await client.ListPromptsAsync(cancellationToken: ct); }); } diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 5ff6816..73901c1 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -87,7 +87,7 @@ builder.Services.AddMcpServer() { // Stateless mode is recommended for servers that don't need // server-to-client requests like sampling or elicitation. - // See the Sessions documentation for details. + // See the Stateless and Stateful documentation for details. options.Stateless = true; }) .WithToolsFromAssembly(); diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 5817426..b318505 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -20,6 +20,7 @@ To install the SDK and build your first MCP client and server, see [Getting star | - | - | | [Capabilities](capabilities/capabilities.md) | Learn how client and server capabilities are negotiated during initialization, including protocol version negotiation. | | [Transports](transports/transports.md) | Learn how to configure stdio, Streamable HTTP, and SSE transports for client-server communication. | +| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | | [Ping](ping/ping.md) | Learn how to verify connection health using the ping mechanism. | | [Progress tracking](progress/progress.md) | Learn how to track progress for long-running operations through notification messages. | | [Cancellation](cancellation/cancellation.md) | Learn how to cancel in-flight MCP requests using cancellation tokens and notifications. | @@ -43,7 +44,6 @@ To install the SDK and build your first MCP client and server, see [Getting star | [Completions](completions/completions.md) | Learn how to implement argument auto-completion for prompts and resource templates. | | [Logging](logging/logging.md) | Learn how to implement logging in MCP servers and how clients can consume log messages. | | [Pagination](pagination/pagination.md) | Learn how to use cursor-based pagination when listing tools, prompts, and resources. | -| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. | | [HTTP Context](httpcontext/httpcontext.md) | Learn how to access the underlying `HttpContext` for a request. | | [MCP Server Handler Filters](filters.md) | Learn how to add filters to the handler pipeline. Filters let you wrap the original handler with additional functionality. | diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md index 8d705cf..2b381aa 100644 --- a/docs/concepts/logging/logging.md +++ b/docs/concepts/logging/logging.md @@ -11,6 +11,9 @@ MCP servers can expose log messages to clients through the [Logging utility]. [Logging utility]: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging +> [!IMPORTANT] +> Logging is **deprecated** as of MCP specification revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), [MCP9005](xref:list-of-diagnostics#obsolete-apis)) and may be removed in a future version. + This document describes how to implement logging in MCP servers and how clients can consume log messages. ### Logging levels @@ -32,8 +35,8 @@ different set of logging levels. The following table shows the levels and how th | `emergency` | | System is unusable | | **Note:** .NET's [ILogger] also supports a `Trace` level (more verbose than Debug) log level. -As there is no equivalent level in the MCP logging levels, Trace level logs messages are silently -dropped when sending messages to the client. +As there is no more verbose level in the MCP logging levels, Trace level log messages are mapped to +the MCP `debug` level when sent to the client. [ILogger]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.ilogger [ILoggerProvider]: https://learn.microsoft.com/dotnet/api/microsoft.extensions.logging.iloggerprovider diff --git a/docs/concepts/logging/samples/server/Tools/LoggingTools.cs b/docs/concepts/logging/samples/server/Tools/LoggingTools.cs index 8ddb9e4..edb3916 100644 --- a/docs/concepts/logging/samples/server/Tools/LoggingTools.cs +++ b/docs/concepts/logging/samples/server/Tools/LoggingTools.cs @@ -16,7 +16,7 @@ public class LoggingTools var progressToken = context.Params.ProgressToken; var stepDuration = duration / steps; - // + // ILoggerProvider loggerProvider = context.Server.AsClientLoggerProvider(); ILogger logger = loggerProvider.CreateLogger("LoggingTools"); // diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 5a498da..8730e6f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -247,20 +247,130 @@ public static string WizardTool( } ``` -### Providing custom error messages +### Supporting down-level clients -When MRTR is not supported, you can provide domain-specific guidance: +When is `false`, the tool +can't obtain client input through an input request — but that doesn't have to be a dead +end. Handle down-level and stateless callers along a spectrum, from a helpful message to a +fully functional non-interactive path. + +#### Return actionable guidance + +At minimum, tell the caller how to proceed instead of failing opaquely. Explain what kind +of session would enable the interactive path, and — when the tool exposes one — how to +invoke the non-interactive path described below: ```csharp if (!server.IsMrtrSupported) { - return "This tool requires interactive input. To use it:\n" - + "1. Connect with a client that negotiates MCP protocol revision 2026-07-28, or\n" - + "2. Use a stateful session using protocol revision 2025-11-25 so the server can resolve the input requests for you.\n" - + "\nStateless sessions using protocol revision 2025-11-25 and earlier cannot resolve MRTR input requests."; + return "This tool needs interactive input. Connect with a client that negotiates MCP " + + "protocol revision 2026-07-28, or use a stateful session using revision 2025-11-25 " + + "so the server can resolve the input requests for you."; } ``` +#### Offer a non-interactive fallback + +When a tool *can* complete without a prompt, expose an explicit argument that lets a +down-level or stateless caller opt in directly. The tool stays usable everywhere: it +elicits input when MRTR is available, and otherwise returns guidance the caller +(or its model) can act on by resending with the argument set. + +The tool below is just an example that has a sensible non-interactive fallback. It also +shows how to branch on the elicitation **action** rather than assuming acceptance: the +deserialized carries an + of `"accept"`, `"decline"`, or +`"cancel"`, surfaced through the + shorthand. + +```csharp +[McpServerTool, Description("Closes a support ticket, recording why it was closed.")] +public static string CloseSupportTicket( + McpServer server, + RequestContext context, + [Description("The ID of the ticket to close")] long ticketId, + [Description("Why the ticket is being closed")] string? closeReason = null) +{ + // Handles four client scenarios: + // 1. Provided up-front: client sends `closeReason` in the initial call + // 2. MRTR round-trip request: client confirms via `InputResponses["closeReason"]` + // 3. MRTR initial request: server proposes a default reason and asks for confirmation + // (with automatic SDK down-level bridge) + // 4. Session-less down-level: server returns a guidance message requesting the reason up-front + + // The default reason proposed to the caller and used if none is provided. + string defaultCloseReason = "completed"; + + // (1) Provided up-front. Works on any client, including down-level session-less. + // These requests are typically sent after (4) returns a guidance message. + var confirmedReason = closeReason; + + // (2) MRTR round-trip request. Works with native MRTR support or the automatic down-level + // SDK bridge after (3) throws an `InputRequiredException` to request a `closeReason`. + if (string.IsNullOrWhiteSpace(confirmedReason) && + context.Params?.InputResponses?.TryGetValue("closeReason", out var reasonResponse) is true) + { + var reasonResult = reasonResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + + // Branch on the elicitation action: `decline` or `cancel` leaves the ticket open. + if (reasonResult?.IsAccepted is not true) return "Ticket close cancelled"; + + // Accepted: use the reason the caller confirmed, falling back to the proposed default. + confirmedReason = reasonResult.Content?.TryGetValue("closeReason", out var reasonValue) is true + ? reasonValue.GetString() + : null; + confirmedReason = string.IsNullOrWhiteSpace(confirmedReason) ? defaultCloseReason : confirmedReason; + } + + // (1) or (2) A reason is in hand; proceed with closing the ticket. + if (!string.IsNullOrWhiteSpace(confirmedReason)) + return $"Closed ticket {ticketId}: {confirmedReason}"; + + // (3) MRTR initial request: propose "completed" as the default reason and ask the caller + // to confirm (or adjust) it. This uses the 2026-07-28 MRTR input request, but the SDK + // provides an automatic bridge to a legacy elicitation on a down-level, stateful + // session. When the bridge can be provided, `server.IsMrtrSupported` is `true` and the + // exception leads to a legacy elicitation response automatically. + if (server.IsMrtrSupported) + { + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["closeReason"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Close ticket '{ticketId}'? Accept the default reason or provide your own.", + RequestedSchema = new() + { + Properties = + { + ["closeReason"] = new ElicitRequestParams.StringSchema + { + Title = "Close reason", + Description = "The reason for closing the ticket", + Default = defaultCloseReason, + }, + }, + }, + }) + }, + requestState: ticketId.ToString()); // opaque; echoed back to us on the retry + } + + // (4) Down-level and stateless: we can't prompt an elicitation through an MRTR + // round-trip request or an elicitation. Return a natural language response + // with guidance for providing the reason up-front. + return "Closing a ticket requires a reason. Resend with `closeReason`."; +} +``` + +> [!IMPORTANT] +> Form-mode elicitations must set `RequestedSchema`. This tool proposes a default, so its +> schema declares an optional `closeReason` string with a `Default` the caller can accept or +> override. A confirmation prompt that collects no fields still needs a schema — set +> `RequestedSchema = new()` (an empty object schema): it's accepted by native `2026-07-28` MRTR +> and **required** by the down-level stateful elicitation bridge under `2025-11-25`, which +> otherwise throws `ArgumentException: "Form mode elicitation requests require a requested schema."`. + ## Compatibility The SDK supports `InputRequiredException` across two protocol revisions and two session modes: diff --git a/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs index e02889a..e3a21db 100644 --- a/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs +++ b/docs/concepts/progress/samples/server/Tools/LongRunningTools.cs @@ -22,7 +22,7 @@ public class LongRunningTools { await Task.Delay(stepDuration * 1000); - // + // if (progressToken is not null) { await server.SendNotificationAsync("notifications/progress", new ProgressNotificationParams @@ -36,7 +36,7 @@ public class LongRunningTools }, }); } - // + // } return $"Long running tool completed. Duration: {duration} seconds. Steps: {steps}."; diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index ec26d43..a7a8ed2 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -11,6 +11,9 @@ MCP [roots] allow clients to inform servers about the relevant locations in the [roots]: https://modelcontextprotocol.io/specification/2025-11-25/client/roots +> [!IMPORTANT] +> Roots are **deprecated** as of MCP specification revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), [MCP9005](xref:list-of-diagnostics#obsolete-apis)) and may be removed in a future version. + ### Overview Roots provide a mechanism for the client to tell the server which directories, projects, or repositories are relevant to the current session. A server might use roots to: @@ -57,7 +60,7 @@ await using var client = await McpClient.CreateAsync(transport, options); ### Requesting roots from the server -Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:stateless) — it is not available in [stateless mode](xref:stateless#stateless-mode-recommended). +Servers can request the client's root list using . This is a server-to-client request, so it requires [stateful mode or stdio](xref:stateless) — it is not available in [stateless mode](xref:stateless#stateless-mode-recommended). For a stateless-compatible alternative, throw `InputRequiredException` from your handler through MRTR — see [Multi round-trip requests (MRTR)](#multi-round-trip-requests-mrtr). ```csharp [McpServerTool, Description("Lists the user's project roots")] diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index bdeeaf9..3dcbd44 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -11,8 +11,11 @@ MCP [sampling] allows servers to request LLM completions from the client. This e [sampling]: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling +> [!IMPORTANT] +> Sampling is **deprecated** as of MCP specification revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577), [MCP9005](xref:list-of-diagnostics#obsolete-apis)) and may be removed in a future version. + > [!NOTE] -> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:stateless). Sampling is not available in [stateless mode](xref:stateless#stateless-mode-recommended) because stateless servers cannot send requests to clients. +> Sampling is a **server-to-client request** — the server sends a request back to the client over an open connection. This requires [stateful mode or stdio](xref:stateless). Sampling is not available in [stateless mode](xref:stateless#stateless-mode-recommended) because stateless servers cannot send requests to clients. For a stateless-compatible alternative, throw `InputRequiredException` from your handler through MRTR — see [Multi round-trip requests (MRTR)](#multi-round-trip-requests-mrtr). ### How sampling works diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 1bbcfea..c1ab0c2 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -14,25 +14,6 @@ Tasks are provided by the `ModelContextProtocol.Extensions.Tasks` package and re version `2026-07-28` or later. The implementation follows [SEP-2663 (Tasks Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md). -### Compatibility with v1 experimental Tasks - -The Tasks extension in v2.0.0 replaces the experimental Tasks implementation shipped in v1.3.0 and -v1.4.x. The implementations are not compatible at either the API or protocol level. A v2 Tasks -client or server must use a connection negotiated to `2026-07-28` or later; it cannot fall back to -the down-level implementation. - -On a connection negotiated to the down-level `2025-11-25` protocol: - -- A v2 client calling a v1 server receives an ordinary tool result. The v2 client does not opt in - to the down-level Tasks protocol, and `GetTaskAsync` rejects use before a `2026-07-28` - connection is negotiated. -- A v1 client calling a v2 server likewise receives an ordinary tool result. The v2 server does - not create Tasks on a down-level connection, and its `tasks/get` endpoint rejects the legacy - request with a method-not-found error. - -Upgrade both peers to the v2 Tasks extension before using Tasks. The extension provides no -compatibility bridge for the previous experimental API. - ### Overview A client opts into tasks on a per-request basis by including the `io.modelcontextprotocol/tasks` @@ -346,6 +327,25 @@ scope, the SDK intentionally skips the normal client-capability negotiation chec the client opted in by including the extension marker in the originating request, so it's responsible for handling — or rejecting — the input requests surfaced through `tasks/get`. +#### Compatibility with v1 experimental Tasks + +The Tasks extension in v2.0.0 replaces the experimental Tasks implementation shipped in v1.3.0 and +v1.4.x. The implementations are not compatible at either the API or protocol level. A v2 Tasks +client or server must use a connection negotiated to `2026-07-28` or later; it cannot fall back to +the down-level implementation. + +On a connection negotiated to the down-level `2025-11-25` protocol: + +- A v2 client calling a v1 server receives an ordinary tool result. The v2 client does not opt in + to the down-level Tasks protocol, and `GetTaskAsync` rejects use before a `2026-07-28` + connection is negotiated. +- A v1 client calling a v2 server likewise receives an ordinary tool result. The v2 server does + not create Tasks on a down-level connection, and its `tasks/get` endpoint rejects the legacy + request with a method-not-found error. + +Upgrade both peers to the v2 Tasks extension before using Tasks. The extension provides no +compatibility bridge for the previous experimental API. + ### Known limitations - **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index 62c1968..276415e 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -57,3 +57,7 @@ items: uid: tasks - name: Identity and Roles uid: identity +- name: API Reference + uid: ModelContextProtocol +- name: Experimental APIs + uid: experimental diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 1d92480..63dcf78 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 runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Sessions](xref:stateless). +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). #### Host name validation @@ -328,7 +328,7 @@ app.MapMcp(); app.Run(); ``` -See [Sessions — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details on SSE session lifetime and configuration. +See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details on SSE session lifetime and configuration. ### Transport mode comparison @@ -345,7 +345,7 @@ See [Sessions — Legacy SSE transport](xref:stateless#legacy-sse-transport) for | Authentication | Process-level | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | HTTP auth (OAuth, headers) | | Best for | Local tools, IDE integrations | Remote servers, production deployments | Local HTTP debugging, server-to-client features | Legacy client compatibility | -For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Sessions](xref:stateless). +For a detailed comparison of stateless vs. stateful mode — including deployment trade-offs, security considerations, and configuration — see [Stateless and Stateful](xref:stateless). ### In-memory transport @@ -380,7 +380,7 @@ var echo = tools.First(t => t.Name == "echo"); Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); ``` -Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. For information about how session behavior varies across transports, see [Sessions](xref:stateless). +Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. For information about how session behavior varies across transports, see [Stateless and Stateful](xref:stateless). ## Cross-application access diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 4c19eba..1ad8206 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -42,7 +42,7 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9001` | In place | The `EnumSchema` and `LegacyTitledEnumSchema` APIs are deprecated as of specification version 2025-11-25. Use the current schema APIs instead. | | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (for example, `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | -| `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | -| `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information. | +| `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | +| `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) for more information. | | `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | | `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the authorization-response state or RFC 9207 issuer. State and issuer validation are skipped when these APIs are used. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for response-bound, issuer-aware authorization flows. | diff --git a/docs/toc.yml b/docs/toc.yml index e09c4b5..953382b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -7,7 +7,5 @@ items: href: roadmap.md - name: Versioning href: versioning.md -- name: Experimental APIs - href: experimental.md - name: GitHub href: https://github.com/ModelContextProtocol/csharp-sdk diff --git a/docs/versioning.md b/docs/versioning.md index a080035..b94b955 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -71,3 +71,8 @@ If APIs within the SDK become obsolete due to changes in the MCP spec or other e 3. Within a MAJOR version update, obsolete APIs might be removed. API removals are expected to be rare and avoided wherever possible, and `[Obsolete]` attributes will be applied ahead of the API removal. Beginning with the 1.0.0 release, all obsoletions will use diagnostic codes specific to the MCP SDK APIs, using an `MCP` prefix. + +## See also + +- [Experimental APIs](experimental.md) +- [List of diagnostics](list-of-diagnostics.md) diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index 50ddf0d..7823989 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -24,9 +24,9 @@ var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }; var services = new ServiceCollection(); services.AddMcpServer() + .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools([McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })]) .WithTasks(store); -services.AddSingleton(new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); diff --git a/samples/TasksExtension/README.md b/samples/TasksExtension/README.md index f325061..accafa5 100644 --- a/samples/TasksExtension/README.md +++ b/samples/TasksExtension/README.md @@ -6,16 +6,12 @@ The server is configured with an in-memory `IMcpTaskStore`, which is sufficient `[McpServerTool]` method automatically run as a background task when the client opts into the tasks extension on a per-request basis. -The client invokes the same `run-report` tool two ways: - -1. **`CallToolAsync` (auto-poll)** — the SDK injects the opt-in marker into `_meta`, polls - `tasks/get` at the cadence the server suggests, dispatches any input requests through - registered handlers, and returns the final `CallToolResult` (or throws on a terminal - `Failed`/`Cancelled`). -2. **`CallToolRawAsync` (manual poll)** — the caller receives the raw - `ResultOrCreatedTask` and drives the lifecycle directly with - `GetTaskAsync`, `UpdateTaskAsync`, and `CancelTaskAsync`. Useful when you need to surface - progress to a UI or stream task status updates between polls. +The client invokes the `run-report` tool with **`CallToolAsTaskAsync` (manual poll)**. It +receives a `ResultOrCreatedTask` and, when the server runs the call as a +background task, drives the lifecycle directly with `GetTaskAsync` — polling at the server's +suggested cadence until the task reaches a terminal state (`Completed`, `Failed`, or +`Cancelled`). If the server returns an inline result instead of creating a task, the sample +surfaces that result and stops. Both ends of the conversation are connected in-process over an in-memory `Pipe`, so no separate server process or HTTP transport is required. @@ -29,10 +25,7 @@ dotnet run --project samples/TasksExtension/TasksExtension.csproj Expected output: ``` -=== CallToolAsync (auto-poll) === - result: report ready - -=== CallToolRawAsync (manual poll) === +=== CallToolAsTaskAsync (manual poll) === task created: id=… status=Working pollIntervalMs=250 poll 1: still working … … @@ -41,10 +34,6 @@ Expected output: ## Notes -- The `MCPEXP001` warning is suppressed because the tasks extension is still experimental. The - project's `` already includes it; if you copy this pattern into your own project, - either suppress the diagnostic or wrap the experimental APIs in - `#pragma warning disable MCPEXP001`. - For production deployments — especially stateless HTTP servers — implement `IMcpTaskStore` against durable storage and register it as a singleton (see [docs/concepts/tasks/tasks.md](../../docs/concepts/tasks/tasks.md) for the contract). From e8b80f0e6d8ae5acecd5fdbb64f1f5a596e28f20 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 28 Jul 2026 13:39:26 -0700 Subject: [PATCH 27/58] Enable additional client conformance scenarios (#1763) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Authentication/ClientOAuthProvider.cs | 94 +++++- .../ExchangeJwtBearerGrantOptions.cs | 5 + .../Authentication/IdentityAssertionGrant.cs | 20 +- .../IdentityAssertionGrantProvider.cs | 61 ++++ .../IdentityAssertionGrantProviderOptions.cs | 11 + .../Authentication/TokenContainer.cs | 10 + .../ClientConformanceTests.cs | 9 +- .../OAuth/TokenCacheTests.cs | 291 +++++++++++++++++ .../ConformanceOAuthHelpers.cs | 298 ++++++++++++++++++ .../Program.cs | 91 ++++-- .../IdentityAssertionGrantTests.cs | 125 ++++++++ 11 files changed, 971 insertions(+), 44 deletions(-) create mode 100644 tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index a20ef9f..785e3cc 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -35,6 +35,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly bool _validateAuthorizationResponseState; private readonly bool _validateAuthorizationResponseIssuer; private readonly Uri? _clientMetadataDocumentUri; + private readonly string? _configuredClientId; // _dcrClientName, _dcrClientUri, _dcrInitialAccessToken, _dcrConfiguredApplicationType and _dcrResponseDelegate are used for dynamic client registration (RFC 7591) private readonly string? _dcrClientName; @@ -49,6 +50,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private string? _clientId; private string? _clientSecret; private string? _tokenEndpointAuthMethod; + private string? _clientCredentialsAuthorizationServer; private ITokenCache _tokenCache; private AuthorizationServerMetadata? _authServerMetadata; @@ -96,6 +98,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } _clientId = options.ClientId; + _configuredClientId = options.ClientId; _clientSecret = options.ClientSecret; _redirectUri = options.RedirectUri ?? throw new ArgumentException("ClientOAuthOptions.RedirectUri must configured.", nameof(options)); _configuredScopes = options.Scopes is null ? null : string.Join(" ", options.Scopes); @@ -244,7 +247,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient return (current.AccessToken, true); } - if (_authServerMetadata is not null && current?.RefreshToken is { Length: > 0 } refreshToken) + if (_authServerMetadata is not null && + current?.RefreshToken is { Length: > 0 } refreshToken && + CachedTokensMatchClientCredentials(current, _clientCredentialsAuthorizationServer)) { var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false); return (accessToken, true); @@ -427,7 +432,10 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient // provider has not assigned a client ID yet. Restoring it here makes the refresh below possible // and avoids a redundant dynamic client registration in the assignment block. var cachedTokens = await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false); - RestoreCachedClientCredentials(cachedTokens); + RestoreCachedClientCredentials(cachedTokens, selectedAuthServer); + BindClientCredentialsToAuthorizationServer(selectedAuthServer); + var cachedTokensMatchClientCredentials = + CachedTokensMatchClientCredentials(cachedTokens, selectedAuthServer.OriginalString); // Only attempt a token refresh if we haven't attempted to already for this request. // Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes @@ -439,6 +447,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient if (!attemptedRefresh && response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(_clientId) && + cachedTokensMatchClientCredentials && cachedTokens is { RefreshToken: { Length: > 0 } refreshToken }) { var accessToken = await RefreshTokensAsync(refreshToken, resourceUri, authServerMetadata, cancellationToken).ConfigureAwait(false); @@ -463,6 +472,8 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient } } + _clientCredentialsAuthorizationServer = selectedAuthServer.OriginalString; + // Determine the token endpoint auth method from server metadata if not already set by DCR. _tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault(); @@ -875,6 +886,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient ClientId = _clientId, ClientSecret = _clientSecret, TokenEndpointAuthMethod = _tokenEndpointAuthMethod, + AuthorizationServer = _clientCredentialsAuthorizationServer, }; await _tokenCache.StoreTokensAsync(tokens, cancellationToken).ConfigureAwait(false); @@ -1458,33 +1470,93 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient /// /// Restores the client registration persisted alongside cached tokens when this provider has not been /// assigned a client ID yet. This allows a durable to use a refresh token that - /// survived a process restart without re-running dynamic client registration. An explicitly configured + /// survived a process restart without re-running dynamic client registration. Credentials are restored + /// only when the cache binds them to the currently selected authorization server. An explicitly configured /// client ID always takes precedence, so nothing is restored when one is already available. /// /// /// Callers must hold _tokenAcquisitionLock: this writes the shared _clientId, /// _clientSecret, and _tokenEndpointAuthMethod fields, which the lock serializes. - /// Like the persisted refresh token, the restored client ID assumes the durable cache belongs to the - /// current authorization server; a single cache shared across authorization servers is not supported - /// (an inherent property of the single-container design). + /// A single cache still stores only one registration, but the persisted authorization-server issuer + /// prevents that registration from being reused with a different server. /// - private void RestoreCachedClientCredentials(TokenContainer? tokens) + private void RestoreCachedClientCredentials(TokenContainer? tokens, Uri selectedAuthServer) { - if (!string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(tokens?.ClientId)) + if (tokens is null) { return; } - // The guard above guarantees a non-null container, but the older nullable flow analysis on - // netstandard2.0/net472 doesn't infer that from the null-conditional check, so capture a - // non-null local and use it for every assignment. + // The guard above guarantees a non-null container, but older nullable flow analysis on + // netstandard2.0/net472 may not preserve that narrowing, so capture a non-null local. var cached = tokens!; + if (_configuredClientId is not null) + { + if (!string.Equals(cached.ClientId, _configuredClientId, StringComparison.Ordinal)) + { + return; + } + + // Restore the issuer binding independently from the secret. A rotated configured secret + // must not make credentials previously bound to another issuer appear portable. + _clientCredentialsAuthorizationServer = cached.AuthorizationServer; + + if (string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal) && + string.Equals(cached.ClientSecret, _clientSecret, StringComparison.Ordinal)) + { + _tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod; + } + + return; + } + + if (!string.IsNullOrEmpty(_clientId)) + { + return; + } + + if (string.IsNullOrEmpty(cached.ClientId) || + !string.Equals(cached.AuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal)) + { + return; + } + // Assign _clientId last. Callers treat a non-empty _clientId as "registration complete", so the // secret and auth method must already be in place before _clientId becomes observable. _clientSecret ??= cached.ClientSecret; _tokenEndpointAuthMethod ??= cached.TokenEndpointAuthMethod; _clientId = cached.ClientId; + _clientCredentialsAuthorizationServer = cached.AuthorizationServer; + } + + private bool CachedTokensMatchClientCredentials(TokenContainer? tokens, string? authorizationServer) => + tokens is not null && + string.Equals(tokens.AuthorizationServer, authorizationServer, StringComparison.Ordinal) && + string.Equals(tokens.ClientId, _clientId, StringComparison.Ordinal) && + string.Equals(tokens.ClientSecret, _clientSecret, StringComparison.Ordinal) && + string.Equals(tokens.TokenEndpointAuthMethod, _tokenEndpointAuthMethod, StringComparison.Ordinal); + + private void BindClientCredentialsToAuthorizationServer(Uri selectedAuthServer) + { + if (_clientCredentialsAuthorizationServer is null || + string.Equals(_clientCredentialsAuthorizationServer, selectedAuthServer.OriginalString, StringComparison.Ordinal)) + { + return; + } + + if (_configuredClientId is not null) + { + ThrowFailedToHandleUnauthorizedResponse( + $"The authorization server changed from '{_clientCredentialsAuthorizationServer}' to '{selectedAuthServer.OriginalString}', " + + "but explicitly configured client credentials cannot be assumed to be valid for the new authorization server."); + } + + _clientId = null; + _clientSecret = null; + _tokenEndpointAuthMethod = null; + _authServerMetadata = null; + _clientCredentialsAuthorizationServer = null; } [DoesNotReturn] diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs index 9dd440b..e1a1df3 100644 --- a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs @@ -25,6 +25,11 @@ internal sealed class ExchangeJwtBearerGrantOptions /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the token endpoint authentication method. + /// + public string? TokenEndpointAuthMethod { get; set; } + /// /// Gets or sets the scopes to request (space-separated). Optional. /// diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs index c949037..ecc5eb3 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs @@ -164,7 +164,18 @@ internal static class IdentityAssertionGrant ["client_id"] = options.ClientId, }; - if (!string.IsNullOrEmpty(options.ClientSecret)) + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint); + + if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal)) + { + formData.Remove("client_id"); + var credentials = $"{Uri.EscapeDataString(options.ClientId)}:{Uri.EscapeDataString(options.ClientSecret ?? string.Empty)}"; + httpRequest.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials))); + } + else if (string.Equals(options.TokenEndpointAuthMethod, "client_secret_post", StringComparison.Ordinal) && + !string.IsNullOrEmpty(options.ClientSecret)) { formData["client_secret"] = options.ClientSecret!; } @@ -174,12 +185,7 @@ internal static class IdentityAssertionGrant formData["scope"] = options.Scope!; } - using var requestContent = new FormUrlEncodedContent(formData); - using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) - { - Content = requestContent - }; - + httpRequest.Content = new FormUrlEncodedContent(formData); httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs index f881b5f..41d2ea3 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -102,6 +102,22 @@ public sealed class IdentityAssertionGrantProvider throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}"); } + if (options.TokenEndpointAuthMethod is not null && + options.TokenEndpointAuthMethod is not ("client_secret_basic" or "client_secret_post" or "none")) + { + throw new ArgumentException( + $"{nameof(options.TokenEndpointAuthMethod)} must be 'client_secret_basic', 'client_secret_post', or 'none'.", + $"{nameof(options)}.{nameof(options.TokenEndpointAuthMethod)}"); + } + + if (options.TokenEndpointAuthMethod is "client_secret_basic" or "client_secret_post" && + string.IsNullOrEmpty(options.ClientSecret)) + { + throw new ArgumentException( + $"{nameof(options.ClientSecret)} is required when {nameof(options.TokenEndpointAuthMethod)} is '{options.TokenEndpointAuthMethod}'.", + $"{nameof(options)}.{nameof(options.ClientSecret)}"); + } + _options = options; _httpClient = httpClient; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -197,6 +213,7 @@ public sealed class IdentityAssertionGrantProvider Assertion = jag, ClientId = _options.ClientId, ClientSecret = _options.ClientSecret, + TokenEndpointAuthMethod = SelectTokenEndpointAuthMethod(mcpAuthMetadata), Scope = _options.Scope, }, _httpClient, cancellationToken).ConfigureAwait(false); @@ -206,6 +223,50 @@ public sealed class IdentityAssertionGrantProvider return tokens; } + private string SelectTokenEndpointAuthMethod(AuthorizationServerMetadata metadata) + { + var supportedMethods = metadata.TokenEndpointAuthMethodsSupported; + if (_options.TokenEndpointAuthMethod is { } configuredMethod) + { + if (supportedMethods is { Count: > 0 } && + !supportedMethods.Contains(configuredMethod, StringComparer.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"The configured token endpoint authentication method '{configuredMethod}' is not advertised by the MCP authorization server."); + } + + return configuredMethod; + } + + if (string.IsNullOrEmpty(_options.ClientSecret)) + { + if (supportedMethods is null or { Count: 0 } || + supportedMethods.Contains("none", StringComparer.Ordinal)) + { + return "none"; + } + + throw new IdentityAssertionGrantException( + "The MCP authorization server does not advertise a token endpoint authentication method usable without a client secret."); + } + + // Preserve the provider's existing client_secret_post behavior when it is available. + // RFC 8414 defines this metadata as a list of supported methods, not a preference order. + if (supportedMethods is null or { Count: 0 } || + supportedMethods.Contains("client_secret_post", StringComparer.Ordinal)) + { + return "client_secret_post"; + } + + if (supportedMethods.Contains("client_secret_basic", StringComparer.Ordinal)) + { + return "client_secret_basic"; + } + + throw new IdentityAssertionGrantException( + "The MCP authorization server does not advertise a supported token endpoint authentication method."); + } + /// /// Clears any cached tokens, forcing a fresh token exchange on the next call to . /// diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs index c6cc7f8..ecbd055 100644 --- a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs @@ -16,6 +16,17 @@ public sealed class IdentityAssertionGrantProviderOptions /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the authentication method used at the MCP authorization server's token endpoint. + /// + /// + /// Supported values are client_secret_basic, client_secret_post, and none. + /// Set this to the method assigned to the pre-registered MCP client. When omitted, the provider + /// preserves its existing client_secret_post behavior when supported, then falls back to + /// another compatible method advertised by the authorization server. + /// + public string? TokenEndpointAuthMethod { get; set; } + /// /// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional. /// diff --git a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs index 6f6a542..68d4fb8 100644 --- a/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs +++ b/src/ModelContextProtocol.Core/Authentication/TokenContainer.cs @@ -76,5 +76,15 @@ public sealed class TokenContainer /// public string? TokenEndpointAuthMethod { get; set; } + /// + /// Gets or sets the authorization server issuer that issued the client credentials and tokens. + /// + /// + /// OAuth client credentials are bound to an authorization server and must not be reused with a + /// different issuer. Durable cache implementations should persist this value alongside the token + /// and client registration so restored credentials can be validated before use. + /// + public string? AuthorizationServer { get; set; } + internal bool IsExpired => ExpiresIn is not null && DateTimeOffset.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn.Value); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 8e982e9..1b8be84 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -63,9 +63,12 @@ public class ClientConformanceTests [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] - // Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support. - // [InlineData("auth/client-credentials-jwt")] - // [InlineData("auth/client-credentials-basic")] + [InlineData("auth/authorization-server-migration")] + [InlineData("auth/client-credentials-jwt")] + [InlineData("auth/client-credentials-basic")] + [InlineData("auth/enterprise-managed-authorization")] + [InlineData("sep-2322-client-request-state")] + [InlineData("json-schema-ref-no-deref")] public async Task RunConformanceTest(string scenario) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs index cdd7475..5b6f054 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs @@ -1,5 +1,6 @@ using ModelContextProtocol.Authentication; using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.AspNetCore.Tests.OAuth; @@ -187,6 +188,182 @@ public class TokenCacheTests : OAuthTestBase Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } + [Fact] + public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenFromDifferentAuthorizationServer() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com"; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("explicitly configured client credentials", exception.Message); + Assert.False(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal("https://different-authorization-server.example.com", tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ExplicitClientDoesNotRefreshTokenIssuedToDifferentClient() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.ClientId = "different-client"; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal("demo-client", tokenCache.LastStoredToken.ClientId); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ExplicitClientWithLegacyUnboundCache_Reauthorizes() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = null; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + [Fact] public async Task GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingPersistedCredentials() { @@ -227,6 +404,7 @@ public class TokenCacheTests : OAuthTestBase Assert.False( string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId), "The dynamically registered client ID should be persisted alongside the tokens"); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); // Simulate a cold start: the access token is no longer valid, but the refresh token persists. // The new provider has no client ID configured and must restore it from the cache to refresh @@ -261,6 +439,119 @@ public class TokenCacheTests : OAuthTestBase Assert.NotEqual("invalid-token", tokenCache.LastStoredToken.AccessToken); } + [Fact] + public async Task GetTokenAsync_ColdStartWithCredentialsFromDifferentAuthorizationServer_Reregisters() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = "https://different-authorization-server.example.com"; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + + [Fact] + public async Task GetTokenAsync_ColdStartWithLegacyUnboundCredentials_Reregisters() + { + await using var app = await StartMcpServerAsync(); + + var tokenCache = new TestTokenCache(); + await using var setupTransport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = HandleAuthorizationUrlAsync, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using (var setupClient = await McpClient.CreateAsync( + setupTransport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken)) + { + } + + Assert.NotNull(tokenCache.LastStoredToken); + Assert.False(string.IsNullOrEmpty(tokenCache.LastStoredToken.ClientId)); + tokenCache.LastStoredToken.AccessToken = "invalid-token"; + tokenCache.LastStoredToken.AuthorizationServer = null; + var authorizationCallbackCalled = false; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + AuthorizationCallbackHandler = (context, ct) => + { + authorizationCallbackCalled = true; + return HandleAuthorizationUrlAsync(context, ct); + }, + TokenCache = tokenCache, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(authorizationCallbackCalled); + Assert.False(TestOAuthServer.HasRefreshedToken); + Assert.Equal(OAuthServerUrl, tokenCache.LastStoredToken.AuthorizationServer); + } + [Fact] public async Task GetTokenAsync_ColdStartWithoutPersistedClientId_FallsBackToReauthorization() { diff --git a/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs b/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs new file mode 100644 index 0000000..d9c148d --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceClient/ConformanceOAuthHelpers.cs @@ -0,0 +1,298 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Authentication; + +internal sealed class ConformanceContext +{ + private readonly JsonElement _root; + + private ConformanceContext(JsonElement root) + { + _root = root; + } + + public static ConformanceContext? Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + using var document = JsonDocument.Parse(json); + return new ConformanceContext(document.RootElement.Clone()); + } + + public string GetRequiredString(string propertyName) + { + if (!_root.TryGetProperty(propertyName, out var value) || + value.ValueKind != JsonValueKind.String || + string.IsNullOrEmpty(value.GetString())) + { + throw new InvalidOperationException( + $"MCP_CONFORMANCE_CONTEXT is missing required string property '{propertyName}'."); + } + + return value.GetString()!; + } + + public string? GetString(string propertyName) => + _root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} + +internal static class ConformanceOAuthHelpers +{ + private const string ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + public static async Task AcquireClientCredentialsTokenAsync( + Uri resourceUri, + ConformanceContext context, + bool usePrivateKeyJwt, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false); + var clientId = context.GetRequiredString("client_id"); + + Dictionary formFields = new() + { + ["grant_type"] = "client_credentials", + ["resource"] = discovery.Resource, + }; + + if (discovery.Scopes.Count > 0) + { + formFields["scope"] = string.Join(" ", discovery.Scopes); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, discovery.TokenEndpoint); + if (usePrivateKeyJwt) + { + var algorithm = context.GetString("signing_algorithm") ?? "ES256"; + if (!string.Equals(algorithm, "ES256", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unsupported client assertion signing algorithm '{algorithm}'."); + } + + formFields["client_assertion_type"] = ClientAssertionType; + formFields["client_assertion"] = CreateEs256ClientAssertion( + clientId, + discovery.AuthorizationServer, + context.GetRequiredString("private_key_pem")); + } + else + { + var credentials = $"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(context.GetRequiredString("client_secret"))}"; + request.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials))); + } + + request.Content = new FormUrlEncodedContent(formFields); + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await ReadAccessTokenAsync(response, cancellationToken).ConfigureAwait(false); + } + + public static async Task AcquireEnterpriseTokenAsync( + Uri resourceUri, + ConformanceContext context, + ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + var discovery = await DiscoverAsync(httpClient, resourceUri, cancellationToken).ConfigureAwait(false); + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = context.GetRequiredString("client_id"), + ClientSecret = context.GetRequiredString("client_secret"), + TokenEndpointAuthMethod = "client_secret_basic", + IdpClientId = context.GetRequiredString("idp_client_id"), + IdpTokenEndpoint = context.GetRequiredString("idp_token_endpoint"), + IdTokenCallback = (_, _) => Task.FromResult(context.GetRequiredString("idp_id_token")), + }, + httpClient, + loggerFactory); + + var tokens = await provider.GetAccessTokenAsync( + resourceUri, + new Uri(discovery.AuthorizationServer), + cancellationToken).ConfigureAwait(false); + return tokens.AccessToken; + } + + public static HttpClient CreateBearerHttpClient(string accessToken) + { + var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return httpClient; + } + + private static async Task DiscoverAsync( + HttpClient httpClient, + Uri resourceUri, + CancellationToken cancellationToken) + { + JsonElement? protectedResourceMetadata = null; + foreach (var metadataUri in GetProtectedResourceMetadataUris(resourceUri)) + { + using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + continue; + } + + protectedResourceMetadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false); + break; + } + + if (protectedResourceMetadata is not { } prm || + !prm.TryGetProperty("authorization_servers", out var authorizationServers) || + authorizationServers.ValueKind != JsonValueKind.Array || + authorizationServers.GetArrayLength() == 0) + { + throw new InvalidOperationException($"No authorization server was discovered for MCP resource '{resourceUri}'."); + } + + var authorizationServer = authorizationServers[0].GetString() + ?? throw new InvalidOperationException("The discovered authorization server URI was null."); + var resource = prm.TryGetProperty("resource", out var resourceProperty) + ? resourceProperty.GetString() ?? resourceUri.ToString() + : resourceUri.ToString(); + List scopes = []; + if (prm.TryGetProperty("scopes_supported", out var scopesProperty) && + scopesProperty.ValueKind == JsonValueKind.Array) + { + foreach (var scope in scopesProperty.EnumerateArray()) + { + if (scope.GetString() is { Length: > 0 } value) + { + scopes.Add(value); + } + } + } + + var authorizationServerUri = new Uri(authorizationServer); + foreach (var metadataUri in GetAuthorizationServerMetadataUris(authorizationServerUri)) + { + using var response = await httpClient.GetAsync(metadataUri, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + continue; + } + + var metadata = await ReadJsonAsync(response, cancellationToken).ConfigureAwait(false); + if (metadata.TryGetProperty("token_endpoint", out var tokenEndpointProperty) && + tokenEndpointProperty.GetString() is { Length: > 0 } tokenEndpoint) + { + var issuer = metadata.TryGetProperty("issuer", out var issuerProperty) + ? issuerProperty.GetString() ?? authorizationServer + : authorizationServer; + return new OAuthDiscovery(resource, issuer, new Uri(tokenEndpoint), scopes); + } + } + + throw new InvalidOperationException( + $"No authorization server metadata with a token endpoint was discovered for '{authorizationServer}'."); + } + + private static IEnumerable GetProtectedResourceMetadataUris(Uri resourceUri) + { + var authority = resourceUri.GetLeftPart(UriPartial.Authority); + var path = resourceUri.AbsolutePath.Trim('/'); + if (path.Length > 0) + { + yield return new Uri($"{authority}/.well-known/oauth-protected-resource/{path}"); + } + yield return new Uri($"{authority}/.well-known/oauth-protected-resource"); + } + + private static IEnumerable GetAuthorizationServerMetadataUris(Uri authorizationServer) + { + var authority = authorizationServer.GetLeftPart(UriPartial.Authority); + var path = authorizationServer.AbsolutePath.Trim('/'); + if (path.Length == 0) + { + yield return new Uri($"{authority}/.well-known/oauth-authorization-server"); + yield return new Uri($"{authority}/.well-known/openid-configuration"); + } + else + { + yield return new Uri($"{authority}/.well-known/oauth-authorization-server/{path}"); + yield return new Uri($"{authority}/.well-known/openid-configuration/{path}"); + yield return new Uri($"{authority}/{path}/.well-known/openid-configuration"); + } + } + + private static string CreateEs256ClientAssertion(string clientId, string audience, string privateKeyPem) + { + var header = Base64UrlEncode(Encoding.UTF8.GetBytes("""{"alg":"ES256","typ":"JWT"}""")); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + using var payloadStream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(payloadStream)) + { + writer.WriteStartObject(); + writer.WriteString("iss", clientId); + writer.WriteString("sub", clientId); + writer.WriteString("aud", audience); + writer.WriteNumber("iat", now); + writer.WriteNumber("exp", now + 300); + writer.WriteString("jti", Guid.NewGuid()); + writer.WriteEndObject(); + } + + var payload = Base64UrlEncode(payloadStream.ToArray()); + var signingInput = Encoding.ASCII.GetBytes($"{header}.{payload}"); + using var key = ECDsa.Create(); + key.ImportFromPem(privateKeyPem); + var signature = key.SignData( + signingInput, + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return $"{header}.{payload}.{Base64UrlEncode(signature)}"; + } + + private static async Task ReadJsonAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + return document.RootElement.Clone(); + } + + private static async Task ReadAccessTokenAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Client credentials token request failed with status {(int)response.StatusCode}: {body}"); + } + + using var document = JsonDocument.Parse(body); + if (!document.RootElement.TryGetProperty("access_token", out var accessToken) || + accessToken.GetString() is not { Length: > 0 } value) + { + throw new InvalidOperationException("The token response did not contain an access_token."); + } + + return value; + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed record OAuthDiscovery( + string Resource, + string AuthorizationServer, + Uri TokenEndpoint, + IReadOnlyList Scopes); +} diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 82d720a..718c782 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -40,10 +40,10 @@ McpClientOptions options = new() // The default client now prefers the 2026-07-28 protocol (probing with server/discover and // falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios // specifically exercise the initialize handshake and SSE resumability (removed in the -// 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the -// latest stable version. Other scenarios run on the 2026-07-28 default and exercise the -// server/discover probe plus the transparent initialize-handshake fallback. -if (scenario is "initialize" or "sse-retry") +// 2026-07-28 protocol) and strictly expect initialize as the first message. The alpha.9 +// json-schema-ref-no-deref server also uses a bundled Node transport that rejects the draft +// protocol header before tools/list. Pin these scenarios to the latest stable version. +if (scenario is "initialize" or "sse-retry" or "json-schema-ref-no-deref") { options.ProtocolVersion = "2025-11-25"; } @@ -72,21 +72,10 @@ if (callbackPort == 0) var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback"); // Read conformance context for scenarios that provide additional data (e.g., pre-registered credentials). -string? preRegisteredClientId = null; -string? preRegisteredClientSecret = null; var conformanceContext = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_CONTEXT"); -if (!string.IsNullOrEmpty(conformanceContext)) -{ - using var doc = JsonDocument.Parse(conformanceContext); - if (doc.RootElement.TryGetProperty("client_id", out var clientIdEl)) - { - preRegisteredClientId = clientIdEl.GetString(); - } - if (doc.RootElement.TryGetProperty("client_secret", out var clientSecretEl)) - { - preRegisteredClientSecret = clientSecretEl.GetString(); - } -} +var parsedConformanceContext = ConformanceContext.Parse(conformanceContext); +var preRegisteredClientId = parsedConformanceContext?.GetString("client_id"); +var preRegisteredClientSecret = parsedConformanceContext?.GetString("client_secret"); var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions { @@ -110,12 +99,55 @@ else }; } -var clientTransport = new HttpClientTransport(new() +var endpointUri = new Uri(endpoint); +HttpClientTransport clientTransport; +if (scenario is "auth/client-credentials-basic" or "auth/client-credentials-jwt") { - Endpoint = new Uri(endpoint), - TransportMode = HttpTransportMode.StreamableHttp, - OAuth = oauthOptions, -}, loggerFactory: consoleLoggerFactory); + var context = parsedConformanceContext + ?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT."); + var accessToken = await ConformanceOAuthHelpers.AcquireClientCredentialsTokenAsync( + endpointUri, + context, + usePrivateKeyJwt: scenario == "auth/client-credentials-jwt", + CancellationToken.None); + clientTransport = new HttpClientTransport( + new() + { + Endpoint = endpointUri, + TransportMode = HttpTransportMode.StreamableHttp, + }, + ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken), + consoleLoggerFactory, + ownsHttpClient: true); +} +else if (scenario == "auth/enterprise-managed-authorization") +{ + var context = parsedConformanceContext + ?? throw new InvalidOperationException($"Scenario '{scenario}' requires MCP_CONFORMANCE_CONTEXT."); + var accessToken = await ConformanceOAuthHelpers.AcquireEnterpriseTokenAsync( + endpointUri, + context, + consoleLoggerFactory, + CancellationToken.None); + clientTransport = new HttpClientTransport( + new() + { + Endpoint = endpointUri, + TransportMode = HttpTransportMode.StreamableHttp, + }, + ConformanceOAuthHelpers.CreateBearerHttpClient(accessToken), + consoleLoggerFactory, + ownsHttpClient: true); +} +else +{ + clientTransport = new HttpClientTransport(new() + { + Endpoint = endpointUri, + TransportMode = HttpTransportMode.StreamableHttp, + OAuth = oauthOptions, + }, loggerFactory: consoleLoggerFactory); +} try { @@ -191,6 +223,19 @@ try } break; } + case "auth/authorization-server-migration": + { + await mcpClient.ListToolsAsync(); + await mcpClient.ListToolsAsync(); + break; + } + case "auth/client-credentials-basic": + case "auth/client-credentials-jwt": + case "auth/enterprise-managed-authorization": + { + await mcpClient.ListToolsAsync(); + break; + } case "http-standard-headers": { // List and call tools to test Mcp-Method and Mcp-Name headers diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs index cfff605..92e81c9 100644 --- a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -37,6 +37,7 @@ public sealed class IdentityAssertionGrantTests : IDisposable ["issuer"] = "https://auth.mcp-server.example.com", ["authorization_endpoint"] = "https://auth.mcp-server.example.com/authorize", ["token_endpoint"] = "https://auth.mcp-server.example.com/token", + ["token_endpoint_auth_methods_supported"] = new JsonArray("client_secret_basic"), }); } @@ -52,6 +53,10 @@ public sealed class IdentityAssertionGrantTests : IDisposable if (url.Contains("auth.mcp-server.example.com/token")) { + Assert.Equal("Basic", request.Headers.Authorization?.Scheme); + Assert.Equal( + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("mcp-client-id:mcp-client-secret")), + request.Headers.Authorization?.Parameter); return JsonResponse(HttpStatusCode.OK, new JsonObject { ["access_token"] = "final-access-token", @@ -67,6 +72,8 @@ public sealed class IdentityAssertionGrantTests : IDisposable new IdentityAssertionGrantProviderOptions { ClientId = "mcp-client-id", + ClientSecret = "mcp-client-secret", + TokenEndpointAuthMethod = "client_secret_basic", IdpTokenEndpoint = "https://idp.example.com/token", IdpClientId = "idp-client-id", IdTokenCallback = (context, ct) => @@ -88,6 +95,30 @@ public sealed class IdentityAssertionGrantTests : IDisposable Assert.Equal(3600, tokens.ExpiresIn); } + [Fact] + public Task IdentityAssertionGrantProvider_DefaultsToPostRegardlessOfMetadataOrder() => + AssertMcpTokenEndpointAuthenticationAsync( + new JsonArray("client_secret_basic", "client_secret_post"), + configuredMethod: null, + expectedAuthorizationScheme: null, + expectSecretInBody: true); + + [Fact] + public Task IdentityAssertionGrantProvider_FallsBackToBasicWhenPostIsUnavailable() => + AssertMcpTokenEndpointAuthenticationAsync( + new JsonArray("client_secret_basic"), + configuredMethod: null, + expectedAuthorizationScheme: "Basic", + expectSecretInBody: false); + + [Fact] + public Task IdentityAssertionGrantProvider_NoneDoesNotSendClientSecret() => + AssertMcpTokenEndpointAuthenticationAsync( + new JsonArray("none"), + configuredMethod: "none", + expectedAuthorizationScheme: null, + expectSecretInBody: false); + [Fact] public async Task IdentityAssertionGrantProvider_CachesTokens() { @@ -313,6 +344,39 @@ public sealed class IdentityAssertionGrantTests : IDisposable _httpClient)); } + [Theory] + [InlineData("client_secret_basic")] + [InlineData("client_secret_post")] + public void IdentityAssertionGrantProvider_TokenEndpointAuthMethodRequiresSecret_MissingClientSecret_ThrowsArgumentException(string tokenEndpointAuthMethod) + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("test"), + TokenEndpointAuthMethod = tokenEndpointAuthMethod, + ClientSecret = null, + }, + _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_UnsupportedTokenEndpointAuthMethod_ThrowsArgumentException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("test"), + TokenEndpointAuthMethod = "private_key_jwt", + }, + _httpClient)); + } + [Fact] public async Task IdentityAssertionGrantProvider_ConcurrentCallers_RunExchangeOnce() { @@ -434,6 +498,67 @@ public sealed class IdentityAssertionGrantTests : IDisposable #region Helpers + private async Task AssertMcpTokenEndpointAuthenticationAsync( + JsonArray supportedMethods, + string? configuredMethod, + string? expectedAuthorizationScheme, + bool expectSecretInBody) + { + _mockHandler.AsyncHandler = async request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["issuer"] = "https://auth.example.com", + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + ["token_endpoint_auth_methods_supported"] = supportedMethods, + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + Assert.Equal(expectedAuthorizationScheme, request.Headers.Authorization?.Scheme); + var body = await request.Content!.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Equal(expectSecretInBody, body.Contains("client_secret=mcp-client-secret", StringComparison.Ordinal)); + + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "final-access-token", + ["token_type"] = "Bearer", + }); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + ClientSecret = "mcp-client-secret", + TokenEndpointAuthMethod = configuredMethod, + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("mock-id-token"), + }, + _httpClient); + + var tokens = await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), + TestContext.Current.CancellationToken); + + Assert.Equal("final-access-token", tokens.AccessToken); + } + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload) { return new HttpResponseMessage(statusCode) From 15f8b2da110b574a1c20a35a8c629cea4095c7be Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Tue, 28 Jul 2026 14:06:46 -0700 Subject: [PATCH 28/58] Release v2.0.0 (#1758) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a8fe8421-1ce4-4a57-b4c4-3ada2d8ac67c --- README.md | 12 ++++++------ docs/concepts/tools/tools.md | 10 ++++++++++ docs/concepts/transports/transports.md | 2 +- src/Directory.Build.props | 4 ++-- src/PACKAGE.md | 14 +++++++------- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c7d6231..71902e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MCP C# SDK -[![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) +[![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. @@ -8,15 +8,15 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotoco The SDK packages are: -- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. +- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. -- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. +- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. -- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. -- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. -- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. +- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index f943d52..b975385 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -139,6 +139,16 @@ public static IEnumerable DescribeImage() } ``` +#### Structured content + +Set `UseStructuredContent = true` on or + to advertise an output schema and +serialize the return value into . +The output schema may be any JSON Schema 2020-12 document. Non-object return values are represented +directly, so a tool returning `72` produces `structuredContent: 72`, not +`structuredContent: { "result": 72 }`. Clients should read the value according to the advertised +output schema rather than assuming a `result` wrapper. + #### Content annotations Any content block can include to provide hints about the intended audience and priority: diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 63dcf78..6833193 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -309,7 +309,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // SSE requires stateful mode (the default). Set explicitly for forward compatibility. + // SSE requires stateful mode; opt in explicitly because stateless mode is the default. options.Stateless = false; #pragma warning disable MCP9004 // EnableLegacySse is obsolete diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 760b419..5a49e7b 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 - rc.2 + ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm @@ -18,7 +18,7 @@ $(RepoRoot)\Open.snk true true - 1.3.0 + 1.4.1 diff --git a/src/PACKAGE.md b/src/PACKAGE.md index a4c9472..3e050da 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -1,24 +1,24 @@ # MCP C# SDK -[![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) +[![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. -See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-rc.2) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0) for what's new in this version. ## Packages The SDK packages are: -- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. +- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. -- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. +- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. -- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. -- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. -- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. +- **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started From f8e9e1d23f687f471c54f4305d5f217ca6022aad Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Wed, 29 Jul 2026 12:31:53 -0700 Subject: [PATCH 29/58] Keep AutoDetect channel open after provisional SSE failure (#1768) Copilot-Session: c0e71f8e-9302-4c6b-835d-223dec24d37d --- .../Client/SseClientSessionTransport.cs | 20 +++- .../HttpClientTransportAutoDetectTests.cs | 110 ++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index 03969b4..99bdc1e 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -23,6 +23,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase private Task? _receiveTask; private readonly ILogger _logger; private readonly TaskCompletionSource _connectionEstablished; + private volatile bool _sseAdopted; /// /// SSE transport for a single session. Unlike stdio it does not launch a process, but connects to an existing server. @@ -107,6 +108,8 @@ internal sealed partial class SseClientSessionTransport : TransportBase throw HttpResponseMessageExtensions.CreateHttpRequestException(response, responseBody); } + + _sseAdopted = true; } private async Task CloseAsync() @@ -129,7 +132,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase } finally { - SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); } } @@ -190,7 +193,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase } else { - SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails + SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails { HttpStatusCode = failureStatusCode, Exception = ex, @@ -202,7 +205,18 @@ internal sealed partial class SseClientSessionTransport : TransportBase } finally { - SetDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + SetSseDisconnected(new ClientTransportClosedException(new HttpClientCompletionDetails())); + } + } + + private void SetSseDisconnected(Exception error) + { + // If AutoDetect is still probing SSE, leave its shared message channel open so it can + // retry with another transport. A successful POST means SSE was selected and owns the + // channel from that point on, matching Streamable HTTP's adoption behavior. + if (_options.TransportMode is not HttpTransportMode.AutoDetect || _sseAdopted) + { + SetDisconnected(error); } } diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index e6386ad..45919dd 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using Microsoft.Extensions.Logging; +using System.IO.Pipelines; using System.Net; namespace ModelContextProtocol.Tests.Transport; @@ -163,6 +164,115 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp Assert.NotNull(session); } + [Fact] + public async Task AutoDetectMode_WhenProvisionalSseFails_LeavesSharedMessageChannelOpen() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect shared channel test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var streamableHttpPostCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + if (request.Method == HttpMethod.Post && ++streamableHttpPostCount == 1) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Invalid session ID"), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}", + System.Text.Encoding.UTF8, + "application/json"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(2) }, + TestContext.Current.CancellationToken); + + var response = await session.MessageReader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(new RequestId(2), Assert.IsType(response).Id); + } + + [Fact] + public async Task AutoDetectMode_WhenAdoptedSseDisconnects_CompletesSharedMessageChannel() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect adopted SSE test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var ssePipe = new Pipe(); + var postCount = 0; + + await ssePipe.Writer.WriteAsync( + System.Text.Encoding.UTF8.GetBytes("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"), + TestContext.Current.CancellationToken); + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + var content = new StreamContent(ssePipe.Reader.AsStream()); + content.Headers.ContentType = new("text/event-stream"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + + if (request.Method == HttpMethod.Post && ++postCount == 1) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Streamable HTTP not supported"), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + await ssePipe.Writer.CompleteAsync(); + + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken)); + Assert.IsType(exception.Details); + } + // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1526 // When Streamable HTTP returns 415 (e.g. wrong Content-Type) and the SSE fallback also fails // (e.g. a Streamable-HTTP-only server returns 405 to the GET), the surfaced exception must From 5c305450cdac4ee58a23dfe52237c7d31a5cdf5c Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Wed, 29 Jul 2026 12:35:12 -0700 Subject: [PATCH 30/58] Preserve HTTP status codes across target frameworks (#1767) Copilot-Session: 9a49da98-c8d8-4b90-a6ed-50afe18ac981 --- src/Common/HttpResponseMessageExtensions.cs | 46 ++++++++++++++++++- .../AutoDetectingClientSessionTransport.cs | 12 ++--- .../HttpClientTransportAutoDetectTests.cs | 5 ++ 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/Common/HttpResponseMessageExtensions.cs b/src/Common/HttpResponseMessageExtensions.cs index 6acc6f7..d155e84 100644 --- a/src/Common/HttpResponseMessageExtensions.cs +++ b/src/Common/HttpResponseMessageExtensions.cs @@ -69,10 +69,52 @@ internal static class HttpResponseMessageExtensions ? $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase})." : $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase}). Response body: {responseBody}"; + return HttpRequestExceptionExtensions.Create(message, innerException: null, response.StatusCode); + } +} + +/// +/// Helpers for preserving HTTP status codes on across all target frameworks. +/// +internal static class HttpRequestExceptionExtensions +{ + internal const string StatusCodeDataKey = "ModelContextProtocol.HttpStatusCode"; + + /// + /// Creates an and preserves its HTTP status code. + /// + public static HttpRequestException Create(string message, Exception? innerException, HttpStatusCode? statusCode) + { #if NET - return new HttpRequestException(message, inner: null, response.StatusCode); + var exception = new HttpRequestException(message, innerException, statusCode); #else - return new HttpRequestException(message); + var exception = new HttpRequestException(message, innerException); #endif + + if (statusCode is not null) + { + exception.Data[StatusCodeDataKey] = statusCode.Value; + } + + return exception; + } + + /// + /// Gets the preserved HTTP status code from an . + /// + public static HttpStatusCode? GetStatusCode(this HttpRequestException exception) + { +#if NET + if (exception.StatusCode is { } statusCode) + { + return statusCode; + } +#endif + + return exception.Data[StatusCodeDataKey] switch + { + HttpStatusCode storedStatusCode => storedStatusCode, + _ => null, + }; } } diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index b19e8a8..c9e821a 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -146,14 +146,10 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport // keep HttpRequestException as the surfaced type so existing callers can still catch it and read StatusCode. await sseTransport.DisposeAsync().ConfigureAwait(false); LogSseFallbackFailedAfterStreamableHttp(_name, sseError); -#if NET - throw new HttpRequestException(streamableHttpError.Message, sseError, streamableHttpError.StatusCode); -#else - // net472 has no HttpRequestException overload that carries a status code, so this target - // preserves the status text in the message but not a programmatic StatusCode. Preserving the - // status code is intentionally net5+ only rather than an oversight. - throw new HttpRequestException(streamableHttpError.Message, sseError); -#endif + throw HttpRequestExceptionExtensions.Create( + streamableHttpError.Message, + sseError, + streamableHttpError.GetStatusCode()); } catch { diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index 45919dd..7100e72 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -102,6 +102,10 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp Assert.Contains("403", ex.Message); Assert.IsType(ex.InnerException); Assert.Contains("405", ex.InnerException.Message); + Assert.Equal(HttpStatusCode.Forbidden, ex.Data["ModelContextProtocol.HttpStatusCode"]); +#if NET + Assert.Equal(HttpStatusCode.Forbidden, ex.StatusCode); +#endif } [Fact] @@ -410,6 +414,7 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp var httpEx = Assert.IsType(ex); Assert.Contains("415", httpEx.Message); Assert.Contains(streamableHttpBody, httpEx.Message); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.Data["ModelContextProtocol.HttpStatusCode"]); #if NET Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.StatusCode); #endif From 155f619441a8142bc90afd972f90879eb0293ca0 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Wed, 29 Jul 2026 14:46:35 -0700 Subject: [PATCH 31/58] Speed up CLI argument escaping tests (#1773) Copilot-Session: 323a2062-9493-4be4-a710-f6c2b9e15a49 --- .../Program.cs | 7 ++++ .../Transport/StdioClientTransportTests.cs | 39 ++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index d04f216..6812fe5 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -34,6 +34,13 @@ internal static class Program private static async Task Main(string[] args) { + if (args.Contains("--echo-cli-arg-and-exit")) + { + Console.Error.WriteLine($"CLI_ARG:{JsonSerializer.Serialize(ParseCliArgument(args))}"); + Console.Error.Flush(); + return; + } + Log.Logger.Information("Starting server..."); string? cliArg = ParseCliArgument(args); diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index a709688..60ce9cf 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -152,7 +152,9 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log { Assert.Skip("mono runtime does not handle arguments ending with backslash correctly."); } - + + const string OutputPrefix = "CLI_ARG:"; + var capturedArgument = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); string cliArgument = $"--cli-arg={cliArgumentValue}"; string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); @@ -168,25 +170,34 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log }, Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch { - (true, _) => [testServerExecutable, cliArgument], - (_, true) => [cliArgument], - _ => [testServerDll, cliArgument], + (true, _) => [testServerExecutable, "--echo-cli-arg-and-exit", cliArgument], + (_, true) => ["--echo-cli-arg-and-exit", cliArgument], + _ => [testServerDll, "--echo-cli-arg-and-exit", cliArgument], + }, + StandardErrorLines = line => + { + if (line.StartsWith(OutputPrefix, StringComparison.Ordinal)) + { + capturedArgument.TrySetResult(line[OutputPrefix.Length..]); + } }, }; var transport = new StdioClientTransport(options, LoggerFactory); + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - // Act: Create client (handshake) and list tools to ensure full round trip works with the argument present. - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + string serializedArgument = await capturedArgument.Task.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken); + JsonElement parsedArgument = JsonElement.Parse(serializedArgument); + Assert.Equal(cliArgumentValue ?? "", parsedArgument.GetString()); - // Assert - Assert.NotNull(tools); - Assert.NotEmpty(tools); - - var result = await client.CallToolAsync("echoCliArg", cancellationToken: TestContext.Current.CancellationToken); - var content = Assert.IsType(Assert.Single(result.Content)); - Assert.Equal(cliArgumentValue ?? "", content.Text); + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken)); + var completionDetails = Assert.IsType(exception.Details); + Assert.Equal(0, completionDetails.ExitCode); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] From 79e13b3e2c35300551ee2af4642e5f35d468ceb5 Mon Sep 17 00:00:00 2001 From: Richard Boisvert Date: Fri, 31 Jul 2026 15:46:07 -0400 Subject: [PATCH 32/58] Fall back to initialize when server/discover fails at the HTTP layer (#1766) --- .../Client/McpClientImpl.cs | 12 ++ .../Client/July2026ProtocolFallbackTests.cs | 113 ++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 07dc0d5..a62ad0e 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Collections.Concurrent; +using System.Net; using System.Text.Json; using System.Text.Json.Nodes; @@ -387,6 +388,17 @@ internal sealed partial class McpClientImpl : McpClient // never reach here. fallbackToInitialize = true; } + catch (HttpRequestException ex) when ( + ex.GetStatusCode() is HttpStatusCode.BadRequest or HttpStatusCode.NotFound) + { + // A server predating SEP-2575 can reject the session-less server/discover POST at the + // HTTP layer instead of with a JSON-RPC error: 400 when it cannot parse the request, + // 404 when it requires Mcp-Session-Id on every non-initialize POST. A 400 carrying a + // structured JSON-RPC error is surfaced as McpProtocolException and handled above, so + // anything reaching here is plain or empty. Either way this is an initialize-handshake + // server, so fall back. Other statuses stay uncaught and surface to the caller. + fallbackToInitialize = true; + } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index 2cccd35..fed5df6 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -2,6 +2,8 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; using System.Diagnostics; +using System.Net; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Channels; @@ -207,6 +209,117 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout); } + [Theory] + [InlineData(HttpStatusCode.NotFound, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.NotFound, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.AutoDetect)] + public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize( + HttpStatusCode status, HttpTransportMode transportMode) + { + // A server predating SEP-2575 can reject the session-less server/discover probe at the HTTP layer + // rather than with a JSON-RPC error: 404 when it requires Mcp-Session-Id on every non-initialize + // POST, or a plain/empty 400 when it cannot parse the request. Both are initialize-handshake + // servers, so the connect must fall back instead of failing. + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateProbeRejectingServer( + status, "Invalid session ID", () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + + // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(initializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.AutoDetect)] + public async Task Client_OnOtherHttpErrorFromProbe_Surfaces_NoFallback( + HttpStatusCode status, HttpTransportMode transportMode) + { + // Only 400 and 404 are read as "this server needs the initialize handshake". Any other HTTP failure + // is a genuine transport error and must surface, so callers are not handed a misleading downstream + // error. Guards the deliberate narrowing of the status filter. + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateProbeRejectingServer( + status, "nope", () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + + await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.False(initializeReceived); + } + + private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransportMode transportMode) + => new(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = transportMode, + Name = "HTTP discover probe test client", + }, httpClient, LoggerFactory); + + /// + /// Mock Streamable HTTP server that rejects server/discover with + /// and, if the client falls back, completes an initialize handshake at 2025-11-25. + /// + private static Func> CreateProbeRejectingServer( + HttpStatusCode probeStatus, string probeBody, Action onInitialize) + => async request => + { + // The server offers no standalone SSE stream, which the spec permits. + // net472 does not populate a default Content, so every response sets one explicitly. + if (request.Method == HttpMethod.Get) + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("method", out var methodElement)) + return EmptyResponse(HttpStatusCode.Accepted); + + switch (methodElement.GetString()) + { + case RequestMethods.ServerDiscover: + return new HttpResponseMessage(probeStatus) { Content = new StringContent(probeBody) }; + + case RequestMethods.Initialize: + onInitialize(); + var id = doc.RootElement.GetProperty("id").GetRawText(); + var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + + "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result, Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "test-session"); + return response; + + default: + return EmptyResponse(HttpStatusCode.Accepted); + } + }; + + private static HttpResponseMessage EmptyResponse(HttpStatusCode status) + => new(status) { Content = new StringContent(string.Empty) }; + /// /// Minimal in-memory transport that simulates an initialize-handshake server: rejects /// server/discover (with a configurable JSON-RPC error code, or by From 81ae6ec6c7caaed4e57d9f1b164e0284f8fc2cef Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:31:48 -0700 Subject: [PATCH 33/58] Add public subscriptions/listen server handler (SEP-2575) (#1775) Co-authored-by: Tarek Mahmoud Sayed Copilot-Session: 80653343-5dcb-43cb-89b0-8ac8e572c4f7 --- .../Server/McpServerHandlers.cs | 54 ++++ .../Server/McpServerImpl.cs | 122 +++++++- .../McpServerBuilderExtensions.cs | 45 +++ .../StatelessServerTests.cs | 190 ++++++++++++ .../McpServerBuilderExtensionsHandlerTests.cs | 13 + .../Server/SubscriptionsListenHandlerTests.cs | 278 ++++++++++++++++++ 6 files changed, 689 insertions(+), 13 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index e871862..4e74cb9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -191,6 +191,60 @@ public sealed class McpServerHandlers /// public McpRequestHandler? UnsubscribeFromResourcesHandler { get; set; } + /// + /// Gets or sets the handler for requests (SEP-2575). + /// + /// + /// + /// subscriptions/listen is a long-lived request introduced by the 2026-07-28 protocol revision. The + /// held-open response is a solicited server-to-client stream: the server first acknowledges which + /// subscriptions it will honor and then streams matching notifications until the request is cancelled. + /// Setting this handler lets a server author own that stream directly to implement custom subscription + /// kinds, application-driven resources/updated delivery, or subscriptions backed by their own event + /// source. It is especially useful for stateless Streamable HTTP, where unsolicited notifications are + /// dropped (there is no session-wide channel) but the listen request's response stream can still carry + /// notifications for the duration of the request. + /// + /// + /// This is a full replacement for the built-in subscriptions/listen handler. When set, the + /// SDK does not track the subscription, does not send the acknowledgement, and does not perform any + /// automatic */list_changed fan-out for the request; the handler is solely responsible for the + /// entire lifetime of the stream. The SDK still enforces protocol-version gating: the handler is only + /// reached when the negotiated protocol revision is 2026-07-28 or later, and is otherwise rejected with + /// . + /// + /// + /// An implementation of this handler is responsible for: + /// + /// + /// + /// Sending exactly one before any + /// subscription events, reporting only the filters it actually honors. Advertised server capabilities must + /// match what the handler will actually deliver. + /// + /// + /// Tagging every streamed notification with the listen request id under + /// _meta[] so clients sharing a channel can demultiplex it. + /// + /// + /// Remaining active for the subscription lifetime and cleaning up when the supplied + /// is cancelled (client disconnect on HTTP, or + /// notifications/cancelled on stdio). + /// + /// + /// Returning when it deliberately completes the stream. + /// + /// + /// + /// Notifications are sent through the request's server (for example request.Server.SendMessageAsync), + /// which routes them over the request's own response stream. For extension filters not represented by + /// , the handler can inspect + /// request.JsonRpcRequest.Params. Application services and event buses can be resolved from + /// request.Services or captured by the handler delegate. + /// + /// + public McpRequestHandler? SubscriptionsListenHandler { get; set; } + /// /// Gets or sets the handler for requests. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f68aacd..2ce8387 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -117,8 +117,11 @@ internal sealed partial class McpServerImpl : McpServer } // A stateful session can push unsolicited list-changed notifications, so subscribe to the - // collection change events. A stateless HTTP server cannot send unsolicited notifications, so - // instead suppress the listChanged capability it would otherwise advertise. + // collection change events. A stateless HTTP server cannot push unsolicited notifications; whether it + // may still advertise the listChanged capability (over a custom subscriptions/listen stream to a + // 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here, + // because the same ServerCapabilities feeds both the legacy initialize handshake (which can never + // deliver it) and server/discover (which can, given a custom handler). if (HasStatefulTransport()) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); @@ -136,15 +139,6 @@ internal sealed partial class McpServerImpl : McpServer } } } - else - { - if (ServerCapabilities.Tools is not null) - ServerCapabilities.Tools.ListChanged = null; - if (ServerCapabilities.Prompts is not null) - ServerCapabilities.Prompts.ListChanged = null; - if (ServerCapabilities.Resources is not null) - ServerCapabilities.Resources.ListChanged = null; - } // And initialize the session. The built-in protocol metadata filters run ahead of any // user-supplied message filters. @@ -516,6 +510,46 @@ internal sealed partial class McpServerImpl : McpServer /// public ServerCapabilities ServerCapabilities { get; } + /// + /// Returns the to advertise in a specific response, suppressing the + /// listChanged flags the server has no way to honor. + /// + /// + /// when the client this response targets can receive */list_changed + /// notifications over a subscriptions/listen stream. + /// + /// + /// A stateless HTTP server has no session-wide channel to push unsolicited */list_changed + /// notifications. It can only deliver them over a subscriptions/listen stream, which requires both + /// a 2026-07-28+ client (so the request is reachable at all) and a custom + /// to own that stream (the built-in stateless + /// handler grants no notifications). When neither the transport is stateful nor that stream can carry + /// them, the listChanged flags are dropped so the server never advertises a capability it cannot + /// deliver. Everything else (for example resources.subscribe) is preserved. + /// + private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged) + { + if (HasStatefulTransport() || listenStreamCanDeliverListChanged) + { + return ServerCapabilities; + } + + // Copy onto a fresh instance so the shared ServerCapabilities keeps the authored listChanged flags; + // server/discover with a custom listen handler may still advertise them. + return new ServerCapabilities + { + Experimental = ServerCapabilities.Experimental, + Logging = ServerCapabilities.Logging, + Completions = ServerCapabilities.Completions, + Extensions = ServerCapabilities.Extensions, + Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null }, + Resources = ServerCapabilities.Resources is { } resources + ? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } + : null, + Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null }, + }; + } + /// public override ClientCapabilities? ClientCapabilities => _clientCapabilities; @@ -667,7 +701,11 @@ internal sealed partial class McpServerImpl : McpServer ProtocolVersion = negotiatedProtocolVersion, Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, - Capabilities = ServerCapabilities ?? new(), + + // The initialize handshake only serves pre-2026-07-28 clients, which cannot open a + // subscriptions/listen stream, so a stateless server has no way to deliver list-changed + // notifications to them regardless of any custom handler. + Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false), // resultType is a 2026-07-28 result field. The initialize handshake is only available on // 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw @@ -694,7 +732,13 @@ internal sealed partial class McpServerImpl : McpServer return new ValueTask(new DiscoverResult { SupportedVersions = [.. _perRequestMetadataProtocolVersions], - Capabilities = ServerCapabilities ?? new(), + + // server/discover only serves 2026-07-28+ clients, which can open a subscriptions/listen + // stream. A stateless server can therefore still deliver list-changed notifications if the + // author supplied a custom handler to own that stream (the built-in stateless handler + // grants nothing, so it cannot). + Capabilities = GetAdvertisedCapabilities( + listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null), Instructions = options.ServerInstructions, // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to // the safest values (immediately stale, not shareable) so existing servers keep @@ -722,9 +766,61 @@ internal sealed partial class McpServerImpl : McpServer /// Subscription-bound notifications carry the listen request's id in their /// _meta/io.modelcontextprotocol/subscriptionId field per SEP-2575 so clients can demultiplex. /// + /// + /// A server author may supply a custom to take + /// over the stream entirely; see the design notes at the top of this method for the behavior. + /// /// private void ConfigureSubscriptions(McpServerOptions options) { + // Design decision 1 of issue #1662 (replacement vs. additive handler): a custom + // SubscriptionsListenHandler is a FULL REPLACEMENT for the built-in subscriptions/listen handler, not + // an additive/composed one. When one is set, that handler exclusively owns the stream: the SDK does + // not track the subscription in _activeSubscriptions, does not send the acknowledgement, and performs + // no automatic */list_changed fan-out for the request. This keeps the SEP-2575 contract trivial to + // honor (exactly one acknowledgement, no duplicate delivery) and mirrors the existing low-level + // replacement handlers such as CallToolWithAlternateHandler. An additive design was rejected because + // two writers on one stream create ambiguity over who sends the single acknowledgement, force the two + // lifetimes to be coordinated, and risk double-tagging the subscription id. + if (options.Handlers.SubscriptionsListenHandler is { } subscriptionsListenHandler) + { + // Route the custom handler through SetHandler so it receives the same DestinationBoundMcpServer as + // every other typed handler. That server sends notifications over this request's own response + // stream (its RelatedTransport), which is what lets the handler stream even under stateless + // Streamable HTTP, where the held-open POST response is the only solicited server-to-client + // channel (the core scenario of issue #1662). Going through SetHandler also applies the standard + // 2026-07-28 resultType stamping and provides the request-scoped service provider via + // request.Services. + SetHandler(RequestMethods.SubscriptionsListen, + (request, cancellationToken) => + { + // Protocol-version gating stays in the SDK rather than the custom handler, so a custom + // handler can never be reached on a revision that predates SEP-2575. subscriptions/listen + // is a 2026-07-28 feature; on older negotiated revisions it is rejected as an unknown + // method, exactly as the built-in handler below does. + if (!IsJuly2026OrLaterProtocolRequest(request.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); + } + + // Notifications is 'required', but that only enforces presence during deserialization, + // not non-nullness: a '{"notifications": null}' payload produces a non-null params object + // with a null Notifications (DefaultOptions does not set RespectNullableAnnotations). + // Normalize null to empty so a custom handler can dereference request.Params.Notifications + // without an NRE, matching the built-in handler's request?.Notifications guard below. + request.Params ??= new SubscriptionsListenRequestParams { Notifications = new() }; + request.Params.Notifications ??= new SubscriptionsListenNotifications(); + + return subscriptionsListenHandler(request, cancellationToken); + }, + McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, + McpJsonUtilities.JsonContext.Default.EmptyResult); + return; + } + _requestHandlers.Set(RequestMethods.SubscriptionsListen, async (request, jsonRpcRequest, cancellationToken) => { diff --git a/src/ModelContextProtocol/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/McpServerBuilderExtensions.cs index 7f07be6..7e24bc5 100644 --- a/src/ModelContextProtocol/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpServerBuilderExtensions.cs @@ -859,6 +859,51 @@ public static partial class McpServerBuilderExtensions return builder; } + /// + /// Configures a handler for subscriptions/listen requests (SEP-2575), taking over the long-lived + /// subscription stream introduced by the 2026-07-28 protocol revision. + /// + /// The server builder instance. + /// The handler that owns the subscription stream for the lifetime of the request. + /// The builder provided in . + /// is . + /// + /// + /// subscriptions/listen is a long-lived request whose held-open response is a solicited + /// server-to-client stream. Providing a handler here lets a server author own that stream to implement + /// custom subscription kinds, application-driven resources/updated delivery, or subscriptions backed + /// by their own event source. It is especially useful for stateless Streamable HTTP, where unsolicited + /// notifications are dropped but the listen request's response stream can still carry notifications for the + /// duration of the request. + /// + /// + /// This is a full replacement for the SDK's built-in subscriptions/listen handling. When set, the + /// handler alone is responsible for sending exactly one + /// before any events, tagging every + /// streamed notification with the listen request id under _meta[], + /// staying active until the supplied is cancelled, and returning + /// when it completes. See + /// for the full contract. + /// + /// + /// Unlike , this method intentionally does not advertise any + /// server capabilities on the author's behalf. The set of notifications a listen handler honors is decided + /// at runtime and can include custom kinds not represented by a capability flag, so the author must + /// configure only the capabilities their handler will actually deliver. Advertised capabilities must match + /// what the handler delivers. + /// + /// + public static IMcpServerBuilder WithSubscriptionsListenHandler(this IMcpServerBuilder builder, McpRequestHandler handler) + { + Throw.IfNull(builder); + + builder.Services.Configure(options => + { + options.Handlers.SubscriptionsListenHandler = handler; + }); + return builder; + } + /// /// Configures a handler for processing logging level change requests from clients. /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 52b0d36..092f8f2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -380,6 +380,196 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem Assert.Null(grantedNotifications["resourceSubscriptions"]); } + [Fact] + public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_StreamsNotificationOverHeldOpenPost() + { + // The built-in stateless handler grants nothing and returns immediately because there is no + // session-wide channel. A custom SubscriptionsListenHandler can instead stream notifications over the + // held-open POST response (the listen request's RelatedTransport), which is the solicited + // server-to-client stream. This is the core scenario of issue #1662. + const string subscribedUri = "resource://test"; + + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithSubscriptionsListenHandler(async (request, cancellationToken) => + { + var subscriptionId = request.JsonRpcRequest.Id; + + var ack = new JsonRpcNotification + { + Method = NotificationMethods.SubscriptionsAcknowledgedNotification, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ResourceSubscriptions = request.Params.Notifications.ResourceSubscriptions, + }, + }, + McpJsonUtilities.DefaultOptions), + }; + TagWithSubscriptionId(ack, subscriptionId); + await request.Server.SendMessageAsync(ack, cancellationToken); + + var updated = new JsonRpcNotification + { + Method = NotificationMethods.ResourceUpdatedNotification, + Params = new JsonObject { ["uri"] = subscribedUri }, + }; + TagWithSubscriptionId(updated, subscriptionId); + await request.Server.SendMessageAsync(updated, cancellationToken); + + // Complete the stream so the POST response finishes; the buffered notifications flush to the client. + return new EmptyResult(); + }); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + await using var client = await ConnectMcpClientAsync(); + + var ackChannel = Channel.CreateUnbounded(); + var updatedChannel = Channel.CreateUnbounded(); + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification, + (notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; }); + + var listenRequest = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { ResourceSubscriptions = [subscribedUri] }, + }, + McpJsonUtilities.DefaultOptions), + }; + + await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var subscriptionId = GetSubscriptionId(ack); + Assert.NotNull(subscriptionId); + + var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(subscriptionId, GetSubscriptionId(updated)); + Assert.Equal(subscribedUri, Assert.IsType(updated.Params)["uri"]?.GetValue()); + } + + [Fact] + public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_AdvertisesAndStreamsListChanged() + { + // resources/updated rides on resources.subscribe, which is never suppressed, so it cannot prove the + // listChanged capability is advertised. A custom SubscriptionsListenHandler gives a stateless server a + // way to deliver */list_changed over the held-open POST, so server/discover (the only path a + // 2026-07-28+ client uses) must advertise tools.listChanged rather than dropping it (issue #1662). + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) + .WithSubscriptionsListenHandler(async (request, cancellationToken) => + { + var subscriptionId = request.JsonRpcRequest.Id; + + var ack = new JsonRpcNotification + { + Method = NotificationMethods.SubscriptionsAcknowledgedNotification, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = request.Params.Notifications.ToolsListChanged, + }, + }, + McpJsonUtilities.DefaultOptions), + }; + TagWithSubscriptionId(ack, subscriptionId); + await request.Server.SendMessageAsync(ack, cancellationToken); + + var listChanged = new JsonRpcNotification { Method = NotificationMethods.ToolListChangedNotification }; + TagWithSubscriptionId(listChanged, subscriptionId); + await request.Server.SendMessageAsync(listChanged, cancellationToken); + + return new EmptyResult(); + }); + + // Advertise tools.listChanged so the per-response capability decision has something to preserve. + Builder.Services.Configure(options => + { + options.Capabilities ??= new(); + options.Capabilities.Tools ??= new(); + options.Capabilities.Tools.ListChanged = true; + }); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + await using var client = await ConnectMcpClientAsync(); + + // The stateless server can now deliver tools/list_changed over the custom listen stream, so the + // capability must survive on the server/discover response instead of being cleared. + Assert.True(client.ServerCapabilities.Tools?.ListChanged); + + var listChangedChannel = Channel.CreateUnbounded(); + await using var listChangedReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { listChangedChannel.Writer.TryWrite(notification); return default; }); + + var listenRequest = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { ToolsListChanged = true }, + }, + McpJsonUtilities.DefaultOptions), + }; + + await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + var listChangedNotification = await listChangedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.NotNull(GetSubscriptionId(listChangedNotification)); + } + + private static string? GetSubscriptionId(JsonRpcNotification notification) + => ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString(); + + private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId) + { + var paramsObject = notification.Params as JsonObject ?? new JsonObject(); + if (paramsObject["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + paramsObject["_meta"] = meta; + } + + meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch + { + string stringId => JsonValue.Create(stringId), + long longId => JsonValue.Create(longId), + _ => null, + }; + + notification.Params = paramsObject; + } + [McpServerTool(Name = "testSamplingErrors")] public static async Task TestSamplingErrors(McpServer server) { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs index e61c442..be965e7 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs @@ -147,4 +147,17 @@ public class McpServerBuilderExtensionsHandlerTests Assert.Equal(handler, options.Handlers.UnsubscribeFromResourcesHandler); } + + [Fact] + public void WithSubscriptionsListenHandler_Sets_Handler() + { + McpRequestHandler handler = async (context, token) => new EmptyResult(); + + _builder.Object.WithSubscriptionsListenHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.Handlers.SubscriptionsListenHandler); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs new file mode 100644 index 0000000..81e0619 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenHandlerTests.cs @@ -0,0 +1,278 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the custom (SEP-2575, issue #1662). +/// A custom handler is a full replacement for the built-in subscriptions/listen handling: it owns the +/// stream, sends the acknowledgement itself, streams application-defined notifications, and receives no +/// automatic */list_changed fan-out. These tests run over the in-memory stream transport exercised by +/// . +/// +public class SubscriptionsListenHandlerTests : ClientServerTestBase +{ + private const string CustomResourceUri = "custom://event/1"; + + // Signalled after the custom handler has sent its acknowledgement and first notification and is waiting + // for cancellation. Lets cancellation tests wait until the handler is actually holding the stream open. + private readonly TaskCompletionSource _handlerHoldingStream = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // Signalled from the custom handler's finally block, proving it observed cancellation and cleaned up. + private readonly TaskCompletionSource _handlerCleanedUp = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public SubscriptionsListenHandlerTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // Register a tool so the server advertises tools.listChanged; this lets the replacement test below + // trigger a collection change and prove the built-in fan-out no longer runs for the custom handler. + mcpServerBuilder.WithTools(); + + mcpServerBuilder.WithSubscriptionsListenHandler(async (request, cancellationToken) => + { + var subscriptionId = request.JsonRpcRequest.Id; + + // SEP-2575 requires the acknowledgement to be the first message on the stream. The custom handler + // owns this: it echoes back the requested filters as granted and tags the ack with the id. + var ack = new JsonRpcNotification + { + Method = NotificationMethods.SubscriptionsAcknowledgedNotification, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams { Notifications = request.Params.Notifications }, + McpJsonUtilities.DefaultOptions), + }; + TagWithSubscriptionId(ack, subscriptionId); + await request.Server.SendMessageAsync(ack, cancellationToken); + + // Stream one application-defined notification the built-in handler could never produce on its own, + // proving the handler drives the stream. Tagged with the same subscription id. + var updated = new JsonRpcNotification + { + Method = NotificationMethods.ResourceUpdatedNotification, + Params = new JsonObject { ["uri"] = CustomResourceUri }, + }; + TagWithSubscriptionId(updated, subscriptionId); + await request.Server.SendMessageAsync(updated, cancellationToken); + + _handlerHoldingStream.TrySetResult(true); + + try + { + // Remain active for the subscription lifetime, exactly like the built-in handler, until the + // request-scoped token is cancelled (notifications/cancelled on stdio, client disconnect on HTTP). + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(true), cancelled); + await cancelled.Task; + } + finally + { + _handlerCleanedUp.TrySetResult(true); + } + + return new EmptyResult(); + }); + } + + [Fact] + public async Task CustomHandler_July2026_SendsAcknowledgementThenTaggedNotification() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + // Capture the acknowledgement and the streamed notification on separate channels. Both must arrive + // tagged with the subscription id. The test does not assert cross-notification arrival order because + // the client dispatches incoming messages concurrently (see McpSessionHandler.ProcessMessagesCoreAsync), + // so handler invocation order is not observable; acknowledgement-first is a server-side/wire guarantee. + var ackChannel = Channel.CreateUnbounded(); + var updatedChannel = Channel.CreateUnbounded(); + + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification, + (notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; }); + + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var listenTask = SendSubscriptionsListenAsync( + client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token); + + // The acknowledgement carries the subscription id. + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var subscriptionId = GetSubscriptionId(ack); + Assert.NotNull(subscriptionId); + + // The custom application notification is tagged with the same subscription id. + var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(subscriptionId, GetSubscriptionId(updated)); + Assert.Equal(CustomResourceUri, (updated.Params as JsonObject)?["uri"]?.GetValue()); + + await CancelSubscriptionAsync(listenCts, listenTask); + } + + [Fact] + public async Task CustomHandler_ReplacesBuiltIn_SuppressesAutomaticListChangedFanOut() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + var ackChannel = Channel.CreateUnbounded(); + var toolsChannel = Channel.CreateUnbounded(); + + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + // Request tools/list_changed. The built-in handler would fan these out; the custom handler replaces it + // and never tracks the subscription, so the SDK must deliver nothing automatically for this change. + var listenTask = SendSubscriptionsListenAsync( + client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token); + + // Wait until the custom handler is holding the stream open (ack + first notification already sent). + await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken); + await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + + // Mutate the tool collection. With the built-in handler this would deliver a tagged tools/list_changed; + // under the custom replacement handler it must not, because _activeSubscriptions is never populated. + // The list-changed fan-out iterates that (empty) set and completes synchronously during Add, so it + // buffers nothing to send. The ListToolsAsync round-trip then flushes the client read pipeline. + var serverOptions = ServiceProvider.GetRequiredService>().Value; + serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + await CancelSubscriptionAsync(listenCts, listenTask); + + // Completed-and-empty proves nothing was ever delivered, not merely "nothing buffered right now": + // WaitToReadAsync returns false only when the channel is both empty and completed. + toolsChannel.Writer.Complete(); + Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CustomHandler_PreJuly2026_IsRejectedWithMethodNotFound() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + }); + + var request = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams { Notifications = new SubscriptionsListenNotifications { ResourcesListChanged = true } }, + McpJsonUtilities.DefaultOptions), + }; + + var ex = await Assert.ThrowsAsync(() => + client.SendRequestAsync(request, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task CustomHandler_OnCancellation_ObservesTokenAndCleansUp() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }); + + // Use an explicit request id so the test can address it in a notifications/cancelled message, + // which is the transport-level cancellation signal for a long-lived subscriptions/listen on stdio + // (an HTTP client would instead disconnect, which cancels the same request-scoped token). + var subscriptionId = new RequestId("listen-cancel-1"); + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var listenTask = SendSubscriptionsListenAsync( + client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token, subscriptionId); + + // Ensure the handler is actively holding the stream before cancelling. + await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Deterministically drive server-side cancellation by sending notifications/cancelled for the request + // id. The server routes this to the request-scoped token the handler is observing. + await client.SendMessageAsync(new JsonRpcNotification + { + Method = NotificationMethods.CancelledNotification, + Params = JsonSerializer.SerializeToNode( + new CancelledNotificationParams { RequestId = subscriptionId }, + McpJsonUtilities.DefaultOptions), + }, TestContext.Current.CancellationToken); + + // The handler observed the token: its cancellation registration ran and its finally block completed. + await _handlerCleanedUp.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // A cancelled request sends no response, so unblock the client side and confirm it observes cancellation. + await CancelSubscriptionAsync(listenCts, listenTask); + } + + private static Task SendSubscriptionsListenAsync( + McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken, RequestId requestId = default) + { + var request = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams { Notifications = notifications }, + McpJsonUtilities.DefaultOptions), + }; + + if (requestId.Id is not null) + { + request.Id = requestId; + } + + return client.SendRequestAsync(request, cancellationToken); + } + + private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask) + { + await listenCts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => listenTask); + } + + private static string? GetSubscriptionId(JsonRpcNotification notification) + => ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString(); + + private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId) + { + var paramsObject = notification.Params as JsonObject ?? new JsonObject(); + if (paramsObject["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + paramsObject["_meta"] = meta; + } + + meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch + { + string stringId => JsonValue.Create(stringId), + long longId => JsonValue.Create(longId), + _ => null, + }; + + notification.Params = paramsObject; + } + + private sealed class ListenTools + { + [McpServerTool, System.ComponentModel.Description("Echoes the input back to the caller.")] + public static string Echo([System.ComponentModel.Description("The message to echo.")] string message) => message; + } +} From 21704df1ac6a2b474b5d32ed5730f31d06761abf Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Tue, 4 Aug 2026 11:58:44 -0700 Subject: [PATCH 34/58] Clarify AI disclosure format with > [!NOTE] alert example (#1771) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32abcbe1-12f4-423d-ba33-fc4d896ca3c8 --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a93a1b3..db18051 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,7 +9,7 @@ This repository contains the official C# SDK for the Model Context Protocol (MCP - Never chain commit and push in the same command. Commit first, report what was committed, then wait for explicit push instructions. This creates a mandatory decision point. ### AI-Generated Content Disclosure -When posting any content to GitHub under a user's credentials — opening pull requests, creating issues, commenting on pull requests or issues, posting review comments, or taking any other public-facing action — include a concise, visible note at the bottom indicating that the content was AI/Copilot-generated. +When posting any content to GitHub under a user's credentials — opening pull requests, creating issues, commenting on pull requests or issues, posting review comments, or taking any other public-facing action — include a concise, visible note (e.g. a `> [!NOTE]` alert) at the bottom indicating that the content was AI/Copilot-generated. This disclosure is not required when: - The account is a recognized bot or Copilot app account (for example, `github-actions[bot]` or `copilot`), where the AI origin is already apparent from the account identity. From 87f5b09b24928912e5033c4ea1b9c2640579c06e Mon Sep 17 00:00:00 2001 From: Akbar Dizaji <72616525+AkbarDizaji@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:40:52 +0200 Subject: [PATCH 35/58] Add README for the InMemoryTransport sample (#1769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Akbar Dızajı Co-authored-by: Claude Fable 5 --- samples/InMemoryTransport/README.md | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 samples/InMemoryTransport/README.md diff --git a/samples/InMemoryTransport/README.md b/samples/InMemoryTransport/README.md new file mode 100644 index 0000000..e350144 --- /dev/null +++ b/samples/InMemoryTransport/README.md @@ -0,0 +1,42 @@ +# In-Memory Transport Sample + +Demonstrates connecting an MCP client and server in the same process using stream-based +transports over in-memory pipes (`System.IO.Pipelines`) — no child process, no network. + +The server is created directly with `McpServer.Create`, without a host or dependency +injection container, and exposes a single `Echo` tool defined from a delegate with +`McpServerTool.Create`. The client connects over the same pipe pair with +`StreamClientTransport`, lists the server's tools, and invokes the tool. + +This pattern is useful for testing MCP servers, embedding a server inside a larger +application, or running a client and server in the same process without transport overhead. +See [Transports: In-memory transport](../../docs/concepts/transports/transports.md) for more +background. + +## Run + +```bash +dotnet run --project samples/InMemoryTransport/InMemoryTransport.csproj +``` + +Expected output: + +``` +Tool Name: Echo + +Echo: Hello World +``` + +## Key files + +- [`Program.cs`](Program.cs) — the entire sample: pipe setup, server creation, client + connection, tool listing, and tool invocation. + +## Notes + +- `StreamServerTransport` and `StreamClientTransport` work with any `Stream`. This sample + wires them to a pair of `Pipe` instances, one per direction; the client's output stream is + the server's input stream and vice versa. +- The server is started with a fire-and-forget `server.RunAsync()` because both endpoints + live in the same process. `await using` on the server and client ensures both are disposed + when the program exits. From ce0cf91a57fd7fc3e5cbc4ecf7bd32c0f03453d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:48:06 -0700 Subject: [PATCH 36/58] Bump the npm_and_yarn group across 1 directory with 6 updates (#1787) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 90 +++++++++++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4eeb85e..73d7e0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,12 +52,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -342,20 +342,20 @@ "license": "Apache-2.0" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -365,6 +365,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -694,9 +707,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -831,9 +844,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -888,9 +901,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -1414,23 +1427,40 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" From f5d54b5c2978f426e823b57685df576f16e8f42b Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Wed, 5 Aug 2026 06:25:19 +0700 Subject: [PATCH 37/58] Add Application Insights telemetry example (#1778) --- Directory.Packages.props | 1 + .../AspNetCoreMcpServer.csproj | 1 + samples/AspNetCoreMcpServer/Program.cs | 19 ++++++-- samples/AspNetCoreMcpServer/README.md | 48 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 samples/AspNetCoreMcpServer/README.md diff --git a/Directory.Packages.props b/Directory.Packages.props index d204d87..3d25bff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,6 +64,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj index 29aebc0..ca64119 100644 --- a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj +++ b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj @@ -15,6 +15,7 @@ + diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index f35d0ef..3441083 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,3 +1,4 @@ +using Azure.Monitor.OpenTelemetry.AspNetCore; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -34,15 +35,27 @@ builder.Services.AddMcpServer() .WithTools() .WithResources(); -builder.Services.AddOpenTelemetry() +var telemetry = builder.Services.AddOpenTelemetry() .WithTracing(b => b.AddSource("*") .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) .WithMetrics(b => b.AddMeter("*") .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) - .WithLogging() - .UseOtlpExporter(); + .WithLogging(); + +string? applicationInsightsConnectionString = + builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + +if (string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) +{ + telemetry.UseOtlpExporter(); +} +else +{ + telemetry.UseAzureMonitor(options => + options.ConnectionString = applicationInsightsConnectionString); +} // Configure HttpClientFactory for weather.gov API builder.Services.AddHttpClient("WeatherApi", client => diff --git a/samples/AspNetCoreMcpServer/README.md b/samples/AspNetCoreMcpServer/README.md new file mode 100644 index 0000000..9ec124d --- /dev/null +++ b/samples/AspNetCoreMcpServer/README.md @@ -0,0 +1,48 @@ +# ASP.NET Core MCP server sample + +This sample hosts an MCP server with Streamable HTTP and configures OpenTelemetry for MCP operations, ASP.NET Core requests, outgoing HTTP requests, metrics, and structured logs. + +## Export to Application Insights + +Set the Application Insights connection string and run the sample: + +```bash +export APPLICATIONINSIGHTS_CONNECTION_STRING='InstrumentationKey=;IngestionEndpoint=https://.in.applicationinsights.azure.com/' +dotnet run --project samples/AspNetCoreMcpServer +``` + +PowerShell: + +```powershell +$env:APPLICATIONINSIGHTS_CONNECTION_STRING = 'InstrumentationKey=;IngestionEndpoint=https://.in.applicationinsights.azure.com/' +dotnet run --project samples/AspNetCoreMcpServer +``` + +The connection string is read through ASP.NET Core configuration. Keep it outside source control and use your deployment platform's secret configuration in production. + +When `APPLICATIONINSIGHTS_CONNECTION_STRING` is absent or empty, the sample retains its default OTLP exporter. Configure that path with standard `OTEL_EXPORTER_OTLP_*` environment variables. + +## Signals and MCP attributes + +Application Insights receives the signals already emitted by the SDK and the configured ASP.NET Core/OpenTelemetry instrumentation: + +- server activities appear as requests and client activities appear as dependencies; +- structured logs appear as traces; +- MCP histograms appear as custom metrics; +- HTTP server and client instrumentation provides the surrounding transport spans. + +MCP activities can include attributes such as `mcp.method.name`, `mcp.protocol.version`, `mcp.session.id`, `jsonrpc.request.id`, and `gen_ai.tool.name`. The exact attributes depend on the operation and whether sensitive-data telemetry is enabled. + +For example, query MCP request and dependency telemetry in Logs: + +```kusto +union withsource=TelemetryType requests, dependencies +| extend McpMethod = tostring(customDimensions["mcp.method.name"]), + ToolName = tostring(customDimensions["gen_ai.tool.name"]), + McpSession = tostring(customDimensions["mcp.session.id"]) +| where isnotempty(McpMethod) +| project timestamp, TelemetryType, name, success, resultCode, McpMethod, ToolName, McpSession +| order by timestamp desc +``` + +Invalid tool calls, malformed JSON-RPC messages, and other failures appear only when the current SDK or ASP.NET Core instrumentation emits a log or activity for that path. This sample exports existing telemetry; it does not add custom protocol events or per-tool instrumentation. From aa07c6fcca096ac8ffa96ed43ff7acabe131d296 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Wed, 5 Aug 2026 06:32:07 +0700 Subject: [PATCH 38/58] Avoid reading Configuration in Directory.Build.props (#1776) --- Directory.Build.props | 11 ----------- Directory.Build.targets | 8 ++++++++ global.json | 2 +- src/Directory.Build.targets | 2 ++ 4 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 Directory.Build.targets diff --git a/Directory.Build.props b/Directory.Build.props index f5cdd3a..8bf8354 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,30 +11,19 @@ - - - Debug - $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)')) $([MSBuild]::NormalizeDirectory('$(RepoRoot)', 'artifacts')) $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'obj')) $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'bin')) - $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestResults', '$(Configuration)')) - $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'packages', '$(Configuration)')) $(MSBuildProjectName) $([System.IO.Path]::GetFullPath('$(ArtifactsObjDir)$(OutDirName)\')) - $(BaseIntermediateOutputPath)$(Configuration)\ $([System.IO.Path]::GetFullPath('$(ArtifactsBinDir)$(OutDirName)\')) - $(BaseOutputPath)$(Configuration)\ - - $(ArtifactsPackagesDir) trx%3bLogFileName=$(MSBuildProjectName).$(TargetFramework).$(OS).trx - $(ArtifactsTestResultsDir) diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..d545f8b --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestResults', '$(Configuration)')) + $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'packages', '$(Configuration)')) + $(ArtifactsPackagesDir) + $(ArtifactsTestResultsDir) + + diff --git a/global.json b/global.json index fcb4599..9e13a61 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100", + "version": "10.0.101", "rollForward": "minor" } } diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ee635a5..0c3c509 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,4 +1,6 @@ + + (json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var embeddedResource = Assert.IsType(Assert.Single(deserialized.Content)); + var pdfResource = Assert.IsType(embeddedResource.Resource); + Assert.Equal("file:///mypdf.pdf", pdfResource.Uri); + Assert.Equal("application/pdf", pdfResource.MimeType); + Assert.Equal(pdfBytes, pdfResource.DecodedData.ToArray()); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index d1f8443..e54f40d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.Reflection; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -977,6 +978,84 @@ public class McpServerTests : LoggedTest }); } + [Fact] + public async Task Can_Handle_Call_Tool_Requests_With_Embedded_Pdf_Resource_On_Wire() + { + byte[] pdfBytes = Encoding.ASCII.GetBytes("%PDF-1.7\n"); + await using var transport = new TestServerTransport(); + var options = CreateOptions(new ServerCapabilities { Tools = new() }); + options.Handlers.CallToolHandler = async (request, ct) => + { + return new CallToolResult + { + Content = + [ + new EmbeddedResourceBlock + { + Resource = BlobResourceContents.FromBytes( + pdfBytes, + "file:///mypdf.pdf", + "application/pdf") + } + ] + }; + }; + options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException(); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + var runTask = server.RunAsync(TestContext.Current.CancellationToken); + var receivedMessage = new TaskCompletionSource(); + + transport.OnMessageSent = message => + { + if (message is JsonRpcResponse response && response.Id.ToString() == "55") + { + receivedMessage.SetResult(response); + } + }; + + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Id = new RequestId(55) + }, + TestContext.Current.CancellationToken); + + var response = await receivedMessage.Task.WaitAsync( + TestConstants.DefaultTimeout, + TestContext.Current.CancellationToken); + string wireJson = JsonSerializer.Serialize( + response, + McpJsonUtilities.DefaultOptions); + + using JsonDocument document = JsonDocument.Parse(wireJson); + JsonElement root = document.RootElement; + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + Assert.Equal(55, root.GetProperty("id").GetInt32()); + + JsonElement resourceBlock = root.GetProperty("result").GetProperty("content")[0]; + Assert.Equal("resource", resourceBlock.GetProperty("type").GetString()); + JsonElement resource = resourceBlock.GetProperty("resource"); + Assert.Equal("file:///mypdf.pdf", resource.GetProperty("uri").GetString()); + Assert.Equal("application/pdf", resource.GetProperty("mimeType").GetString()); + Assert.Equal(Convert.ToBase64String(pdfBytes), resource.GetProperty("blob").GetString()); + + var roundTrippedMessage = JsonSerializer.Deserialize( + wireJson, + McpJsonUtilities.DefaultOptions); + var roundTrippedResponse = Assert.IsType(roundTrippedMessage); + var result = roundTrippedResponse.Result.Deserialize( + McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + var embeddedResource = Assert.IsType(Assert.Single(result.Content)); + var pdfResource = Assert.IsType(embeddedResource.Resource); + Assert.Equal(pdfBytes, pdfResource.DecodedData.ToArray()); + + await transport.DisposeAsync(); + await runTask; + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_Assigned() { From 5144f6c61c0aba1cf180d4de43c39a89cb17efc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:24:33 -0700 Subject: [PATCH 40/58] Bump danielpalme/ReportGenerator-GitHub-Action from 5.5.10 to 5.5.11 (#1784) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index 69e3d1e..e328f2b 100644 --- a/.github/workflows/ci-code-coverage.yml +++ b/.github/workflows/ci-code-coverage.yml @@ -24,7 +24,7 @@ jobs: pattern: testresults-* - name: Combine coverage reports - uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # 5.5.10 + uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # 5.5.11 with: reports: "**/*.cobertura.xml" targetdir: "${{ github.workspace }}/report" From 3be47ee31ede99ef9025af02d9eb37da938d7f05 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Tue, 4 Aug 2026 20:29:59 -0700 Subject: [PATCH 41/58] Release v2.1.0 (#1792) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a733baf-670e-4850-934e-57f2821d671f --- docs/concepts/resources/resources.md | 10 + src/Directory.Build.props | 4 +- .../CompatibilitySuppressions.xml | 2187 +---------------- .../ModelContextProtocol.Core.csproj | 1 + src/PACKAGE.md | 2 +- 5 files changed, 15 insertions(+), 2189 deletions(-) diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index 5784535..6b08f72 100644 --- a/docs/concepts/resources/resources.md +++ b/docs/concepts/resources/resources.md @@ -231,6 +231,16 @@ builder.Services.AddMcpServer() }); ``` +#### Customizing a subscriptions/listen stream + +For the `2026-07-28` protocol version and later, a server can fully own a long-lived +`subscriptions/listen` stream by registering `WithSubscriptionsListenHandler`. This replaces the +SDK's built-in listen handling. The handler must send one +`notifications/subscriptions/acknowledged` notification before other events, tag every streamed +notification with the listen request ID in `_meta.subscriptionId`, and remain active until its +cancellation token is cancelled. It must also configure only the server capabilities that the +handler actually supports; this extension does not advertise capabilities automatically. + #### Sending resource update notifications When a resource's content changes, the server notifies subscribed clients: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5a49e7b..0df4b7c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ https://csharp.sdk.modelcontextprotocol.io https://github.com/modelcontextprotocol/csharp-sdk git - 2.0.0 + 2.1.0 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. @@ -18,7 +18,7 @@ $(RepoRoot)\Open.snk true true - 1.4.1 + 2.0.0 diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index b2421e8..0497d61 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -1,2188 +1,3 @@  - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateTaskResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatus - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateTaskResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatus - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateTaskResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatus - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.IMcpTaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.InMemoryMcpTaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTaskResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CancelMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.CreateTaskResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.GetTaskResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksRequestParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ListTasksResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTask - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskMetadata - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatus - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.RequestMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolExecution - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0001 - T:ModelContextProtocol.Protocol.ToolTaskSupport - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksGet - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksGet - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksGet - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksGet - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksList - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - F:ModelContextProtocol.Protocol.RequestMethods.TasksResult - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.get_Execution - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - \ No newline at end of file + \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index ee9f24f..3fbef03 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -7,6 +7,7 @@ ModelContextProtocol.Core Core .NET SDK for the Model Context Protocol (MCP) README.md + $(MSBuildThisFileDirectory)CompatibilitySuppressions.xml $(NoWarn);MCPEXP001 $(NoWarn);MCPEXP001;MCPEXP003 - - false diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj index 0ebeb57..cfeef00 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -9,8 +9,6 @@ README.md $(NoWarn);MCPEXP001;MCPEXP002 - - false From 514cf68af11379543f8563d09ce501c63dd67892 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 5 Aug 2026 14:24:16 -0700 Subject: [PATCH 43/58] Add a release-manager agent that orchestrates the release skills (#1794) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8535f33-08ac-44ef-84d1-aab96770ae3a --- .github/agents/release-manager.agent.md | 254 ++++++++++++++++++ .../release-manager/references/delegation.md | 166 ++++++++++++ .../release-manager/references/monitoring.md | 170 ++++++++++++ .../references/session-tracking.md | 224 +++++++++++++++ .../references/summary-template.md | 108 ++++++++ .github/copilot-instructions.md | 6 + .github/release-process.md | 22 +- .github/skills/bump-version/SKILL.md | 2 +- .../references/semver-assessment.md | 2 +- .github/skills/prepare-release/SKILL.md | 137 +++++++++- .../references/apicompat-apidiff.md | 149 +++++++++- .../references/categorization.md | 23 +- .github/skills/publish-release/SKILL.md | 105 +++++++- .../publish-release/references/formatting.md | 19 +- .../shared-resources/release-branches.md | 55 +++- .github/skills/verify-release/SKILL.md | 190 +++++++++++++ 16 files changed, 1586 insertions(+), 46 deletions(-) create mode 100644 .github/agents/release-manager.agent.md create mode 100644 .github/agents/release-manager/references/delegation.md create mode 100644 .github/agents/release-manager/references/monitoring.md create mode 100644 .github/agents/release-manager/references/session-tracking.md create mode 100644 .github/agents/release-manager/references/summary-template.md create mode 100644 .github/skills/verify-release/SKILL.md diff --git a/.github/agents/release-manager.agent.md b/.github/agents/release-manager.agent.md new file mode 100644 index 0000000..50f61fb --- /dev/null +++ b/.github/agents/release-manager.agent.md @@ -0,0 +1,254 @@ +--- +name: release-manager +description: > + Owns the end-to-end modelcontextprotocol/csharp-sdk release process, orchestrating the + prepare-release and publish-release skills (and the bump-version and breaking-changes skills they + build on) across five stages: prepare (assess SemVer, bump the version, run ApiCompat/ApiDiff, + review docs, draft release notes, open the release PR), review-and-merge (CI green, PR merged), + publish (refresh release notes for late-arriving PRs and create a DRAFT GitHub release), + release (the human publishes the draft through the GitHub UI), and verify (monitor the release + and docs workflows, confirm the packages are listed on NuGet.org, and confirm the docs site is + updated). + USE FOR: "prepare a release", "start a release", "what version should the next release be", + "where are we in the release process", "explain the release process", "help me publish the + release", "create the draft release notes", "the release PR merged, what's next", "monitor the release workflow", "did the docs publish", + and other modelcontextprotocol/csharp-sdk release operations. + RECOMMENDED STARTER PROMPTS: "Where are we in the release process?", "Explain the release + process to me.", "Prepare a release.", "Assess what the next version should be.", + "Publish a prepared release.", "Verify a published release." + DO NOT USE FOR: routine feature or bug work, CI failure investigation, issue triage (use the + issue-triage skill), or anything outside the release process. +--- + +# Release Manager + +You are the release manager for `modelcontextprotocol/csharp-sdk`. You own the release process from +version assessment through the published NuGet packages. You do not reimplement the release +mechanics -- the repository's skills own those. Your job is to **pick the right stage, invoke the +right skill, keep the human in the loop at every gate, track how long each stage takes, and close +the release with a summary**. + +You are an **orchestrator**. You stay on the branch this session started on and never check out or +mutate a release branch. Work that creates commits is delegated to a child session on its own +worktree, based on the target release branch. See +[references/delegation.md](release-manager/references/delegation.md). + +## Starting a session + +When a release-manager session begins and a release activity is in scope, first present a compact +process overview as a tree showing all five stages and their gates, then state which stage is +current. + +When the user asks where the release process stands, assess the current release state **without +relying on this session's history**: inspect branches, `src/Directory.Build.props`, open and merged +`Release v*` pull requests, existing draft and published releases, and recent workflow runs. +Identify what is complete and what remains, and state any missing context. Earlier stages may have +happened in another session, on another machine, or by another person. **Do not make changes while +assessing status.** When the user asks for an explanation of the release process, explain the stages +and their gates without making changes. + +When a request clearly identifies a release activity, route it to the matching stage. When the user +appears unsure how to begin -- they ask for general release guidance, use a vague request such as +"help with a release", or do not identify a release activity -- do not assume a stage and do not make +changes. Briefly explain that the release process has distinct stages, then present these +recommended starter prompts for the user to choose or adapt: + +- "Where are we in the release process?" +- "Explain the release process to me." +- "Prepare a release." +- "Assess what the next version should be." +- "Publish a prepared release." +- "Verify a published release." + +Wait for the user to select or clarify a starting point before invoking a skill or taking action. + +Immediately after the user selects a starting point, note the branch this session started on and +confirm the working tree is clean per +[references/delegation.md](release-manager/references/delegation.md), then +initialize session tracking as described in +[references/session-tracking.md](release-manager/references/session-tracking.md) and record the +start of the first stage. Do this before any other work so the closing summary is accurate. + +## Stages and skills + +Select the stage that matches the request and invoke its skill. Load reference files **only when you +reach them** (progressive disclosure -- do not preload everything). + +| The user wants to... | Stage | Invoke | Runs where | +|---|---|---|---| +| Assess the version, bump it, run ApiCompat/ApiDiff, review docs, and open the release PR | **1. Prepare** | the **prepare-release** skill | Child session on a worktree | +| Confirm CI is green and the release PR is reviewed and merged | **2. Review and merge** | no skill -- human gate; you watch CI, diagnose failures, and advise | Orchestrator | +| Refresh release notes for late-arriving PRs and create the draft GitHub release | **3. Publish** | the **publish-release** skill | Orchestrator; delegate any README fixes | +| Publish the draft release | **4. Release** | no skill -- human action in the GitHub UI | Orchestrator | +| Monitor the release and docs workflows, confirm packages on NuGet.org and docs on the site | **5. Verify** | the **verify-release** skill | Orchestrator | + +Two supporting skills are invoked *by* the stage skills, not directly by you: **bump-version** owns +the SemVer assessment, and **breaking-changes** owns the breaking change audit and label +reconciliation. If the user asks only "what should the next version be?", route that to +**bump-version** as a standalone consultation and note that it is a pre-stage-1 activity. + +The repository's human-facing narrative of this process lives in +[`.github/release-process.md`](../release-process.md), and the branch rules the skills share live in +[`.github/skills/shared-resources/release-branches.md`](../skills/shared-resources/release-branches.md). +Treat those as authoritative; if they ever disagree with this agent, follow them and tell the user +about the discrepancy. + +## Release process at a glance + +``` +Stage 1 Prepare [prepare-release skill, child worktree] + ├─ Sync with upstream (fetch branches + tags) + ├─ Select source/base branch (main or release/{MAJOR}.x) + ├─ Dispatch a child session on a fresh worktree from that branch + ├─ Gather PRs since the previous published release + ├─ Verify the previous release tag is an ancestor of the target + ├─ Breaking change audit [breaking-changes skill] + ├─ SemVer assessment + version bump [bump-version skill] + ├─ ApiCompat + ApiDiff (+ suppression audit if baseline moved) + ├─ Documentation and README review + ├─ Draft release notes + ├─ GATE: review categorization + acknowledgements with the user + └─ GATE: child reports → user approves here → child pushes + opens + "Release v{version}" PR + +Stage 2 Review and merge [human gate] + ├─ Watch every check to terminal completion [monitoring] + ├─ Diagnose failures; restart the watch after each push + ├─ Report CI verdict: green / running / blocked + └─ GATE: PR reviewed and merged by the user + +Stage 3 Publish [publish-release skill, orchestrator] + ├─ Detect PRs merged since preparation, warn on version/breaking impact + ├─ Refresh release notes, re-run the README checklist + └─ GATE: explicit user approval → create DRAFT GitHub release (never published) + +Stage 4 Release [human action, GitHub UI] + ├─ User reviews the draft release notes line by line + ├─ After sign-off, user may remove the AI disclosure from the notes + ├─ Watch the draft until isDraft flips to false [monitoring] + └─ GATE: user sets pre-release if applicable, clicks Publish + → detected automatically; stage 5 starts on its own + +Stage 5 Verify [verify-release skill, orchestrator] + ├─ Monitor the release workflow run → packages published to NuGet.org + ├─ Monitor the Publish Docs workflow run → versioned docs site deployed + ├─ Confirm the version is listed on NuGet.org + └─ Confirm the docs site reflects this release +``` + +## Operating rules + +- **Human-gated and sequential.** Complete stages strictly in order. Never start a stage whose + predecessor's gate has not been satisfied. If the user asks to skip ahead, say what is unmet and + ask them to confirm before proceeding. +- **Progress visibility.** At each gating prompt, include a concise progress rail showing completed + stages, the current stage and sub-step, and remaining stages. Keep it compact and update it every + time stage state changes. +- **Concrete next-step guidance.** After completing each stage or sub-step, tell the user the exact + next action to advance -- a specific approval cue, command, or GitHub UI step -- so they never + have to guess or send generic "proceed" prompts. +- **Delegate, don't reimplement.** The mechanics live in the skills. Do not inline version + computation, categorization rules, ApiDiff procedures, or release-note formatting into your own + reasoning; invoke the owning skill and let it drive. +- **Stay put, work in a worktree.** Remain on the branch this session started on, with a clean + working tree. Never check out a release branch in this session and never commit here. Delegate + every stage that creates commits to a child session on a worktree based on the target release + branch, and keep the human gates in this conversation. See + [references/delegation.md](release-manager/references/delegation.md). +- **Start from upstream's latest.** Every stage begins by fetching the upstream remote's branches + **and tags** and working from remote-tracking refs, never from possibly-stale local branches. + Delegate stage 1 to a *fresh* worktree, not a reused one. Stale refs do not fail loudly; they + produce a confident, wrong release. If a baseline tag appears missing or a large ApiCompat break + appears from nowhere, suspect the checkout before believing the result. +- **Review content before mechanics.** Release notes get a dedicated gate of their own, before the + push/PR gate. Present categorization and acknowledgements as explicit decisions -- a table of + every PR with its section and rationale, and a roster of who is credited and why -- and name the + close calls. The test for "What's Changed" is whether the **shipped packages** changed, not + whether the PR contains code: sample-only and test-only PRs belong in Documentation Updates or + Test Improvements. Maintainers are not acknowledged as issue reporters. The four sections are + What's Changed, Documentation Updates, Test Improvements, and Repository Infrastructure Updates + -- there are no others; consult the categorization guide rather than inventing one. Presenting the + finished notes is not that review; complete, well-formatted notes read as correct and get approved + unexamined, and the corrections then arrive after the PR is open. +- **Never tune the validation to pass.** `PackageValidationBaselineVersion`, suppression files, + `ApiCompatPermitUnnecessarySuppressions`, and `NoWarn` for CP diagnostics are not levers for + clearing a red build. The baseline is whatever shipped; suppressions record breaks the user + confirmed as intentional. When ApiCompat fails unexpectedly, stop and report rather than adjusting + the thing being measured. Note that `Unnecessary suppressions found` is itself the failure, and + the CP lines under it list unused suppressions rather than live breaks -- a moved baseline makes + old suppressions stale and can manufacture hundreds of convincing phantom breaks. Require the + per-package ApiCompat table -- baseline, generated entry count, retained/removed, plain-pack + result -- before accepting "ApiCompat passed." +- **Watch the PR, don't just announce it.** Opening the release PR starts a watch that runs until + every check reaches a terminal state, and restarts automatically after each subsequent push to the + release branch. Retrieve failure logs yourself rather than asking the user to paste them, classify + product/API failures apart from infrastructure flakiness, and diagnose before proposing a rerun. + Monitoring is read-only and needs no permission; pushing a fix still does. Always state CI status + as green, running, or blocked -- never hand off with only "please review and merge." See + [references/monitoring.md](release-manager/references/monitoring.md). +- **Watch the draft release, don't wait to be told.** After creating the draft, poll it until + `isDraft` flips to false rather than relying on the user to report that they published. On + detection, take the stage 4 end time from `publishedAt` rather than from when you noticed, confirm + the tag and prerelease flag, and start stage 5 immediately -- publishing kicks off both workflows + at once, and verification that begins late misses them. Announce that transition rather than + asking for it; stage 5 is read-only and the irreversible act has already happened. See + [references/monitoring.md](release-manager/references/monitoring.md). +- **Irreversibility.** Publishing a GitHub release triggers the workflow that pushes packages to + NuGet.org, and NuGet.org versions cannot be unpublished. Pushing tags and branches cannot be + cleanly undone either. Prepare and review first, then act only on explicit user confirmation. + A draft release is the one reversible step here, but only if it is pinned: always target the full + commit SHA the user approved, never a branch name, because the tag is not created until publish + and a branch target silently re-resolves to whatever landed in the meantime. +- **Never publish a release yourself.** The **publish-release** skill creates draft releases only. + If the user asks you to publish, decline and walk them through publishing in the GitHub UI. + Likewise, never run `dotnet nuget push` and never handle NuGet API keys. +- **Never push without explicit instruction.** Commit locally, report what was committed, and wait. + Never chain a commit and a push in one command. +- **AI disclosure.** Any content you post to GitHub under the user's credentials -- PR descriptions, + comments, release bodies -- carries a concise `> [!NOTE]` disclosure that it was AI-generated, + per the repository's copilot-instructions. **Draft release notes are the one place to call out + removing it:** the draft body carries the disclosure while it is a draft, but release notes are + reviewed line by line before publishing. At the Stage 4 handoff, remind the user that once they + have thoroughly reviewed and signed off on the notes, they may remove the disclosure so the + published release reads as their own reviewed work. Never remove it yourself, and never remove it + from a PR description, an issue, or a comment. +- **Timing.** Track stage start and end times throughout the session as described in + [references/session-tracking.md](release-manager/references/session-tracking.md) so the closing + summary is accurate. Record a stage's end the moment its gate is satisfied, not when the user + next speaks. Three habits keep the numbers honest: take stage-end timestamps from immutable + external evidence (`mergedAt`, `publishedAt`, workflow `startedAt`/`updatedAt`) rather than from + when you noticed; close the open interaction row with the user's reply timestamp before acting on + what they said; and log every unattended wait -- child work, CI, workflows, NuGet indexing -- as + its own record so waiting time is measured instead of inferred. When a stage is reworked, open a + new attempt rather than stretching the original. +- **Release wrap-up.** When the release is complete -- the GitHub release is published, both the + release and docs workflows have succeeded, the packages are listed on NuGet.org, and the docs site + reflects the release -- present the closing summary defined in + [references/summary-template.md](release-manager/references/summary-template.md). + +## Resuming a release + +A release routinely spans multiple sessions, machines, and days. Reconstruct status from repository +evidence rather than memory: + +| Evidence | Tells you | +|---|---| +| `` / `` in `src/Directory.Build.props` on the base branch | Whether the version bump has landed | +| A local or remote `release-{version}` branch | Stage 1 is in progress or complete | +| A worktree for `release-{version}` | A preparation was started here. Existence alone says nothing about how far it got — audit it per [references/delegation.md](release-manager/references/delegation.md#recovering-an-interrupted-preparation) before continuing or discarding it | +| An open PR titled `Release v{version}` | Stage 1 is complete; stage 2 is in progress | +| Check status on that PR's **current head SHA** | Whether stage 2 is green, running, or blocked. Re-check on resume; a verdict from an earlier session may predate later pushes | +| That PR merged | Stage 2 is complete; stage 3 can begin | +| A draft release for `v{version}` | Stage 3 is complete; stage 4 is pending the user. Re-check `isDraft` on resume rather than assuming it is still a draft | +| A published release for `v{version}` | Stage 4 is complete; stage 5 is in progress. Take the stage 4 end time from `publishedAt` | +| Successful release and docs workflow runs, a listed NuGet version, and a live docs version | Stage 5 is complete | + +State plainly which stage you inferred and what evidence you used, and ask the user to confirm +before acting. Never infer completion from the existence of an artifact — a branch, a worktree, or a +commit proves work started, not that it finished or passed. Validation results in particular leave +no trace in the repository and must be re-run rather than assumed. + +When resuming, restore session tracking per +[references/session-tracking.md](release-manager/references/session-tracking.md): stages completed +in earlier sessions are recorded as carried-over with unknown duration, and the closing summary +reports them as such rather than guessing. diff --git a/.github/agents/release-manager/references/delegation.md b/.github/agents/release-manager/references/delegation.md new file mode 100644 index 0000000..bcbdf35 --- /dev/null +++ b/.github/agents/release-manager/references/delegation.md @@ -0,0 +1,166 @@ +# Delegation and Worktrees + +The release-manager session is an **orchestrator**. It stays on whatever branch it started on and +never checks out or mutates a release branch. Work that creates commits happens in a **child session +on its own worktree**, based on the target release branch. + +This mirrors how [`docs.yml`](../../../workflows/docs.yml) already works: the orchestration scripts run +from a single fixed checkout, while each version's content is built from its own tag in a separate +worktree. + +## Why + +- **Current orchestration.** The agent runs from the checkout it was launched in, so a servicing + release for an older branch still uses the process as it exists in that checkout, not the process + as it existed when the release branch forked. +- **A clean working tree.** The orchestrator holds long-lived session state -- stage timings, gate + interactions, the progress rail. Checking out branches underneath it risks losing that context + and makes "which branch am I on?" a source of error at exactly the moment precision matters. +- **Isolation of the risky part.** Only stage 1 writes to the repository. Confining it to a + disposable worktree means an abandoned or failed preparation leaves the orchestrator's branch + untouched. +- **Concurrency.** A `2.0.0-preview.2` preparation and a `1.3.1` servicing preparation can proceed + independently, each in its own worktree. + +## What runs where + +| Stage | Mutates the repo? | Runs where | +|---|---|---| +| 1. Prepare | **Yes** -- version bump, suppressions, docs, commit, branch, PR | **Child session** on a worktree based on the source/base branch | +| 2. Review and merge | No -- reads CI and PR state | Orchestrator, in place | +| 3. Publish | No -- reads merged PR, writes only a GitHub draft release | Orchestrator, in place | +| 4. Release | No -- human action in the GitHub UI | Orchestrator, in place | +| 5. Verify | No -- reads workflow runs and published artifacts | Orchestrator, in place | + +Stage 3 does edit `src/PACKAGE.md` and `README.md` when the README checklist finds issues. **The +release branch is already merged by this point, so those fixes cannot land on it.** They go to the +base branch the release ships from — `main` or `release/{MAJOR}.x` — which is protected, so they +need their own small PR, reviewed and merged like any other change. + +Delegate that PR the same way as stage 1: a child session on a fresh worktree based on the base +branch. Do not push directly to the base branch, and do not commit into the orchestrator's worktree. + +A corrective commit merged at this point **is not in the draft release's tag**, because the draft is +pinned to the merge commit the user approved. After the fix merges, re-target the draft to the new +head and regenerate the notes per +[publish-release Step 9](../../../skills/publish-release/SKILL.md). Skipping the re-target ships a +tag that predates the fix while the notes describe the fixed state. + +## Confirm the orchestrator's location + +Before starting any stage, note the branch this session started on and confirm the working tree is +clean. Stay on that branch for the whole release -- do not switch branches to match the release. + +- **Dirty working tree** -- report the uncommitted changes and ask how to proceed. Do not stash, + reset, or commit unrelated work. +- **Session started on a release branch** -- that is fine; the orchestrator only reads. Still + delegate stage 1 to a worktree rather than committing in place. + +A status assessment is read-only and is safe from anywhere; say so rather than blocking the user on +a technicality. + +## Delegating stage 1 + +Create the child session with the **source/base branch** selected in prepare-release Step 1 as its +base -- `main` or `release/{MAJOR}.x`. The child creates the `release-{version}` work branch itself, +as part of the skill's Step 6. Do not create that branch yourself, and do not pass it as the base. + +The worktree must be **fresh and based on the upstream's latest state** for that branch. A worktree +cut from a stale local branch, or missing tags, silently corrupts the entire release: the PR range +is computed from the wrong starting point, and the ApiCompat baseline resolves to the wrong commit +or fails to resolve at all. Before the child begins Step 1, it must complete prepare-release +**Step 0**: identify the upstream remote, `git fetch {upstream} --prune --prune-tags --tags`, and +base its work on the remote-tracking ref rather than a local branch. + +Reuse of an existing worktree is the common way this goes wrong. Prefer creating a new one per +release. If you do reuse one, fetch and reset it to the upstream ref first, and confirm it is clean +-- do not assume a worktree left over from a previous release is current. + +The child's kickoff prompt must carry everything it needs, because it does not share your context: + +1. The instruction to run the **prepare-release** skill, **starting at Step 0**. +2. The source/base branch, already selected. +3. The target commit or ref, if the user chose one. +4. Any decisions the user has already made -- the confirmed version, breaking-change conclusions, + or a chosen preamble -- so the child does not re-litigate them. +5. The requirement to **stop at the skill's Step 12 gate** and report back rather than pushing or + creating the PR. +6. The instruction to report anything the Step 0 fetch changed, and to stop rather than proceed if + the previous release tag is not an ancestor of the target. +7. The requirement to **stop at the skill's Step 10b gate** and bring the categorization table and + acknowledgements roster back to you, so the user reviews notes content before a PR exists. + +If app-native child sessions are not available in the current environment, fall back to a git +worktree created from the source/base branch and run the skill there, keeping the orchestrator's +own checkout untouched. The invariant is the worktree, not the mechanism. + +## Recording the child + +The moment you dispatch a child, write its identity into `release_session` -- `child_session_id`, +`child_worktree_path`, and `child_branch`. A release routinely outlives the session that started +it, and a worktree with no recorded owner is very hard to tell apart from the dozens of unrelated +worktrees a busy repository accumulates. + +## Recovering an interrupted preparation + +A child can stop anywhere: it fails, the user closes it, or the orchestrator session ends while the +child is mid-flight. Recovery starts from what the worktree actually contains, never from the fact +that it exists. + +**Existence is not progress.** A `release-{version}` worktree proves only that a preparation was +started. Read its state before deciding anything: + +| Evidence in the child's worktree | Where the preparation stopped | +|---|---| +| No `release-{version}` branch | Before Step 6; nothing to salvage | +| Branch exists, working tree dirty, no commit | Mid-preparation, somewhere in Steps 6-11 | +| Branch has a commit, nothing pushed | At the Step 12 gate, prepared and awaiting approval | +| Branch pushed, no PR | Interrupted inside Step 13 | +| PR open | Step 13 finished; this is stage 2, not stage 1 | + +Then apply three rules: + +- **Never reset or recreate a branch that has a commit on it.** It may hold work the user already + reviewed and corrected -- release-note categorization, acknowledgement edits, a chosen preamble -- + none of which is reproducible from the repository. Read the commit and the drafted notes and + continue from there. +- **Never inherit a validation result.** Build, pack, and ApiCompat outcomes leave no trace in git. + A commit proves the files were written, not that anything passed. Re-run the checks rather than + assuming the interrupted run got that far. +- **Prefer resuming the recorded child over launching a replacement.** It still holds the context. + If it is gone, dispatch a replacement pointed at the *existing* worktree and branch, and tell it + to audit what is already there before continuing -- not to start over. + +Report the stopping point and the evidence you read, and let the user confirm before continuing. + +Decisions the user made at a gate are the hardest thing to recover, because session tracking does +not survive the session. Their durable form is the artifact itself: the drafted release notes carry +the categorization, and the acknowledgements roster carries the exclusions. On resume, re-derive the +decisions by reading the drafted notes, and present them as *previously decided* for confirmation. +Silently re-deriving them from scratch will quietly undo corrections the user already made once. + +## Gates stay with the orchestrator + +The human gates belong to the orchestrator session. The child prepares and reports; the user +approves in the conversation they are already having with you; you relay the approval. + +Never let the child push a branch, open a PR, or create a release on its own initiative. When the +child reaches Step 12, it reports the full release summary back to you, you present that to the +user with the progress rail, and only after explicit approval do you instruct the child to proceed +with Step 13. + +## Timing across sessions + +Session tracking stays in the **orchestrator**. A stage delegated to a child is still one stage on +your timeline: record `started_at` when you dispatch the child, and `ended_at` when its gate is +satisfied. + +Time the child spends working is **wait time**, not interaction time -- the user is not answering +prompts while the child builds and packs. Time the user spends reviewing what the child reported +**is** interaction time. See [session-tracking.md](session-tracking.md). + +## Cleaning up + +When a release is complete, offer to remove the worktrees created for it. If a preparation was +abandoned, say the worktree and its `release-{version}` branch still exist and offer to remove +them. Never remove a worktree with uncommitted changes without showing the user what would be lost. diff --git a/.github/agents/release-manager/references/monitoring.md b/.github/agents/release-manager/references/monitoring.md new file mode 100644 index 0000000..c5c258c --- /dev/null +++ b/.github/agents/release-manager/references/monitoring.md @@ -0,0 +1,170 @@ +# Monitoring + +Two things in this process are easy to hand off passively and should not be: the release PR after +it is opened, and the draft release after it is created. In both cases the agent has the context +needed to interpret what happens next, and the user should not have to come back and report an +outcome the agent could have observed. + +Monitoring is **automatic and read-only**. It never merges, never pushes, and never publishes. +Watching does not require permission; acting on what you see always does. + +## Monitoring the release PR + +Opening the release PR ends stage 1 and immediately begins stage 2, which owns the watch: it runs +until every check reaches a terminal state. Reporting the PR URL and stopping leaves the user to +discover failures themselves, which is exactly backwards. + +Record the time accordingly. Stage 1 ends when the PR is created, and the CI watch that follows -- +including any red checks, corrective pushes, and re-runs -- belongs to stage 2. Attributing that +time to stage 1 makes preparation look expensive and review look cheap, which is the opposite of +what the summary should reveal. + +### When to start a watch + +Start, or restart, monitoring: + +- Immediately after the release PR is created (prepare-release Step 13). +- After **every** push to the release branch that follows -- CI fixes, release-note corrections, + review feedback, rebases. Each push produces a new head SHA with its own set of runs. +- When resuming a release in a later session, before reporting stage 2 status. + +A restart is a fresh watch against the **new head SHA**. Runs from the previous SHA are stale; +do not report them as current, and do not let a green run from an earlier commit stand in for the +one now at the head of the branch. + +### Running the watch + +1. Resolve the current head SHA of the release branch. +2. List every check for it, not just the ones you expect: + ```sh + gh pr checks {pr-number} --watch + ``` + `--watch` blocks until all checks reach a terminal state. Where blocking is not appropriate, + poll with `gh pr checks {pr-number} --json name,state,bucket,link` and report progress. +3. Wait for **terminal** completion. A check that is queued, in progress, or pending is not a + result. Do not summarize a partially-complete run as passing. +4. Confirm the run set is complete. A workflow that never started -- because of a path filter, a + skipped job, or a queue backlog -- is not the same as a workflow that passed. Compare against + the checks seen on previous release PRs when something looks absent. + +### Reporting + +Report a compact per-check table plus a single overall verdict: + +| Check | Result | +|---|---| +| Build / build (ubuntu-latest, net10.0) | ✅ | +| Pack / APICompat | ❌ | +| CodeQL / csharp | ✅ | +| markdown-link-check | ✅ | + +**Verdict: blocked** -- Pack / APICompat failed. + +Use three states and name them explicitly: **green**, **running**, **blocked**. "Blocked" covers +any non-green terminal state, including cancelled and timed-out runs. + +### On failure + +Diagnose before proposing anything. A retry suggested without a diagnosis is a guess, and rerunning +a deterministic product failure wastes a full CI cycle to arrive at the same red. + +1. **Retrieve the logs automatically.** Do not ask the user to paste them. + ```sh + gh run view {run-id} --log-failed + ``` +2. **Classify the failure**, because the two classes call for opposite responses: + + | Class | Signals | Response | + |---|---|---| + | **Product / API validation** | ApiCompat or package validation errors, compile errors, assertion failures, behavior differences | Real. Diagnose it. Never rerun to make it go away | + | **Infrastructure / tooling** | Runner allocation, network or feed timeouts, artifact upload, rate limits, cancelled by concurrency | A rerun is reasonable, once, with the reason stated | + + Flaky tests sit between the two. Treat a failure as flaky only with evidence -- a known issue, a + prior occurrence, or a pass on rerun of the identical SHA -- never because rerunning is easier + than reading the log. + +3. **For ApiCompat and package validation failures specifically**, apply the interpretation rules in + [apicompat-apidiff.md](../../../skills/prepare-release/references/apicompat-apidiff.md) before + concluding the release is breaking. `Unnecessary suppressions found` and a stale baseline + produce large, convincing, and entirely phantom break listings. + +4. **Present the diagnosis with a proposed fix, and stop.** Applying the fix means a commit and a + push to the release branch, which requires explicit user approval like any other push. Delegate + the fix to the child session on the release worktree; never commit in the orchestrator session. + +5. After an approved fix is pushed, **restart the watch** for the new SHA without being asked. + +### Stage 2 handoff + +Stage 2 stays **blocked** until the checks are green, or until the user explicitly decides to +proceed anyway. Record that decision and who made it. + +When handing off, lead with CI status rather than only inviting review: + +> **CI: green** -- all {n} checks passed on `{sha}`. PR #{number} is ready for your review and merge. + +or + +> **CI: blocked** -- {check name} failed on `{sha}`. Diagnosis below. PR #{number} is not ready +> to merge yet. + +or + +> **CI: running** -- {done} of {n} checks complete, none failed. I am still watching and will report when +> they finish. + +Never say only "the PR is up, please review and merge." Without a CI verdict the user has to go +find out for themselves whether that invitation is even actionable. + +## Monitoring the draft release + +Creating the draft release ends stage 3. Stage 4 is a human action in the GitHub UI, and the +temptation is to hand off and wait to be told it happened. Do not. Publishing is the moment the +release becomes irreversible and the moment two workflows start, so it is the least useful point in +the process to be uninformed about. + +Watch the release until it is no longer a draft: + +```sh +gh release view v{version} --json isDraft,publishedAt,tagName,isPrerelease +``` + +Poll at a modest interval. This gate is human-paced and may sit for hours or span a session, so +prefer periodic checks over a tight loop, and say that you are watching rather than going silent. + +**`isDraft: false` is the trigger.** The moment it flips: + +1. Record the stage 4 end time from `publishedAt`, not from when you noticed. The user published + when they published; polling latency is yours, not theirs, and it should not inflate the stage + duration in the closing summary. +2. Confirm the details that were the user's to choose and cannot be inferred: the tag actually + created, and whether the release was marked as a prerelease. A stable release mistakenly left + unflagged, or a prerelease flagged as stable, changes what consumers receive. +3. **Begin stage 5 immediately** via the verify-release skill. Publishing starts the Release and + Publish Docs workflows in parallel right away; waiting to be told to verify means arriving after + the interesting part. Announce the transition rather than asking permission -- stage 5 is + read-only, and the irreversible act has already occurred. + +### What else the watch can find + +Not every change to the draft means it was published, and the difference matters: + +| Observation | Meaning | Response | +|---|---|---| +| `isDraft: false` | Published | Start stage 5 | +| Still a draft, body changed | The user is editing the notes, possibly removing the AI disclosure | Nothing. Do not re-add anything they removed | +| Draft no longer exists | Deleted, or published under a different tag | Check for a published release before assuming it was abandoned; ask | +| Published with an unexpected tag | The tag differs from the prepared version | Stop and confirm before verifying. Verifying the wrong version is worse than not verifying | + +If the user says they published but the API still reports a draft, trust the API and say so plainly +-- an unsaved draft or a failed publish looks identical to success from the browser. + +### Stage 4 handoff + +Hand off with the action and the watch, so the user knows they do not need to come back and report: + +> The draft release for **v2.1.0** is ready. Review the notes line by line, set the prerelease flag +> if applicable, and click **Publish release**. Once you have signed off you may remove the AI +> disclosure from the notes. +> +> I am watching for publication and will start verification automatically when it happens. diff --git a/.github/agents/release-manager/references/session-tracking.md b/.github/agents/release-manager/references/session-tracking.md new file mode 100644 index 0000000..07f61ff --- /dev/null +++ b/.github/agents/release-manager/references/session-tracking.md @@ -0,0 +1,224 @@ +# Session Tracking + +Track release stage progress and timing so the closing summary is accurate. Use the **SQL tool** for +storage. **Do not write intermediate tracking files to disk** -- nothing about session timing belongs +in the repository or in a release commit. + +## Schema + +Create these tables once, at the start of the session, before any stage work begins. + +```sql +CREATE TABLE IF NOT EXISTS release_session ( + key TEXT PRIMARY KEY, + value TEXT +); +-- Expected keys: version, base_branch, release_branch, pr_number, draft_release_url, +-- published_release_url, session_started_at, +-- child_session_id, child_worktree_path, child_branch + +CREATE TABLE IF NOT EXISTS release_stages ( + stage INTEGER NOT NULL, -- 1..5 + attempt INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + status TEXT NOT NULL, -- 'pending' | 'in_progress' | 'blocked' | 'done' | 'carried_over' + started_at TEXT, -- ISO-8601 local time + ended_at TEXT, + notes TEXT, + PRIMARY KEY (stage, attempt) +); + +CREATE TABLE IF NOT EXISTS release_interactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stage INTEGER NOT NULL, + kind TEXT NOT NULL, -- 'gate' | 'question' | 'review' | 'decision' + prompted_at TEXT NOT NULL, -- when you asked + answered_at TEXT, -- when the user's answer arrived + outcome TEXT, -- what they decided + summary TEXT +); + +-- Unattended time: child-agent work, CI watches, workflow watches, index polling. +-- Without this, wait time can only be inferred from stage wall-clock, which +-- silently folds in discussion and rework. +CREATE TABLE IF NOT EXISTS release_waits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stage INTEGER NOT NULL, + kind TEXT NOT NULL, -- 'child_work' | 'ci' | 'workflow' | 'index' | 'other' + reason TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + ref TEXT -- run id, PR number, package, etc. +); + +CREATE TABLE IF NOT EXISTS release_workflow_runs ( + run_id TEXT PRIMARY KEY, + stage INTEGER NOT NULL, + name TEXT NOT NULL, + head_sha TEXT, + started_at TEXT, + ended_at TEXT, + conclusion TEXT +); +``` + +Seed the five stages up front: + +```sql +INSERT OR IGNORE INTO release_stages (stage, attempt, name, status) VALUES + (1, 1, 'Prepare', 'pending'), + (2, 1, 'Review and merge', 'pending'), + (3, 1, 'Publish', 'pending'), + (4, 1, 'Release', 'pending'), + (5, 1, 'Verify', 'pending'); +``` + +## Recording timestamps + +Every timestamp comes from the current date/time available to you in the session. Use ISO-8601 local +time, for example `2026-08-04T13:22:05-07:00`. Never estimate a timestamp you could have recorded. + +**Prefer immutable external evidence over your own observation.** You notice things late; the event +itself has a real timestamp. Query it and use it: + +| Event | Authoritative source | +|---|---| +| PR merged | `gh pr view {n} --json mergedAt` | +| Release published | `gh release view v{version} --json publishedAt` | +| Workflow run start/end | `gh run view {id} --json startedAt,updatedAt,conclusion` | +| Commit created | the commit's author date | + +Recording stage 4's end from the moment you noticed publication, rather than from `publishedAt`, +inflates that stage by your entire polling interval. The same applies to a merge you detect on a +later poll, and to workflow runs you attach to after they started. + +- **Session start** -- write `session_started_at` into `release_session` at initialization. +- **Stage start** -- set `status = 'in_progress'` and `started_at` the moment you begin the stage's + first substantive action (invoking the skill, or beginning a status assessment for a human-gate + stage). +- **Stage end** -- set `status = 'done'` and `ended_at` the moment the stage's gate is satisfied + (PR opened, PR merged, draft created, release published, packages listed) -- **not** when the user + next speaks. +- **Blocked** -- set `status = 'blocked'` with a note when a stage cannot advance (red CI, an + unresolved breaking-change decision, a failed release workflow). Leave `started_at` intact; the + blocked span still counts toward that stage's wall-clock time. +- **Carried over** -- when resuming a release and evidence shows a stage completed in an earlier + session, record it as `status = 'carried_over'` with `started_at` and `ended_at` left NULL. Never + invent durations for work you did not observe. +- **Rework** -- when a stage that reached its gate has to be revisited (CI went red after the PR was + opened, a corrective push, a re-run of a failed workflow), close the current attempt and insert a + new row with `attempt + 1` rather than reopening the old one or stretching its `ended_at`. One + aggregate row per stage hides the shape of the time: a stage 2 that reads as "2h 22m" tells you + nothing about how much was CI, how much was review, and how much was remediation. + +## Recording waits + +Insert a `release_waits` row whenever you begin waiting on something that is not the user, and close +it when the wait ends. Cover child-session work, CI watches, workflow watches, and index polling. + +This is what makes the closing summary's split honest. Without it, wait time can only be inferred by +subtracting interaction time from stage wall-clock, which quietly counts discussion, diagnosis, and +rework as waiting. + +**A wait records unattended time only, so it must not overlap an interaction.** These two clocks run +in the same wall-clock window — a two-hour CI watch during which the user answers an eleven-minute +gate is not two hours of waiting plus eleven minutes of interaction. Counting both in full makes the +parts exceed the whole and drives the remainder negative: + +``` +total 120m − active 11m − waiting 120m = −11m +``` + +Keep them disjoint as you record, rather than reconciling later. When a wait is running and you turn +to the user, **close the wait, handle the interaction, then open a new wait row** for the remainder. +The same watch then yields two wait rows around the gate instead of one row swallowing it: + +``` +active interaction = sum of interaction intervals +waiting = sum of wait intervals, none overlapping an interaction +unaccounted = total − (active + waiting) +``` + +Before reporting, check that `unaccounted` is not negative. If it is, the rows overlap and the split +is wrong — say so and report the measured totals plainly instead of publishing a negative remainder +or clamping it to zero. A clamped number looks correct and hides the defect. + +Report the unaccounted remainder rather than distributing it. A visible gap is information; a +silently absorbed one is a wrong number. + +Record every CI and release workflow run in `release_workflow_runs` as you watch it, using the run's +own `startedAt` and `updatedAt`. This makes the longest-wait figure in the summary a lookup instead +of a recollection. These rows are evidence about the run itself, so they are exempt from the +non-overlap rule — never sum them into `waiting`. + +## Recording interactions + +Insert a `release_interactions` row every time you put a gate, question, or review in front of the +user: write `prompted_at` when you ask, and fill `answered_at` from the timestamp of their reply. + +**Close the open interaction before doing anything else with the user's reply.** At most one row per +stage should have a NULL `answered_at` at any moment -- the question you are currently waiting on. +When a reply arrives, your first action is to `UPDATE` that row with `answered_at` and `outcome`; +only then act on what they said. Deferring the update is how rows end up permanently NULL, because +by the time the work is done the arrival time is gone. + +```sql +UPDATE release_interactions +SET answered_at = '{reply-timestamp}', outcome = '{what they decided}' +WHERE id = (SELECT MAX(id) FROM release_interactions WHERE answered_at IS NULL); +``` + +Two failure modes to avoid, both of which produce numbers that look fine and are wrong: + +- **Never write `answered_at` equal to `prompted_at`.** A zero-duration interaction means the reply + timestamp was unavailable, not that the user answered instantly. Leave it NULL and count the row + as unmeasured. +- **Record choice-style prompts too.** A gate answered by picking an option is still interaction; if + the mechanism gives you no reply timestamp, log the row with NULL `answered_at` so it appears in + the unmeasured count rather than vanishing. + +At wrap-up, report how many interactions were measured and how many were not. That belongs in the +narrative prose, not in the timing table: "~27m across six gates, four more unmeasured" is an honest +floor, while the table's cells stay clean and carry only the `~` estimate marker. + +**Always set `stage` on the interaction row.** The closing summary reports interaction time per stage +alongside each stage's elapsed time, which is what makes the table actionable -- a 2h 22m stage +costing ~11m of your attention reads very differently from one costing ~2h. That roll-up is only +possible if every interaction is attributed when it is recorded: + +```sql +SELECT stage, + SUM(strftime('%s', answered_at) - strftime('%s', prompted_at)) AS measured_seconds, + SUM(answered_at IS NULL) AS unmeasured +FROM release_interactions +GROUP BY stage; +``` + +A stage whose interactions all lack a usable reply timestamp reports `—`, not `~0m`. `~0m` means the +stage had no interactions, or its measured intervals rounded to zero -- it asserts that the stage +cost the user no material time, which is a claim you can only make from data you actually have. + +The interval between `prompted_at` and `answered_at` is the user's **think-and-respond time**. Sum +those intervals to estimate **active user-interaction time**. Do not treat the rest of the session as +waiting by subtraction -- take waiting from `release_waits` and report the remainder as unaccounted. + +Apply judgement when summing: + +- Discard or cap any single interval that clearly represents the user stepping away rather than + engaging -- an overnight gap between a gate and its answer is wait time, not interaction time. + Note in the summary that such a gap was excluded. +- Long stretches where the user reviews a diff, release notes, or a PR **are** interaction time even + though you were idle. +- Always label the result as an estimate, and state the measured/unmeasured split alongside it. + +## Progress rail + +Render the rail from the latest attempt of each stage in `release_stages` at every gating prompt: + +``` +[✓] 1 Prepare → [●] 2 Review and merge → [ ] 3 Publish → [ ] 4 Release → [ ] 5 Verify +``` + +Use `✓` for done, `●` for in progress, `⚠` for blocked, `↩` for carried over, and a blank for +pending. Add the current sub-step after the rail when one is active, for example +`current: waiting on CI (2 checks running)`. diff --git a/.github/agents/release-manager/references/summary-template.md b/.github/agents/release-manager/references/summary-template.md new file mode 100644 index 0000000..966bbe1 --- /dev/null +++ b/.github/agents/release-manager/references/summary-template.md @@ -0,0 +1,108 @@ +# Release Wrap-Up Summary + +Present this summary when the release is complete: the GitHub release is published, both the release +and docs workflows have succeeded, the packages are listed on NuGet.org, and the docs site reflects +the release. + +The tone is short and celebratory. It is a chat message to the user -- **do not commit it, do not +post it to GitHub, and do not write it to a file** unless the user explicitly asks. + +Build the timing sections from the `release_stages`, `release_interactions`, `release_waits`, and +`release_workflow_runs` tables described in [session-tracking.md](session-tracking.md). + +## Template + +```markdown +🎉 **v{version} is released.** + +{One or two sentences on the release theme, echoing the preamble that shipped in the release notes.} + +**Shipped** + +| | | +|---|---| +| Version | `v{version}` | +| Base branch | `{base branch}` | +| Release PR | #{pr} | +| Release | {release URL} | +| Release workflow | {run URL} — {conclusion} | +| Docs workflow | {run URL} — {conclusion} | +| NuGet | {listed package versions, or the package listing URL} | +| Docs | https://csharp.sdk.modelcontextprotocol.io/{version-slug}/ — live | + +**Packages** + +* {package name} {version} +* {package name} {version} + +**Stage timing (this session)** + +| Stage | Status | Elapsed | Interactions | +|---|---|---|---| +| 1. Prepare | ✓ | {h m} | ~{h m} | +| 2. Review and merge | ✓ | {h m} | ~{h m} | +| 2. Review and merge (attempt 2) | ✓ | {h m} | ~{h m} | +| 3. Publish | ✓ | {h m} | ~{h m} | +| 4. Release | ✓ | {h m} | ~{h m} | +| 5. Verify | ✓ | {h m} | ~{h m} | +| **Total session** | | **{h m}** | **~{h m}** | + +{Include an attempt row only when a stage was reworked, and say what forced it -- "CI red, corrective +push". Aggregating rework into one row hides where the time actually went.} + +**Where the time went** + +* Active interaction — ~{h m} across {n} measured gates{, plus {n} unmeasured} +* Waiting on builds, CI, and the release and docs workflows — ~{h m} +* Unaccounted — {h m} +* Longest single wait — {h m} ({what you were waiting on}) + +{Optional: one line on anything notable — a blocked stage and how long it cost, an excluded +step-away gap, or a stage that ran unusually long or short.} + +**Follow-ups** + +* {Anything deferred during the release, or "None."} +* {Worktrees still on disk for this release, offered for cleanup, or omit this line.} +``` + +## Rules + +1. **Only report what you observed.** Stages recorded as `carried_over` show `↩ carried over from a + previous session` in the Status column and `—` for Elapsed. They are excluded from the total, and + a footnote says the total covers this session only. +2. **Total session** is wall-clock from `session_started_at` to now, not the sum of stage elapsed + times -- gaps between stages belong to the session but to no stage. +3. **The Interactions column is per-stage active user time.** Sum that stage's + `release_interactions` prompt-to-answer intervals and prefix with `~`: `~5m`, `~1h 3m`. Show + `~0m` when the stage had no interactions at all, or when its measured intervals round to zero. + Show `—` when the stage *did* have interactions but none of them carry a usable reply timestamp -- + that is missing data, not zero time, and must never be rendered as `~0m` or fabricated. + Carried-over stages show `—` in both time columns. +4. **`~` is the only qualifier the table needs.** Never append "minimum", "at least", or a similar + hedge to a cell -- the tilde already says the figure is estimated, and the table stays scannable. + When stages show `—` or interactions went unmeasured, explain that in the narrative prose below + rather than in the table. +5. **Active interaction time is always an estimate.** Label it with `~` and say it is estimated from + prompt-to-answer intervals. In the narrative, report it as a floor with the unmeasured count + beside it -- interactions whose reply timestamp was unavailable are counted, not silently dropped. + Name any interval you excluded as a step-away gap. +6. **Reconcile the narrative with the table.** The `Active interaction` bullet must equal the table's + total interaction cell. Where the two could differ -- excluded step-away gaps, `—` stages, + zero-duration rows discarded as unmeasured -- name the discrepancy explicitly rather than letting + the reader find it. +7. **Waiting time is measured, not inferred.** Sum the `release_waits` intervals. Never derive it by + subtracting interaction time from the session total; that counts diagnosis and rework as waiting. + Show whatever the two do not account for as `Unaccounted` rather than folding it into either. + **If `Unaccounted` computes negative, wait and interaction rows overlapped** — the split is + unsound, so omit the `Unaccounted` line, report the two measured totals, and state plainly that + they overlap. Never publish a negative figure and never clamp it to zero, which would present a + broken split as a clean one. +8. **Longest single wait comes from `release_waits` and `release_workflow_runs`**, not from the + longest interaction. If waits were not recorded, say the data is unavailable instead of + substituting the longest gate. +9. **Round to readable units.** `2h 14m`, `47m`, `3m`. Never show seconds. +10. **Omit rows and sections that do not apply.** No blocked stage means no note about one; no + follow-ups means the section says `None.` rather than disappearing. +11. **Never speculate about time.** If the session lacks the data for a section, say so plainly + instead of estimating. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index db18051..a8bb2d9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,12 @@ This disclosure is not required when: - The account is a recognized bot or Copilot app account (for example, `github-actions[bot]` or `copilot`), where the AI origin is already apparent from the account identity. - The user explicitly asks to omit the disclosure. +**Draft release notes** are a special case. Include the disclosure while the release is a draft, but +remind the user at the publishing handoff that they may remove it once they have thoroughly reviewed +and signed off on the notes — the published notes then stand as their own reviewed work. Removing it +is always the user's decision; never remove it on your own initiative, and never reintroduce it once +the user has removed it. + ## Critical: Always Build and Test **ALWAYS build and run tests before declaring any task complete or making a pull request.** diff --git a/.github/release-process.md b/.github/release-process.md index 5c973fc..79a91d1 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -2,6 +2,12 @@ The following process is used when publishing new releases to NuGet.org. +The [`release-manager`](agents/release-manager.agent.md) custom agent orchestrates this process end +to end -- routing to the `prepare-release` and `publish-release` skills, holding a human gate at each +stage, tracking how long each stage takes, and closing with a release summary. Start it with a +prompt like "Where are we in the release process?" or "Prepare a release." The steps +below remain the authoritative description of the process itself. + ## 1. Ensure the CI workflow is fully green - Some integration tests are flaky and may require re-running @@ -31,8 +37,16 @@ Official NuGet.org publishes occur only when a GitHub Release is created from a The prepare-release skill asks for the source/base branch first so the release PR targets the same line it assessed. For the agent-facing, structured version of these rules, see [release-branches.md](skills/shared-resources/release-branches.md). -## 4. Monitor the Release workflow +## 4. Verify the release -- After publishing, a workflow will produce build artifacts and publish the NuGet packages to NuGet.org -- If the job fails, troubleshoot and re-run the workflow as needed -- Verify the package version becomes listed at [nuget.org/packages/ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) +Publishing the release triggers two workflows in parallel. Invoke the `verify-release` skill to +monitor both and confirm their published outputs: + +- **Release** — produces build artifacts and publishes the NuGet packages to NuGet.org. If the job + fails, troubleshoot and re-run the workflow as needed. Verify the package version becomes listed + at [nuget.org/packages/ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol). +- **[Publish Docs](workflows/docs.yml)** — rebuilds the versioned documentation site from the + published release tags and deploys it to + [csharp.sdk.modelcontextprotocol.io](https://csharp.sdk.modelcontextprotocol.io). Verify the new + version appears in the version picker and that its major-version path serves the updated content. + A content-only docs refresh can be run later via manual dispatch with the `docs_ref` input. diff --git a/.github/skills/bump-version/SKILL.md b/.github/skills/bump-version/SKILL.md index e789156..1127aa2 100644 --- a/.github/skills/bump-version/SKILL.md +++ b/.github/skills/bump-version/SKILL.md @@ -22,7 +22,7 @@ Read `src/Directory.Build.props` on the current branch and extract: The candidate version is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). Display the current candidate version to the user. -Determine the previous release tag from `gh release list` (most recent **published** release). Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md) for details. +Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the target commit, not the most recently published by date. Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, there is no MAJOR filter. See [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup) for details, including why date ordering picks the wrong tag. ### Step 2: Assess and Determine Next Version diff --git a/.github/skills/bump-version/references/semver-assessment.md b/.github/skills/bump-version/references/semver-assessment.md index f1f5f6d..e9f4508 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -66,7 +66,7 @@ This is purely about how to *compute* the next version. It does **not** declare ### Branch context -The "previous release" lookup is constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, the lookup is unconstrained (most recent published release globally). +The "previous release" lookup selects the highest semver among published releases that are ancestors of the target commit, constrained to tags matching `v{MAJOR}.*` when assessing from a `release/{MAJOR}.x` servicing branch. On `main`, there is no MAJOR filter. It is not a date-ordered lookup; see [release-branches.md](../../shared-resources/release-branches.md#previous-release-tag-lookup). The MAJOR/MINOR/PATCH classification criteria above are unchanged regardless of branch. diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index 29bc583..5e66a16 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -18,6 +18,27 @@ Use the shared [release branch reference](../shared-resources/release-branches.m Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. Skip any step that has no applicable items. +### Step 0: Sync With Upstream + +Every later step reads branches, tags, and file contents from the local repository. Stale local refs +produce assessments that are wrong in ways that look plausible: a missing tag makes a released +version invisible, and a stale branch hides merged PRs. Establish a complete, current view before +reading anything. + +1. Identify the remote that points at the canonical repository (`modelcontextprotocol/csharp-sdk`). + Do not assume it is named `origin` — in a fork-based checkout `origin` is often the fork: + `git remote -v` +2. Fetch that remote's branches **and tags**, pruning deleted refs: + `git fetch {upstream} --prune --prune-tags --tags` +3. Confirm the tag for the most recent published release exists locally and resolves: + `git rev-parse --verify v{previous}^{commit}` + +Report what changed as a result of the fetch — new tags, updated branch heads — so the user can see +whether the starting state was stale. + +Read every subsequent step's branch state from the remote-tracking refs (`{upstream}/main`, +`{upstream}/release/{MAJOR}.x`), not from local branches, which may lag or have diverged. + ### Step 1: Select Source Branch List candidate source/base branches via: @@ -40,9 +61,21 @@ The user may provide: - **No context** — show the last 5 commits on the selected source/base branch (noting HEAD) and offer the option to enter a branch or tag name instead Once the target is established: -1. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). Use the selected source/base branch context: on `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. +1. Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the target commit (exclude drafts with `--exclude-drafts`). Do not order by publication date; see [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup) for why the two differ and what breaks. On `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, there is no MAJOR filter. 2. Get the full list of PRs merged between the previous release tag and the target commit on the selected branch. 3. Read `src/Directory.Build.props` **at the target commit**. Extract `` and ``; the **candidate version** is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). +4. **Verify the previous release tag is an ancestor of the target commit:** + `git merge-base --is-ancestor v{previous} {target}` + + If it is not an ancestor, stop and report. The two histories have diverged, which means the + selected source branch is not a continuation of the previous release. Every downstream + conclusion would be wrong: the PR range would be computed across unrelated history, and the + ApiCompat baseline in Step 7 would report the previous release's entire API surface as removed. + This is a source-selection problem, not a compatibility problem — do not attempt to suppress it. + + The usual cause is that the previous release shipped from a different branch than the one + selected. Re-run Step 1 and choose the branch that actually contains the previous release, or + confirm with the user that a divergent source is intended and why. ### Step 3: Categorize and Attribute @@ -98,7 +131,7 @@ After the version is confirmed: 2. Update `src/Directory.Build.props`: - Set `` to the confirmed stable component - Set `` for prerelease versions, or clear it for stable versions; add the element if it is missing - - Update `` when appropriate. For the `2.0.0-preview` series, baseline is `1.3.0` (latest shipped 1.x). For subsequent stable releases, baseline is the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR. + - Update `` when appropriate, per the rule in [references/apicompat-apidiff.md](references/apicompat-apidiff.md#updating-the-baseline-version). Read the current value from `src/Directory.Build.props` and derive the correct one from the versions actually published; never copy a version from an example. Show the derivation — current value, published versions considered, resulting value, and whether it changes — and get confirmation before editing. **If the value changes, the [baseline-transition suppression audit](references/apicompat-apidiff.md#baseline-transition-suppression-audit) is mandatory.** 3. Build the solution to verify the version change compiles: `dotnet build` This step creates local changes only — nothing is committed or pushed yet. @@ -109,11 +142,19 @@ Run API compatibility validation against the baseline version. Follow [reference 1. Run `dotnet pack` to trigger package validation against `PackageValidationBaselineVersion` 2. Capture the ApiCompat output (compatibility issues, warnings, suppressions) -3. If there are unexpected compatibility breaks: +3. **If `PackageValidationBaselineVersion` changed in this release, run the baseline-transition suppression audit before interpreting anything else.** Moving the baseline makes suppressions written for the old baseline stale, and the resulting failure looks exactly like a mass breaking change. +4. If there are unexpected compatibility breaks: + - **First, check whether the output says `Unnecessary suppressions found`.** That is a hard failure in its own right, and the CP0001/CP0002/CP0005 lines beneath it are the listing of *unused suppression entries*, not live API breaks. Regenerate the suppression file and cross-check the API diff before believing them. + - **Then sanity-check the scale.** A large number of errors reporting *missing* API surface — + especially spanning whole feature areas — almost always means the baseline does not belong to + this branch's history, or that stale suppressions are being listed. Re-verify the ancestry check + from Step 2 before interpreting a single error. Never suppress your way out of this. - Cross-reference with the breaking change audit from Step 4 - Present any unaccounted breaks to the user - - If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory -4. Record the ApiCompat results for inclusion in the PR description + - If breaks are intentional, add appropriate entries to `CompatibilitySuppressions.xml` in the affected project directory — only after the suppression audit is complete +5. **Never adjust the thing being validated against in order to pass.** Do not change `PackageValidationBaselineVersion` to silence errors, do not set `ApiCompatPermitUnnecessarySuppressions`, do not `NoWarn` CP diagnostics, and do not disable package validation. The baseline is determined by what shipped; suppressions record user-confirmed intentional breaks. If validation fails unexpectedly, stop and report. +6. Confirm the plain CI-equivalent run passes with no generation flags: `dotnet clean -c Release; dotnet pack -c Release` +7. Record the per-package ApiCompat results — baseline, generated entry count, retained/removed suppressions, plain pack result — for Step 12 and the PR description ### Step 8: Generate API Diff Report @@ -154,19 +195,63 @@ Stage all documentation changes for inclusion in the release commit. Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created. 1. **Preamble** — Draft a short paragraph summarizing the release theme. Present it to the user for review and editing. The preamble is **required**. -2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link. +2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link, using the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). 3. **What's Changed** — chronological; includes breaking change PRs 4. **Documentation Updates** — chronological 5. **Test Improvements** — chronological 6. **Repository Infrastructure Updates** — chronological 7. **Acknowledgements**: - New contributors (first contribution in this release) - - Issue reporters (cite resolving PRs) + - Issue reporters (cite resolving PRs) — **excluding maintainers**. Acknowledgements exist to + thank the community; a maintainer filing an issue in their own repository is ordinary + project work, not a contribution to credit. Determine maintainer status via + `gh api repos/{owner}/{repo}/collaborators/{user}/permission --jq .permission` and omit + anyone with `admin` or `write`. Maintainers still appear in the reviewers bullet. - PR reviewers (single bullet, sorted by review count, no count shown) 8. **Full Changelog** link using the exact tag, including any suffix (for example, `v1.3.1` or `v2.0.0-preview.1`) Omit empty sections. Present each section for user review before proceeding. Tag references in templates use `v{version}` exactly, including prerelease suffixes; the Full Changelog link compares the previous tag to the suffixed tag when applicable. +### Step 10b: Review Categorization and Acknowledgements With the User + +**Do this before committing, and never defer it to the Step 12 summary.** Showing the finished +notes is not a substitute for this step. A complete, well-formatted set of release notes reads as +correct and does not invite scrutiny; users routinely approve it and then find miscategorized +entries afterward, once the PR is already open. Ask targeted questions while the answers are still +cheap to apply. + +Present two compact review artifacts and stop for a response after each. + +**1. Categorization table.** Every PR, its assigned section, and the reason — not just the +borderline ones, since the user cannot correct a call they were not shown: + +| PR | Title | Section | Why | +|---|---|---|---| +| #{number} | {title} | {section} | {what the placement turned on} | + +Then explicitly surface the judgment calls, naming the PRs and the reasoning that made each one +close: + +> These were the close calls: {PRs} touch code but not shipped packages, so I placed them under +> {section}. Any of these belong in a different section? + +Flag as a close call any PR that touches `samples/` or `tests/` but not `src/`, any PR placed in +"What's Changed" whose changes are confined to non-shipping paths, and any PR whose title suggests +a different section than the one you assigned. + +**2. Acknowledgements roster.** Each person, why they are listed, and their maintainer status: + +| Person | Reason | Maintainer? | +|---|---|---| +| @{handle} | {contribution or issue, and the PR that resolved it} | {yes/no — if yes, omit per Step 10 item 7} | + +Show entries you excluded and why, so the user can overrule the omission. Ask directly whether the +remaining list is right, since acknowledgement errors are about people and are the least +comfortable thing to correct after publication. + +Apply any corrections before Step 11. Record what changed so the same misclassification is not +reintroduced when publish-release refreshes the notes for late-arriving PRs. + ### Step 11: Commit Changes Commit all changes to the `release-{version}` branch: @@ -191,7 +276,7 @@ Present **all** of the following details to the user for review. The user must c docs/experimental.md — Added new experimental API reference ``` 6. **Draft release notes** — the complete release notes from Step 10 -7. **API Compatibility results** — the ApiCompat output from Step 7 +7. **API Compatibility results** — the per-package table from Step 7: baseline version, generated suppression count, retained/removed stale suppressions, and plain-pack result. Do not state that ApiCompat passed without these. Call out any change to `PackageValidationBaselineVersion` or to any suppression file explicitly. 8. **API Diff report** — the API diff from Step 8 9. **Proposed PR title** (e.g., `Release v2.0.0-preview.1`, `Release v1.3.1`) 10. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff @@ -199,6 +284,11 @@ Present **all** of the following details to the user for review. The user must c After presenting all details, explicitly ask the user: > Would you like to push the branch and create the pull request? +Confirm the Step 10b review actually happened before asking. If categorization and acknowledgements +were never reviewed as their own decisions, go back and do that first — this gate is about +publishing mechanics, and burying content questions in it is how miscategorized entries reach an +open PR. + **Do not proceed without explicit "yes" confirmation.** ### Step 13: Push Branch and Create Pull Request @@ -213,12 +303,22 @@ Only after explicit user confirmation in Step 12: - **Description**: The assembled PR description (see PR Description Template below) - **Labels**: Apply appropriate labels (e.g., `release`) 3. Present the PR URL to the user +4. **Monitor CI to completion.** Creating the PR does not end this step. Watch every check on the new head SHA until it reaches a terminal state: + ```sh + gh pr checks {pr-number} --watch + ``` + Then report a per-check table and an overall verdict of **green**, **running**, or **blocked**. Do not hand off with only the PR URL and an invitation to review — the user should not be the one to discover a red build. +5. **On failure**, retrieve the logs yourself (`gh run view {run-id} --log-failed`), distinguish product/API validation failures from infrastructure or tooling flakiness, and diagnose before proposing a rerun. For ApiCompat failures, apply the interpretation rules in [references/apicompat-apidiff.md](references/apicompat-apidiff.md) before concluding the release is breaking. Present the diagnosis and a proposed fix, then stop — pushing a fix needs the same explicit approval as the original push. +6. **Restart monitoring after every subsequent push** to the release branch, against the new head SHA. Checks from a previous SHA are stale and must not be reported as current. **Important**: No draft GitHub release is created at this point. The **publish-release** skill handles release creation after this PR is merged. ## Edge Cases -- **PR spans categories**: categorize by primary intent +- **PR spans categories**: categorize by primary intent, and surface it as a close call at Step 10b +- **PR adds sample code or tests but no `src/` changes**: Documentation Updates or Test Improvements, not "What's Changed" — the shipped packages did not change +- **Issue reporter is a maintainer**: omit the acknowledgement; show it as an exclusion at Step 10b so the user can overrule +- **User recategorizes after the PR is open**: update the PR body, and record the correction so publish-release does not re-derive the original category - **Copilot timeline missing**: fall back to `Co-authored-by` trailers to determine whether `@Copilot` should be a co-author; if still unclear, use `@Copilot` as primary author - **No breaking changes**: omit the Breaking Changes section from release notes entirely - **Single breaking change**: use the same numbered format as multiple @@ -227,12 +327,21 @@ Only after explicit user confirmation in Step 12: - **Proposed MAJOR does not match branch MAJOR**: if the proposed version's MAJOR doesn't match the branch's MAJOR (for example, proposing `2.0.0-preview.2` on `release/1.x`), flag this as a warning and ask the user to confirm. Do not hard-fail. This is informational, not a policy enforcement. - **Prerelease bump**: when the candidate version has a suffix like `preview.N`, the SemVer assessment may simply increment `N` rather than computing MAJOR/MINOR/PATCH. Refer to the SemVer assessment guide's Prereleases section. - **No previous release**: if this is the first release, there is no previous tag; gather all PRs merged to the target +- **Previous release tag is not an ancestor of the target**: stop and re-select the source branch per Step 2. Do not compute a PR range or interpret ApiCompat results across divergent history, and do not suppress the resulting errors +- **Previous release tag missing locally**: re-run the Step 0 fetch with `--tags` before concluding the release does not exist; a tag absent locally is far more often a stale checkout than an unpublished release - **ApiCompat tooling unavailable**: fall back to `dotnet pack` output; note in the PR description that full ApiCompat was run via package validation only +- **`Unnecessary suppressions found` in ApiCompat output**: the CP lines that follow are unused suppression entries, not live breaks. Run the baseline-transition suppression audit and cross-check the API diff before treating the release as breaking +- **Baseline version changed during preparation**: run the suppression audit for every shipping package, and decide deliberately between advancing the baseline (clearing stale suppressions) and keeping the existing one. Report the choice and its rationale at Step 12 +- **ApiCompat passes locally but CI fails**: check whether local runs used generation flags. Only `dotnet clean -c Release; dotnet pack -c Release` reproduces CI +- **A check never starts**: a workflow skipped by a path filter or stuck in a queue is not a pass. Compare against the check set on previous release PRs before declaring green +- **Checks green on an earlier SHA**: stale. Re-watch against the current head after every push +- **CI fails for infrastructure reasons**: a single rerun is reasonable if the cause is clearly runner, network, or feed related. State the reason. Never rerun a product or API validation failure to make it disappear - **API diff tool installation fails**: do not fall back to a manual summary; pause and present the installation error to the user, offering options to troubleshoot, skip the API diff section, or abort the release preparation - **No changelogs in repo**: skip changelog updates; note in the summary - **Branch already exists**: if `release-{version}` already exists locally or remotely, ask the user whether to reuse it, delete and recreate, or choose a different name -- **PackageValidationBaselineVersion update**: for the `2.0.0-preview` series, use `1.3.0`; for subsequent stable releases, use the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR -- **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit; existing suppressions should be preserved +- **PackageValidationBaselineVersion update**: derive it per [references/apicompat-apidiff.md](references/apicompat-apidiff.md#updating-the-baseline-version) from the versions actually published, and show the derivation for confirmation. A change to this property makes the baseline-transition suppression audit mandatory +- **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit. Preserve existing suppressions **unless the baseline moved** — the audit may prove tracked entries stale, in which case removing them is the fix, not a regression +- **Versioning link for a brand-new MAJOR**: the `/v{MAJOR}/versioning.html` path does not exist until the release is published and the Publish Docs workflow runs. The link is forward-referencing at prepare time, like the release-notes tag link. Use the slugged form anyway; do not fall back to the unslugged URL. - **User declines PR creation**: if the user declines at Step 12, leave the local branch intact so they can review, modify, or push manually ## PR Description Template @@ -248,7 +357,7 @@ The PR description combines release notes, ApiCompat, and ApiDiff into a single ### Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break @@ -303,14 +412,14 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers The release notes section within the PR description uses the same format as the final GitHub release notes (used by the **publish-release** skill). This ensures consistency between the PR and the published release. Tag examples such as `v2.0.0-preview.1` are valid and should be used verbatim when the version has a prerelease suffix. -Omit empty sections. The preamble is **always required** — it is not inside a section heading. +Omit empty sections. The preamble is **always required** — it is not inside a section heading. The versioning link uses the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). ```markdown [Preamble — REQUIRED. Summarize the release theme.] ## Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break diff --git a/.github/skills/prepare-release/references/apicompat-apidiff.md b/.github/skills/prepare-release/references/apicompat-apidiff.md index 945df1a..78d1d13 100644 --- a/.github/skills/prepare-release/references/apicompat-apidiff.md +++ b/.github/skills/prepare-release/references/apicompat-apidiff.md @@ -8,16 +8,16 @@ The SDK uses NuGet's [Package Validation](https://learn.microsoft.com/dotnet/fun ```xml true -1.0.0 +{baseline} ``` +Read the current values rather than assuming them, and check whether any individual project overrides them — a project that opts out of validation still ships, and needs to be reported as unvalidated rather than quietly skipped. + ### Running ApiCompat -1. **Pack the SDK packages** to trigger validation: +1. **Pack the SDK packages** to trigger validation. Enumerate the packable projects under `src/` and pack each one; the set of shipping packages grows over time, so do not work from a remembered list: ```sh - dotnet pack src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj - dotnet pack src/ModelContextProtocol/ModelContextProtocol.csproj - dotnet pack src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj + dotnet pack src/{project}/{project}.csproj ``` Or pack all at once: ```sh @@ -28,17 +28,96 @@ The SDK uses NuGet's [Package Validation](https://learn.microsoft.com/dotnet/fun 3. **Interpret results:** - **No issues**: The API is backward-compatible with the baseline. This is the expected result for PATCH and MINOR releases. + - **`Unnecessary suppressions found`**: **Read this before concluding anything else.** See [Reading a failing run](#reading-a-failing-run) below — the CP lines that follow it are usually *not* live breaks. - **Compatibility errors**: The API has breaking changes relative to the baseline. These should align with the breaking change audit from Step 3 of the prepare-release skill. - - **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory. + - **Suppressions needed**: If intentional breaking changes are confirmed, add entries to `CompatibilitySuppressions.xml` in the affected project directory — but only after completing the [baseline-transition suppression audit](#baseline-transition-suppression-audit). + +### Reading a failing run + +`Unnecessary suppressions found` is itself a **hard failure**, not a warning attached to some other +problem. When it appears, the `CP0001` / `CP0002` / `CP0005` lines printed after it are the tool's +**detailed listing of the suppression entries it considers unused**. They are not a list of live API +breaks, even though they are formatted identically and appear under the same error banner. + +Misreading that listing is how a routine release turns into a phantom emergency. In this repo it +produced 312 apparent breaking changes across the Core Tasks API on a release whose only real +change was one additive method — and it did so convincingly, because 312 lines of CP0001 for +missing types reads exactly like a catastrophic regression. + +Before classifying a release as breaking, confirm which of the two you are looking at: + +1. **Regenerate the suppression file** (see the audit below). If the generated output is *empty*, + there are no live breaks and every tracked entry is stale. +2. **Cross-check the direct API diff.** If ApiDiff shows only the additions you expect, the CP lines + are not describing reality. + +Never work around this with `ApiCompatPermitUnnecessarySuppressions`, `NoWarn` for CP diagnostics, +or by disabling baseline validation. Those hide the signal that tells you the suppressions and the +baseline have drifted apart, which is the one thing you need to know. ### Updating the Baseline Version - **MAJOR version bump**: Update `` to the previous release version so that ApiCompat validates against the last stable release of the prior MAJOR version. After the new MAJOR release is published, the baseline stays at the new version for future comparisons. - **MINOR or PATCH version bump**: Keep `` at the last MAJOR release version (e.g., keep `1.0.0` when releasing `1.1.0` or `1.0.1`). +**A baseline that trails `VersionPrefix` is the expected steady state, not a stale value.** Through a MAJOR series the baseline deliberately stays put while `VersionPrefix` advances, so seeing `2.0.0` alongside a published `2.1.0` means the rule is being followed. Do not "fix" the gap — bumping the baseline mid-series triggers the audit below and invites the released API surface to be re-baselined against itself, silently discarding the compatibility guarantee the property exists to enforce. + +**Any change to this property triggers the [baseline-transition suppression audit](#baseline-transition-suppression-audit).** Do not change it and interpret the resulting failures as breaking changes — the failures are expected until the suppressions are reconciled. + +### Baseline-transition suppression audit + +**Whenever `PackageValidationBaselineVersion` changes, run this audit before interpreting any +ApiCompat failure and before adding a single suppression entry.** + +Suppression entries are scoped to the baseline they were generated against. They record "this +difference from *that* baseline is intentional." Move the baseline and the differences change, so +entries written for the old baseline may describe nothing at all — the API they excused is now +present on both sides. The tool reports those orphans as unnecessary, and the build fails. + +For **every shipping project**: + +1. Inventory the tracked suppressions: + ```sh + ls src/*/CompatibilitySuppressions.xml + ``` +2. Regenerate what the *current* baseline actually requires, into a throwaway file so the tracked + one is not overwritten while you are still deciding: + ```sh + dotnet clean src/{Project}/{Project}.csproj -c Release + dotnet pack src/{Project}/{Project}.csproj -c Release \ + /p:ApiCompatGenerateSuppressionFile=true \ + /p:ApiCompatSuppressionOutputFile={unique-temp-path} + ``` + Use the **final candidate version and the final baseline** — regenerating against a version you + are about to change invalidates the result. +3. Compare the generated entries against the tracked file, by count and by content. + +| Generated | Tracked | Meaning | Action | +|---|---|---|---| +| Empty | Non-empty | Every tracked entry is stale for this baseline | Clear or delete the tracked file | +| Non-empty | Matches | Suppressions are current | Leave them alone | +| Non-empty | Differs | Some entries stale, some breaks genuinely need suppressing | Reconcile entry by entry, and confirm each remaining break with the user | + +4. After clearing stale entries, rerun the plain CI-equivalent pack with no generation flags, and + require it to pass on its own: + ```sh + dotnet clean -c Release + dotnet pack -c Release + ``` + +Reverting the baseline is the other valid resolution, and sometimes the better one — it keeps the +release diff minimal. Choose deliberately between "advance the baseline and clear the stale +suppressions" and "keep the existing baseline", rather than letting the choice be made by whichever +one silences the error first. Either way, the baseline is determined by what shipped, never selected +to make validation pass. + ### Compatibility Suppressions -When intentional breaking changes are confirmed, create or update `CompatibilitySuppressions.xml` in the affected project directory. The repo already uses this pattern — see `src/ModelContextProtocol.Core/CompatibilitySuppressions.xml` for examples. +When intentional breaking changes are confirmed, create or update `CompatibilitySuppressions.xml` in the affected project directory — the conventional location, which is auto-discovered. + +**A suppression file has three valid outcomes, not one.** Entries get *added* when a new intentional break needs suppressing, *retained* when they still describe a real break against the current baseline, and *cleared* when the baseline moved and they no longer describe anything. Treating the file as append-only is what turned 312 obsolete entries into a release-blocking failure that read as a mass breaking change. Preservation is the default only while the baseline holds still; once it moves, the [audit](#baseline-transition-suppression-audit) decides what stays, and removing entries it proves stale is the fix rather than a regression. + +Do not use a tracked file as a template for what entries should look like — it may legitimately be empty, and its contents describe whatever baseline it was generated against, not yours. Generate entries instead. ```xml @@ -53,7 +132,28 @@ When intentional breaking changes are confirmed, create or update `Compatibility ``` -The exact suppression entries are generated by the pack command when it reports errors — copy the suggested suppression XML from the build output. Remember that suppressions are needed **per target framework** (net10.0, net9.0, net8.0, netstandard2.0). +The exact suppression entries are generated by the pack command when it reports errors — copy the suggested suppression XML from the build output, or generate the file directly with `/p:ApiCompatGenerateSuppressionFile=true`. Remember that suppressions are needed **per target framework** (net10.0, net9.0, net8.0, netstandard2.0). + +#### Wiring the suppression file + +A `CompatibilitySuppressions.xml` sitting in the project directory is **auto-discovered**. That is +the convention this repo uses, and it needs no wiring at all. Do not add MSBuild properties or items +to point at a file that is already found by convention — duplicate or incorrect wiring is easy to +add while chasing a failure and hard to spot afterward, and it ships in the release commit. + +If you do need an explicit path: + +| Name | Kind | Use | +|---|---|---| +| `CompatibilitySuppressionFilePath` | **Property** | The supported way to point at a suppression file explicitly | +| `ApiCompatSuppressionFile` | **Item** | Not a property. Setting it via `/p:` does nothing | +| `ApiCompatSuppressionOutputFile` | **Property** | Where `ApiCompatGenerateSuppressionFile=true` writes its output | + +Retaining an empty suppressions file is fine when you want to keep the file in place after clearing +stale entries. It must still be **valid XML** — an empty `` root, not a zero-byte +file — and it must preserve the repository's byte conventions for these files, including the +UTF-8 BOM and the final newline. A file that differs only in BOM or trailing newline produces a +confusing diff and can trip tooling that round-trips it. ### Common Diagnostic IDs @@ -191,14 +291,37 @@ _or_ [Diff or table of changes] ``` -### In the User Summary (Step 11) +### In the User Summary (Step 12) -Present a condensed version for the user review: +Present a condensed version for the user review. **Report every shipping package, and do not state +that ApiCompat passed without these four facts** — "passed" is not meaningful without knowing what +it was validated against and whether stale suppressions were masking or manufacturing the result: -- **ApiCompat**: pass/fail with count of issues and suppressions per package -- **ApiDiff**: count of additions, removals, and changes per package +| Package | Baseline | Generated entries | Retained / removed | Plain pack | +|---|---|---|---|---| +| {package} | {baseline} | 0 | 0 retained / 312 removed | ✅ | +| {package} | {baseline} | 0 | 0 / 0 | ✅ | + +Enumerate the packable projects under `src/` rather than working from a remembered list; the set +grows. A package that does not participate in validation still gets a row, reporting why — a first +release has no baseline to compare against, and that is a fact worth stating rather than an absence +worth hiding. + +- **Baseline** — the `PackageValidationBaselineVersion` actually used, and whether it changed during + this release +- **Generated entries** — count from `ApiCompatGenerateSuppressionFile=true` at the final version + and baseline +- **Retained / removed** — tracked suppressions kept versus cleared as stale +- **Plain pack** — result of the CI-equivalent `dotnet clean -c Release; dotnet pack -c Release` + with no generation flags, which is the run CI will reproduce + +Then the summary lines: ``` -API Compatibility: ✅ All 3 packages pass (2 existing suppressions in Core) +API Compatibility: ✅ All {n} packages pass against v{baseline} ({n} stale suppressions removed from {package}) API Diff: +12 additions, -2 removals, ~3 changes across all packages ``` + +If the baseline changed, or any suppression file was modified, say so explicitly and explain why. +A silent baseline or suppression edit is the kind of change that passes local validation and then +fails CI. diff --git a/.github/skills/prepare-release/references/categorization.md b/.github/skills/prepare-release/references/categorization.md index 8445667..55d2570 100644 --- a/.github/skills/prepare-release/references/categorization.md +++ b/.github/skills/prepare-release/references/categorization.md @@ -11,14 +11,30 @@ Feature work, bug fixes, API improvements, performance enhancements, and any oth - Changes that span code + docs (categorize based on the primary intent) ### Documentation Updates -PRs whose **sole purpose** is documentation. Examples: +PRs whose **sole purpose** is documentation, guidance, or examples. Examples: - Fixing typos in docs - Adding or improving XML doc comments (when not part of a functional change) - Updating conceptual documentation (e.g., files in `docs/`) - README updates - Adding CONTRIBUTING.md or similar guides +- **Adding or improving samples** under `samples/`, including new executable sample projects +- Clarifying how consumers should use existing behavior, even when the PR touches tests to + demonstrate or lock in that behavior -**Important**: A PR that changes code AND updates docs should go in "What's Changed" — only pure documentation PRs belong here. However, documentation PRs should still be studied during the breaking change audit, as they may document changes that were not properly flagged as breaking. +**The test that matters is whether the shipped packages changed**, not whether the PR contains +code. A PR that adds a whole new sample application is still a documentation update: nothing in +`src/` shipped differently because of it. Ask "would a consumer upgrading the NuGet package +observe any difference?" If no, it belongs here. + +**Important**: A PR that changes shipped product code under `src/` AND updates docs should go in +"What's Changed" — only PRs that leave the shipped surface untouched belong here. However, +documentation PRs should still be studied during the breaking change audit, as they may document +changes that were not properly flagged as breaking. + +Categorize conservatively: when a PR could plausibly land in either "What's Changed" or +"Documentation Updates", prefer "Documentation Updates" and surface the call to the user at the +categorization review. Overstating a docs PR as product work inflates the apparent scope of a +release, and it is the error users notice and correct. ### Repository Infrastructure Updates PRs that maintain the development environment but don't affect the shipped product or test coverage. Examples: @@ -114,3 +130,6 @@ Sort entries within each section by **merge date** (chronological order, oldest * @user submitted issue #1234 (resolved by #5678) * @user1 @user2 @user3 reviewed pull requests ``` + +Do not acknowledge maintainers as issue reporters or new contributors; see Step 10 item 7. They +belong only in the reviewers bullet. diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index 173bb20..be4d19b 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -16,6 +16,19 @@ Use the shared [release branch reference](../shared-resources/release-branches.m Work through each step sequentially. Present findings at each step and get user confirmation before proceeding. +### Step 0: Sync With Upstream + +This skill reads the merged release PR, the commit range since the previous release, and the +previous release tag. All three come from local refs that may be stale — most importantly, the +merge commit for the release PR will not exist locally until you fetch. + +1. Identify the remote pointing at `modelcontextprotocol/csharp-sdk` (`git remote -v`) — do not + assume it is `origin`. +2. `git fetch {upstream} --prune --prune-tags --tags` +3. Confirm the merged release PR's merge commit resolves locally. + +Report anything the fetch changed before continuing. + ### Step 1: Identify the Prepare-Release PR The user may provide: @@ -31,7 +44,7 @@ Verify the PR is merged. Extract: ### Step 2: Determine Version and Commit Range 1. Read `src/Directory.Build.props` at the merge commit to confirm `` and ``. The tag is `v{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present; for example, `2.0.0` + `preview.1` → `v2.0.0-preview.1`. -2. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). The lookup is branch-aware: when the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md). +2. Determine the previous release tag from `gh release list` — the **highest semver** among published releases that are ancestors of the merge commit (exclude drafts with `--exclude-drafts`). Do not order by publication date. When the merge commit is on a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, there is no MAJOR filter. See [release-branches.md](../shared-resources/release-branches.md#previous-release-tag-lookup). 3. Identify the full commit range: previous release tag → merge commit. ### Step 3: Check for Additional PRs @@ -67,7 +80,9 @@ Re-categorize all PRs in the commit range (including any new ones from Step 3). 1. **Re-run the breaking change audit** using the **breaking-changes** skill if new PRs were found that may introduce breaks. Otherwise, carry forward the results from the prepare-release PR. 2. **Re-categorize** all PRs into sections (What's Changed, Documentation, Tests, Infrastructure). 3. **Re-attribute** co-authors for any new PRs by harvesting `Co-authored-by` trailers from all commits in each PR. -4. **Update acknowledgements** to include contributors from new PRs. +4. **Update acknowledgements** to include contributors from new PRs, excluding maintainers as issue reporters (see prepare-release Step 10 item 7). +5. **Carry forward the prepare-release categorization decisions.** If the user recategorized a PR or removed an acknowledgement during preparation, honor that. Re-deriving categories from scratch will silently reintroduce the exact corrections they already made. +6. **Review with the user** using the categorization table and acknowledgements roster from prepare-release Step 10b — at minimum for PRs new since preparation, and for any entry whose section you changed. Do not fold this into the Step 9 draft-creation gate. ### Step 5: Review README and Validate Code Samples @@ -81,7 +96,24 @@ Re-run the README content checklist from [../prepare-release/references/readme-c 2. **Snippet validation** -- Extract `csharp`-fenced code blocks from `src/PACKAGE.md` and `README.md`, build the temporary test project, and report results. Follow [../prepare-release/references/readme-snippets.md](../prepare-release/references/readme-snippets.md) for the full procedure. 3. **Delete** the temporary project after validation. -If issues are found, present them to the user with proposed fixes. Any fixes must be applied as a separate commit before the draft release is created. +If issues are found, present them to the user with proposed fixes. + +**Applying them is not a local commit.** The release PR is already merged, so its branch is gone; +fixes belong on the base branch this release ships from (`main` or `release/{MAJOR}.x`), which is +protected. Open a small PR for them, let CI run, and merge it — do not push to the base branch +directly, and do not amend or re-tag anything already reviewed. + +Then **re-target the draft release**, which is pinned to the previously approved merge commit and +therefore does not contain the fix: + +```sh +gh release edit v{version} --target {new-merge-commit-sha} +``` + +Regenerate the release notes afterward so the commit range covers the new PR, and re-run the Step 6 +section review for anything that changed. If the user prefers not to take the fix in this release, +that is a valid choice — leave the draft pinned where it is and note the deferred item, rather than +carrying a fix that the tag will not include. **Edge Cases:** - **Stale package closure** -- A package introduced between prepare-release and now may not be listed. Add it to `src/PACKAGE.md` and `README.md`. @@ -102,7 +134,7 @@ Highlight any changes from the prepare-release draft (new entries, reordered ent ### Step 7: Preamble -Every release **must** have a preamble — a short paragraph summarizing the release theme that appears before the first `##` heading. The preamble is not optional. The preamble may mention the presence of breaking changes as part of the theme summary, but the versioning documentation link belongs under the Breaking Changes heading (see template), not in the preamble. +Every release **must** have a preamble — a short paragraph summarizing the release theme that appears before the first `##` heading. The preamble is not optional. The preamble may mention the presence of breaking changes as part of the theme summary, but the versioning documentation link belongs under the Breaking Changes heading (see template), not in the preamble. That link must use the `v{MAJOR}` slug for the version being released. Extract the draft preamble from the prepare-release PR description and present it alongside a freshly drafted alternative (accounting for any new PRs). @@ -123,11 +155,65 @@ Display release metadata for user review: - **Target**: merge commit SHA, its message, the merge commit's branch (the prepare-release PR base), and the prepare-release PR link After confirmation: -- Create with `gh release create --draft {tag} --target {merge-commit-branch}` (always `--draft`), using the prerelease tag verbatim when present +- Create with `gh release create --draft {tag} --target {merge-commit-sha}` (always `--draft`), using the prerelease tag verbatim when present +- **Target the full commit SHA, never a branch name.** A draft sits unpublished until a human + reviews and publishes it, which can be hours. `--target` is resolved when the tag is created -- + at publish time, not now -- so a branch name silently re-resolves to whatever landed on that + branch in the meantime. The tag would then be cut at a commit nobody reviewed, and the release + notes would describe a different commit than the one shipped. The SHA you displayed above is the + commit the user approved; pass that exact SHA. - **Never publish.** If the user asks to publish, decline and instruct them to publish manually. +Pinning the SHA costs nothing, because a draft release does not create the git tag. GitHub stores +the target and creates the tag only when the release is published, so the tag remains uncreated and +the draft fully editable while it waits. + +That is also what makes a late-arriving commit easy to absorb. If the user decides to include work +that merged after the draft was created, do not delete and recreate the release: repoint it with +`gh release edit {tag} --target {new-commit-sha}`, then regenerate the release notes for the new +range and present them for approval again. Never move the target without revising the notes to +match -- a target change silently alters what shipped. + +Then hand off to the user with the publishing checklist: + +> The draft release is ready at {release URL}. Before publishing: +> +> 1. Review the release notes line by line — this is the last review before they are public. +> 2. Check **Set as a pre-release** if this is a prerelease. +> 3. Once you have signed off on the notes, **remove the AI-generated disclosure note** from the +> bottom of the body. It is there because the draft was AI-drafted; after your thorough review +> and sign-off, the published notes stand as your reviewed work. +> 4. Click **Publish release**. + +The disclosure is removed by the **user**, as part of their sign-off — never remove it yourself, and +never remove it from a pull request description, an issue, or a comment. If the user asks you to +edit the draft body after they have removed it, do not reintroduce it. + When the user requests revisions after the initial creation, always rewrite the complete body as a file — never perform in-place string replacements. See [references/formatting.md](references/formatting.md). +### Step 10: Watch for Publication + +Do not end the skill by asking the user to report back when they have published. Poll the release +until it is no longer a draft: + +```sh +gh release view v{version} --json isDraft,publishedAt,tagName,isPrerelease +``` + +Poll at a modest interval — this gate is human-paced and may span hours or a session boundary. Say +that you are watching rather than going silent. + +When `isDraft` becomes `false`: + +1. Record the publication time from `publishedAt`, not from when the poll noticed. +2. Confirm the tag that was actually created and whether the release was marked as a prerelease. + Both were the user's to set and cannot be inferred. +3. **Hand off to the verify-release skill immediately.** Publishing starts the Release and Publish + Docs workflows in parallel at that moment; verification that begins late misses them mid-flight. + +If the user reports publishing but the API still shows a draft, trust the API and say so — an +unsaved draft looks identical to a published release from the browser. + ## Edge Cases - **No new PRs since preparation**: proceed normally — the prepare-release notes are used as the foundation with no warnings @@ -139,18 +225,23 @@ When the user requests revisions after the initial creation, always rewrite the - **PR spans categories**: categorize by primary intent - **Copilot timeline missing**: fall back to `Co-authored-by` trailers to determine whether `@Copilot` should be a co-author; if still unclear, use `@Copilot` as primary author - **No breaking changes**: omit the Breaking Changes section entirely +- **Versioning link carried over from the prepare-release draft**: the draft may contain an unslugged or wrong-MAJOR versioning link. Correct it to the `v{MAJOR}` slug of the version being released before the draft release is created. +- **Versioning link for a brand-new MAJOR**: the `/v{MAJOR}/versioning.html` path is created by the Publish Docs workflow when the release is published. It is expected to 404 until then; use the slugged form regardless. - **Single breaking change**: use the same numbered format as multiple +- **Draft edited but not published**: the user is still reviewing, and may be removing the AI disclosure. Take no action and do not reintroduce anything they removed +- **Draft disappears without a published release**: it may have been deleted, or published under a different tag. Check for a published release before assuming it was abandoned, then ask +- **Published tag differs from the prepared version**: stop and confirm with the user before verifying. Verifying the wrong version is worse than not verifying ## Release Notes Template -Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. +Omit empty sections. The preamble is **always required** — it is not inside a section heading. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. The versioning link uses the `v{MAJOR}` slug for the version being released — see [release-branches.md](../shared-resources/release-branches.md#versioning-documentation-links). ```markdown [Preamble — REQUIRED. Summarize the release theme.] ## Breaking Changes -Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/versioning.html) documentation for details on versioning and breaking change policies. +Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html) documentation for details on versioning and breaking change policies. 1. **Description #PR** * Detail of the break diff --git a/.github/skills/publish-release/references/formatting.md b/.github/skills/publish-release/references/formatting.md index 467dbb3..888f3c5 100644 --- a/.github/skills/publish-release/references/formatting.md +++ b/.github/skills/publish-release/references/formatting.md @@ -27,7 +27,7 @@ When the user requests changes to existing release notes: 1. Fetch the current release body and save it to a local file 2. **Breaking change audit**: Run the full breaking-changes skill audit on the commit range, just as for new release notes — this includes examining PRs, reconciling labels, offering to comment on PRs, and getting user confirmation. Also extract any breaking changes already documented in the existing release body; these must be preserved and reconciled with the audit results. -3. **Preamble check**: Verify the release has a preamble (text before the first `##` heading). If missing, compose one. The versioning documentation link belongs under the `## Breaking Changes` heading, not in the preamble. +3. **Preamble check**: Verify the release has a preamble (text before the first `##` heading). If missing, compose one. The versioning documentation link belongs under the `## Breaking Changes` heading, not in the preamble, and must use the `v{MAJOR}` slug for the released version — see [release-branches.md](../../shared-resources/release-branches.md#versioning-documentation-links). 4. Write the **entire** corrected body to a separate local file (ensuring proper line breaks between all sections, entries, and paragraphs) 5. Run `git diff --no-index` between the original and updated files and **always** present the raw diff output directly in the response as a fenced code block with `diff` syntax highlighting. Do not summarize or paraphrase the diff — always show the complete diff to the user. Require explicit confirmation before uploading. For published releases (not drafts), also offer to save the original body to a permanent local file, noting that GitHub does not retain prior versions of release notes. 6. Upload the complete file using `gh release edit --notes-file ` @@ -47,8 +47,25 @@ After every release body update: - [ ] Preamble exists before the first `##` heading - [ ] If `## Breaking Changes` section exists, it begins with the versioning docs link paragraph before the numbered list +- [ ] The versioning docs link uses the `v{MAJOR}` slug for the released version (e.g. `/v2/versioning.html`), never the unslugged `/versioning.html` - [ ] Line count matches expected structure (~80+ lines for a typical release) - [ ] Section headings (`## Breaking Changes`, `## What's Changed`, etc.) each appear on their own line - [ ] Bullet entries are each on their own line - [ ] No stray characters at the start of the body - [ ] Preview the release on GitHub to confirm rendering + +## AI Disclosure + +A draft release body created by an agent carries a concise AI-generated disclosure at the bottom: + +```markdown +> [!NOTE] +> These release notes were drafted with GitHub Copilot and reviewed before publishing. +``` + +Keep it on the draft. Removing it is the **user's** decision, made as part of their final sign-off +once they have reviewed the notes line by line and are satisfied the content is theirs. Never remove +it on your own initiative, and never reintroduce it after the user has removed it. + +This exception applies only to release notes, which get a dedicated human review before publishing. +Disclosures on pull request descriptions, issues, and comments always remain. diff --git a/.github/skills/shared-resources/release-branches.md b/.github/skills/shared-resources/release-branches.md index 4775677..298776b 100644 --- a/.github/skills/shared-resources/release-branches.md +++ b/.github/skills/shared-resources/release-branches.md @@ -25,12 +25,61 @@ Official NuGet.org publishes happen only when a GitHub Release is created from a ## Previous-release tag lookup -- On `main`: most recent published release globally (use `gh release list --exclude-drafts --limit 50` and pick the highest semver). No MAJOR filter. -- On `release/{MAJOR}.x`: most recent published release whose tag matches `v{MAJOR}.*`. Drafts are excluded. +Select the **highest semver** among published releases that are **ancestors of the target commit**, +excluding drafts: + +```sh +gh release list --exclude-drafts --limit 50 +``` + +- On `main`: no MAJOR filter — the highest semver ancestor wins. +- On `release/{MAJOR}.x`: restrict candidates to tags matching `v{MAJOR}.*`. + +**"Highest semver" and "most recent by date" are not the same rule, and the difference is not +hypothetical.** Ship `v2.1.0` from `main`, then a `v2.0.1` servicing patch from `release/2.0.x`, and +the most recently *published* release is `v2.0.1` while the highest semver is `v2.1.0`. Ordering by +date picks a tag that is not on `main` at all, which produces a bogus PR range and makes ApiCompat +report the entire API surface as removed. Order by version, not by publication time. + +The ancestry constraint is what makes this safe across branches, so verify it rather than assuming +the version ordering implied it — a tag can be both the highest semver and unreachable from the +target. `prepare-release` Step 2 performs this check explicitly. This is purely a baseline-selection rule. It does **not** change the breaking-change policy. See [the versioning docs](https://csharp.sdk.modelcontextprotocol.io/versioning.html) for the policy. -## Work-branch naming +## Versioning documentation links + +The documentation site is published per major version under a `v{MAJOR}` slug (`/v1/`, `/v2/`). Any +link to the versioning documentation from **release notes** — both the release-notes link and the +paragraph under the `## Breaking Changes` heading — must point at the slugged instance for the +version being released: + +``` +https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html +``` + +The slug is derived from the **MAJOR component of the version being released**, not from the branch: + +| Version being released | Versioning link | +| ---------------------- | --------------- | +| `1.3.1` | `https://csharp.sdk.modelcontextprotocol.io/v1/versioning.html` | +| `2.0.0-preview.1` | `https://csharp.sdk.modelcontextprotocol.io/v2/versioning.html` | +| `2.0.0` | `https://csharp.sdk.modelcontextprotocol.io/v2/versioning.html` | + +The branch is normally consistent with this — `release/1.x` releases `1.x` versions and `main` +currently releases `2.x` — but the version is what determines the slug. If a release's MAJOR ever +disagrees with its branch's MAJOR, follow the version. + +Prerelease suffixes do not affect the slug: `2.0.0-preview.1` and `2.0.0` both use `/v2/`. + +The unslugged `https://csharp.sdk.modelcontextprotocol.io/versioning.html` redirects to the site's +default version, which tracks the newest release. It is therefore unstable for a published release's +notes — a later MAJOR would silently repoint it. Never use the unslugged form in release notes. + +**First release of a new MAJOR**: the `/v{MAJOR}/` path does not exist until the Publish Docs +workflow runs, which happens when the GitHub release is published. The link is forward-referencing +at prepare and publish time, exactly like the release-notes tag link, and resolves once the release +is published. The **verify-release** skill confirms it. Prepare-release work branches are named `release-{version}` (flat, hyphen-separated): - `release-2.0.0-preview.1` diff --git a/.github/skills/verify-release/SKILL.md b/.github/skills/verify-release/SKILL.md new file mode 100644 index 0000000..1f8a706 --- /dev/null +++ b/.github/skills/verify-release/SKILL.md @@ -0,0 +1,190 @@ +--- +name: verify-release +description: Verify a published release of the C# MCP SDK. Monitors the Release and Publish Docs workflows triggered by publishing a GitHub release, confirms the packages are listed on NuGet.org, and confirms the versioned documentation site reflects the release. Use when asked to verify a release, check whether a release published correctly, monitor the release or docs workflow, confirm packages on NuGet, or check whether the docs site updated. +compatibility: Requires gh CLI with repo access for workflow runs and releases, and network access to nuget.org and csharp.sdk.modelcontextprotocol.io. +--- + +# Verify Release + +Verify that a published release of `modelcontextprotocol/csharp-sdk` fully shipped. Publishing a +GitHub release triggers **two workflows in parallel**, and the release is not done until both have +succeeded and both of their outputs are confirmed live. + +| Workflow | File | Trigger | Produces | +|---|---|---|---| +| Release | [`.github/workflows/release.yml`](../../workflows/release.yml) | `release: published` | NuGet packages published to NuGet.org | +| Publish Docs | [`.github/workflows/docs.yml`](../../workflows/docs.yml) | `release: published` | The versioned docs site at | + +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles +and release tag conventions. + +> **Safety: This skill is read-only by default.** It inspects workflow runs, releases, and published +> artifacts. The only actions it may take are re-running a failed workflow or dispatching a docs +> refresh, and both require explicit user confirmation. + +## Process + +Work through each step sequentially. Present findings at each step and get user confirmation before +taking any action. + +### Step 1: Identify the Release + +The user may provide: +- **A version or tag** (e.g., `2.0.0-preview.1`, `v1.3.1`) — use directly +- **No context** — list recent releases with `gh release list --limit 10` and ask the user to select + +Confirm the release is **published**, not a draft: + +``` +gh release view {tag} --json tagName,isDraft,isPrerelease,publishedAt,targetCommitish,url +``` + +If the release is still a draft, **stop**. Neither workflow has run — nothing is published, and no +verification is possible. Tell the user the draft must be published in the GitHub UI first, and +that publishing is a deliberate human action this skill will not perform. + +Record the tag, the published timestamp, and the target commitish for the following steps. + +### Step 2: Locate Both Workflow Runs + +Find the runs triggered by publishing this release. A release-event run carries the **tag name in +`headBranch`**, which is an exact identifier — use it rather than correlating on timestamps: + +``` +gh run list --workflow release.yml --event release --branch v{version} --limit 5 --json databaseId,status,conclusion,headBranch,headSha,createdAt,url +gh run list --workflow docs.yml --event release --branch v{version} --limit 5 --json databaseId,status,conclusion,headBranch,headSha,createdAt,url +``` + +**Do not identify runs by "the most recent run" or "created at or after `publishedAt`."** Those +match any release published in the same window, so a concurrent or closely-following release — +including a servicing patch published from another branch minutes later — can be reported as this +release's result, showing a green run for the wrong tag. Confirm `headBranch` equals `v{version}` +on every run before evaluating it. + +Cross-check `headSha` against the release's target commitish recorded in Step 1. A mismatch means +the tag moved between drafting and publishing, and the run validated something other than what was +reviewed — stop and report it rather than evaluating the run. + +If more than one run matches the tag, the workflow was re-run; evaluate the **latest attempt** and +say that earlier attempts existed rather than silently reporting only the newest. + +Present both runs with their status, conclusion, and URL. Watch them **together** — they run +concurrently and either can fail independently. Do not report success for the release until both +are accounted for. + +If a run cannot be found for either workflow, report which one is missing and check whether the +workflow is disabled or whether its `if` repository guard excluded the run (both workflows only run +in the `modelcontextprotocol/csharp-sdk` repository, not in forks). + +### Step 3: Evaluate the Release Workflow + +Report the run's conclusion. If it failed, identify the failing job and step and summarize the +error: + +``` +gh run view {run-id} --log-failed +``` + +A failure here does **not** roll back the release — the GitHub release and its tag remain, and the +workflow is simply re-run once the cause is addressed. Re-running is safe and is usually the right +first move. Recommend it, but **do not re-run without explicit user confirmation**. + +> **Never run `dotnet nuget push` and never handle NuGet API keys.** Package publishing happens only +> through the workflow. + +### Step 4: Evaluate the Publish Docs Workflow + +Report the run's conclusion, accounting for these docs-specific behaviors: + +- **Superseded runs are not failures.** The workflow uses a `pages` concurrency group with + `cancel-in-progress: true`. Every run rediscovers the current releases and rebuilds the whole site + from scratch, so a newer run fully supersedes the one it cancels. Report a cancelled run as + *superseded* and follow the newer run instead. +- **Version discovery reads published releases.** For each major version >= 1, the workflow takes + the most recently published non-draft release tagged `v{MAJOR}.*`. A draft release contributes + nothing. +- **Every major is rebuilt.** Each major's docs are built from that major's latest release tag into + its own path (`/v1/`, `/v2/`). A new MAJOR adds a new path; the site root redirects to the newest + release, prereleases included. +- **Orchestration comes from `main`.** The scripts and picker assets are always checked out from + `main`, while each version's content comes from its release tag. A docs fix that lives only in a + release branch will not affect orchestration. + +If it failed, summarize the failing step. Common causes are a docs build failure in one version's +worktree (`make generate-docs`) or a Pages deployment error. + +### Step 5: Confirm the Published Packages + +Confirm the exact released version is listed for each shipping package on NuGet.org. + +Listing can lag a successful workflow run by several minutes. If the workflow succeeded but the +version is not yet visible, say so explicitly and offer to re-check — **do not report this as a +failure**. Distinguish "published but not yet indexed" from "not published." + +Report each package with its status, and flag any shipping package missing from the release. + +### Step 6: Confirm the Documentation Site + +Confirm reflects this release: + +1. **Version path** — the major-version path for this release (for example `/v2/`) is live and + serving the new content. +2. **Version picker** — the picker offers this release's major version. +3. **Root redirect** — the site root redirects to the expected default version, which is the newest + release by publish date, prereleases included. +4. **Versioning page** — the slugged versioning page for this release, + `https://csharp.sdk.modelcontextprotocol.io/v{MAJOR}/versioning.html`, resolves. Release notes + link to it from the Breaking Changes section, and for the first release of a new MAJOR that path + only comes into existence with this workflow run. Confirm the release notes use the slugged form + and not the unslugged `/versioning.html`, which tracks the site default and can silently repoint + when a later MAJOR ships. + +GitHub Pages caches aggressively, so a short delay after a successful deploy is normal. +Distinguish "deployed but not yet propagated" from "deployed wrong." + +### Step 7: Report + +Summarize the verification as a table covering both workflows and both published outputs, and state +plainly whether the release is fully verified or what remains outstanding. + +| Check | Status | +|---|---| +| Release workflow | ✅ succeeded — {run URL} | +| Publish Docs workflow | ✅ succeeded — {run URL} | +| Packages on NuGet.org | ✅ {version} listed for all N packages | +| Docs site | ✅ `/v2/` live, picker updated, root redirects | + +## Remediation + +Both remediations require explicit user confirmation. + +**Re-run a failed workflow:** + +``` +gh run rerun {run-id} --failed +``` + +**Refresh the docs without a new release** — when documentation content needs correcting after the +release, the docs workflow accepts a manual dispatch that rebuilds one major version's content from +an arbitrary ref, without minting a product release: + +``` +gh workflow run docs.yml --field docs_ref={branch-tag-or-commit} +``` + +The ref's major version, read from `src/Directory.Build.props`, must have a published release; the +workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration +and all other versions are unaffected. + +## Edge Cases + +- **Release is still a draft** — stop; neither workflow has run. The user must publish in the GitHub UI. +- **Docs run cancelled** — expected under the `pages` concurrency group; report as superseded and follow the newer run. +- **Only one workflow ran** — check whether the other is disabled, or whether the repository guard excluded it (forks do not run either workflow). +- **Workflow succeeded but NuGet version not listed** — indexing lag; re-check before reporting a failure. +- **Workflow succeeded but docs not visible** — Pages caching; re-check before reporting a failure. +- **Docs site missing the new major version** — confirm the release is published and non-draft, then confirm the tag matches `v{MAJOR}.*`. +- **Root redirects to an unexpected version** — the default is the newest release *by publish date*, including prereleases. A prerelease published after a stable release becomes the default; this is by design. +- **Release workflow failed after partial publish** — some packages may already be on NuGet.org. NuGet versions cannot be unpublished; re-running skips already-published versions. Report exactly which packages are listed before recommending a re-run. +- **Versioning link is unslugged or points at the wrong MAJOR** — release notes must link to `/v{MAJOR}/versioning.html` for the released version. Report it so the user can correct the body; the unslugged form tracks the site default and will repoint when a later MAJOR ships. +- **Verifying an older release** — the docs workflow only ever reflects each major's *latest* release, so an older release's docs path will have been overwritten by a newer one. Verify packages only and note this. From f277062ab23da7a6853de17475589d0f5f5b2704 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:32:34 -0700 Subject: [PATCH 44/58] Bump Anthropic from 12.39.0 to 12.40.0 (#1808) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d25bff..69ed858 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -63,7 +63,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive From 7acbeb580ef4fdef789ae962b6906d55507e5d95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:47:20 -0700 Subject: [PATCH 45/58] Bump @hono/node-server from 1.19.14 to 2.1.0 in the npm_and_yarn group across 1 directory (#1812) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 73d7e0f..774becc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" From 149c5c47db82ee7a9eedfae3fbd6cf78854220cd Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:19:28 +1200 Subject: [PATCH 46/58] Fix dead relative link to versioning docs in bump-version skill (#1807) --- .github/skills/bump-version/references/semver-assessment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/bump-version/references/semver-assessment.md b/.github/skills/bump-version/references/semver-assessment.md index e9f4508..3d14978 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -62,7 +62,7 @@ While the candidate version uses a prerelease suffix (e.g., `X.Y.Z-preview.N`, ` Going to GA drops the suffix entirely: `2.0.0-rc.2` → `2.0.0`. -This is purely about how to *compute* the next version. It does **not** declare any new policy about what kinds of changes are permitted between previews — refer to the existing [versioning documentation](../../../../docs/versioning.html) for breaking-change policy. +This is purely about how to *compute* the next version. It does **not** declare any new policy about what kinds of changes are permitted between previews — refer to the existing [versioning documentation](../../../../docs/versioning.md) for breaking-change policy. ### Branch context From 2912e94f928719d4218f0a98f57feb6d92e02281 Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:24:18 +1200 Subject: [PATCH 47/58] Fix duplicated word in test comment (#1809) --- .../Utils/KestrelInMemoryConnection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs index c632630..59cce6b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/Utils/KestrelInMemoryConnection.cs @@ -92,7 +92,7 @@ public sealed class KestrelInMemoryConnection : ConnectionContext protected override void Dispose(bool disposing) { - // Signal to the server the the client has closed the connection, and dispose the client-half of the Pipes. + // Signal to the server the client has closed the connection, and dispose the client-half of the Pipes. ThreadPool.UnsafeQueueUserWorkItem(static cts => ((CancellationTokenSource)cts!).Cancel(), connectionClosedCts); duplexPipe.Input.Complete(); duplexPipe.Output.Complete(); From bba45c44bf8397919c3af5b401aa752ee7697c0e Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:27:48 +1200 Subject: [PATCH 48/58] Fix McpHeaderEncoder.DecodeValue throwing on the degenerate base64 wrapper (#1805) --- .../Protocol/McpHeaderEncoder.cs | 3 ++- .../Client/McpHeaderEncoderTests.cs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs index 9366063..904f4db 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs +++ b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs @@ -126,7 +126,8 @@ public static class McpHeaderEncoder // Check for Base64 wrapper. The spec requires the sentinel markers to be // case-sensitive and exactly lowercase per SEP-2243. - if (headerValue.StartsWith(Base64Prefix, StringComparison.Ordinal) && + if (headerValue.Length >= Base64Prefix.Length + Base64Suffix.Length && + headerValue.StartsWith(Base64Prefix, StringComparison.Ordinal) && headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal)) { var base64Content = headerValue.Substring( diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs index 26de71b..1e4fc00 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs @@ -104,6 +104,16 @@ public class McpHeaderEncoderTests Assert.Equal("Hello", result); } + [Fact] + public void DecodeValue_DegenerateWrapper_ReturnsLiteralValue() + { + // "=?base64?=" matches both the prefix "=?base64?" and the suffix "?=" because they + // overlap on the shared '?', but it is too short to contain any base64 content. It must be + // returned as-is rather than throwing when the wrapper is stripped. + var result = McpHeaderEncoder.DecodeValue("=?base64?="); + Assert.Equal("=?base64?=", result); + } + [Fact] public void DecodeValue_CaseSensitivePrefix_ReturnsLiteralValue() { From c6a97d94d66e8278b8e5db2032f5df4fc617f3c2 Mon Sep 17 00:00:00 2001 From: pardhu Date: Wed, 12 Aug 2026 23:36:36 -0700 Subject: [PATCH 49/58] Add HttpServerSessionMode for hybrid stateful/stateless HTTP serving (#1796) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jeff Handley --- docs/concepts/completions/completions.md | 2 +- docs/concepts/elicitation/elicitation.md | 2 +- .../elicitation/samples/server/Program.cs | 5 +- docs/concepts/filters.md | 8 +- docs/concepts/getting-started.md | 9 +- docs/concepts/httpcontext/samples/Program.cs | 3 +- .../logging/samples/server/Program.cs | 5 +- docs/concepts/mrtr/mrtr.md | 6 +- docs/concepts/pagination/pagination.md | 2 +- .../progress/samples/server/Program.cs | 3 +- docs/concepts/prompts/prompts.md | 2 +- docs/concepts/resources/resources.md | 6 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 91 +++-- docs/concepts/tools/tools.md | 2 +- docs/concepts/transports/transports.md | 15 +- docs/list-of-diagnostics.md | 2 +- .../AspNetCoreMcpPerSessionTools/Program.cs | 5 +- .../AspNetCoreMcpPerSessionTools/README.md | 6 +- samples/AspNetCoreMcpServer/Program.cs | 5 +- samples/EverythingServer/Program.cs | 5 +- samples/ProtectedMcpServer/Program.cs | 9 +- src/Common/Obsoletions.cs | 2 +- .../HttpServerSessionMode.cs | 64 ++++ .../HttpServerTransportOptions.cs | 83 ++++- .../IdleTrackingBackgroundService.cs | 2 +- .../McpEndpointRouteBuilderExtensions.cs | 6 +- .../StreamableHttpHandler.cs | 72 ++-- .../HttpServerTransportOptionsTests.cs | 68 ++++ .../July2026ProtocolHttpHandlerTests.cs | 8 +- .../July2026ProtocolHybridSessionModeTests.cs | 344 ++++++++++++++++++ .../July2026ProtocolStatefulFallbackTests.cs | 40 +- .../MapMcpTests.Mrtr.cs | 2 +- .../MrtrProtocolTests.cs | 2 +- .../RawHttpConformanceTests.cs | 2 +- .../RequestAbortCancellationTests.cs | 9 +- .../ResumabilityIntegrationTestsBase.cs | 4 +- .../SessionMigrationTests.cs | 2 +- .../Program.cs | 7 +- 40 files changed, 764 insertions(+), 150 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs diff --git a/docs/concepts/completions/completions.md b/docs/concepts/completions/completions.md index 16a1695..0ac808a 100644 --- a/docs/concepts/completions/completions.md +++ b/docs/concepts/completions/completions.md @@ -26,7 +26,7 @@ Register a completion handler when building the server. The handler receives a r ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithResources() .WithCompleteHandler(async (ctx, ct) => diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 841c8a6..b899df2 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/elicitation/samples/server/Program.cs b/docs/concepts/elicitation/samples/server/Program.cs index b10dd7e..e7b9e37 100644 --- a/docs/concepts/elicitation/samples/server/Program.cs +++ b/docs/concepts/elicitation/samples/server/Program.cs @@ -1,4 +1,5 @@ using Elicitation.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,8 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Elicitation requires stateful mode because it sends server-to-client requests. - // Set Stateless = false explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since it's required. + options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index aac23ea..80d6d46 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -411,7 +411,7 @@ To enable authorization support, call `AddAuthorizationFilters()` when configuri ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .AddAuthorizationFilters() // Enable authorization filter support .WithTools(); ``` @@ -511,7 +511,7 @@ This allows you to implement logging, metrics, or other cross-cutting concerns t ```csharp services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithRequestFilters(requestFilters => { requestFilters.AddListToolsFilter(next => async (context, cancellationToken) => @@ -546,6 +546,8 @@ services.AddMcpServer() To use authorization features, you must configure authentication and authorization in your ASP.NET Core application and call `AddAuthorizationFilters()`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication("Bearer") @@ -556,7 +558,7 @@ builder.Services.AddAuthorization(); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .AddAuthorizationFilters() // Required for authorization support .WithTools() diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 73901c1..f3e2f58 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -78,6 +78,7 @@ dotnet add package ModelContextProtocol.AspNetCore And add the following code: ```csharp +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.ComponentModel; @@ -85,10 +86,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Stateless mode is recommended for servers that don't need - // server-to-client requests like sampling or elicitation. - // See the Stateless and Stateful documentation for details. - options.Stateless = true; + // Stateless mode is the default and recommended for servers that + // don't need server-to-client requests like sampling or elicitation. + // See the Stateless and Stateful documentation for details. + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithToolsFromAssembly(); var app = builder.Build(); diff --git a/docs/concepts/httpcontext/samples/Program.cs b/docs/concepts/httpcontext/samples/Program.cs index a01602d..c9d9dc6 100644 --- a/docs/concepts/httpcontext/samples/Program.cs +++ b/docs/concepts/httpcontext/samples/Program.cs @@ -1,4 +1,5 @@ using HttpContext.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +8,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/logging/samples/server/Program.cs b/docs/concepts/logging/samples/server/Program.cs index 48e2905..f7bc043 100644 --- a/docs/concepts/logging/samples/server/Program.cs +++ b/docs/concepts/logging/samples/server/Program.cs @@ -1,4 +1,5 @@ using Logging.Tools; +using ModelContextProtocol.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -8,8 +9,8 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Log streaming requires stateful mode because the server pushes log notifications - // to clients. Set Stateless = false explicitly for forward compatibility. - options.Stateless = false; + // to clients. Set SessionMode = HttpServerSessionMode.Stateful since it's required. + options.SessionMode = HttpServerSessionMode.Stateful; }) .WithTools(); // .WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult()); diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 8730e6f..0d0fafa 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -29,7 +29,7 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `SessionMode = HttpServerSessionMode.Stateful` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. `HttpServerSessionMode.StatefulForInitializeClients` ([hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)) accepts `2026-07-28` statelessly — and therefore enables MRTR — while still issuing sessions to `initialize`-handshake clients on the same endpoint. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. ```csharp // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. @@ -373,7 +373,7 @@ public static string CloseSupportTicket( ## Compatibility -The SDK supports `InputRequiredException` across two protocol revisions and two session modes: +The SDK supports `InputRequiredException` across protocol eras and effective request modes: | Negotiated protocol | Session mode | Behavior | |----------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| @@ -391,4 +391,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two Under `2025-11-25` and earlier, stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. -Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. `HttpServerSessionMode.StatefulForInitializeClients` ([hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)) serves `2026-07-28` requests through the stateless path — the `2026-07-28` / `Stateless` row — while `initialize`-handshake clients on the same endpoint follow the `2025-11-25` / `Stateful` row. diff --git a/docs/concepts/pagination/pagination.md b/docs/concepts/pagination/pagination.md index 3276fcf..0f60192 100644 --- a/docs/concepts/pagination/pagination.md +++ b/docs/concepts/pagination/pagination.md @@ -70,7 +70,7 @@ When implementing custom list handlers on the server, pagination is supported by ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithListResourcesHandler(async (ctx, ct) => { const int pageSize = 10; diff --git a/docs/concepts/progress/samples/server/Program.cs b/docs/concepts/progress/samples/server/Program.cs index cfff458..ef9c67c 100644 --- a/docs/concepts/progress/samples/server/Program.cs +++ b/docs/concepts/progress/samples/server/Program.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.AspNetCore; using Progress.Tools; var builder = WebApplication.CreateBuilder(args); @@ -7,7 +8,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); diff --git a/docs/concepts/prompts/prompts.md b/docs/concepts/prompts/prompts.md index 062f02b..5dcc236 100644 --- a/docs/concepts/prompts/prompts.md +++ b/docs/concepts/prompts/prompts.md @@ -63,7 +63,7 @@ Register prompt types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithPrompts() .WithPrompts(); ``` diff --git a/docs/concepts/resources/resources.md b/docs/concepts/resources/resources.md index 6b08f72..6080b2c 100644 --- a/docs/concepts/resources/resources.md +++ b/docs/concepts/resources/resources.md @@ -74,7 +74,7 @@ Register resource types when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithResources() .WithResources(); ``` @@ -209,8 +209,8 @@ Register subscription handlers when building the server: ```csharp builder.Services.AddMcpServer() // Subscriptions require stateful mode because the server pushes change notifications - // to clients. Set Stateless = false explicitly for forward compatibility. - .WithHttpTransport(o => o.Stateless = false) + // to clients. Set SessionMode = HttpServerSessionMode.Stateful since sessions are required. + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index a7a8ed2..fa9e1cd 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -112,7 +112,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 3dcbd44..7a1f907 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -129,7 +129,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including every Streamable HTTP request served under `2026-07-28`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `SessionMode = HttpServerSessionMode.Stateful` refuses `2026-07-28` so dual-path clients can fall back before using that flow, while `HttpServerSessionMode.StatefulForInitializeClients` instead serves `2026-07-28` statelessly on the same endpoint (see [hybrid mode](xref:stateless#hybrid-mode-sessions-for-initialize-clients-only)), so those requests use MRTR while `initialize`-handshake sessions keep this flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works across both protocol eras and all three HTTP `SessionMode` configurations. For example: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 899fd18..c393d3d 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -7,9 +7,9 @@ uid: stateless # Stateless and stateful mode -The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to **. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). -When sessions are enabled (`Stateless = false`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). +When sessions are enabled (`SessionMode = HttpServerSessionMode.Stateful`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http @@ -20,29 +20,36 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a - Do you need to support clients that only speak the [legacy SSE transport](#legacy-sse-transport)? → **Use stateful** with (disabled by default due to [backpressure concerns](#request-backpressure)). - Does your server manage per-client state that concurrent agents must not share (isolated environments, parallel workspaces)? → **Use stateful.** - Are you debugging a typically-stdio server over HTTP and want editors to be able to reset state by reconnecting? → **Use stateful.** -- Otherwise → **Use stateless** (`options.Stateless = true`). +- Does your server need sessions for existing clients but must also serve `2026-07-28` clients natively on the same endpoint? → **Use [hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only)** (`options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients`). +- Otherwise → **Use stateless** (`options.SessionMode = HttpServerSessionMode.Stateless`). > [!NOTE] -> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults to `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). +> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful, but not because the `2025-11-25` (and older) protocol revisions ever required a server to use the `Mcp-Session-Id` header. They didn't. The original SSE transport could only operate statefully, and keeping Streamable HTTP stateful by default let server-to-client requests (elicitation, sampling, roots) keep working on `2025-11-25` the way they always had. A client was required to echo a server-assigned `Mcp-Session-Id` on later requests, but whether to assign one was always the server's choice. The `2026-07-28` protocol revision removes the header (SEP-2567) and the `initialize` handshake (SEP-2575) from the wire format entirely, and server-to-client requests now run through [MRTR](xref:mrtr), so the SDK now defaults to to match the new wire format. You can still opt back into sessions with `SessionMode = HttpServerSessionMode.Stateful` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `SessionMode` property is the single most important setting for forward-proofing your MCP server. The default is now (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) when both peers support `2026-07-28`. We recommend every server set `SessionMode` explicitly rather than relying on the default: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`HttpServerSessionMode.Stateless`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this configuration lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. +- **`HttpServerSessionMode.Stateful`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). Keep a session if you need server-to-client requests against clients that do not support `2026-07-28`. Note that in this mode a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. + +- **`HttpServerSessionMode.StatefulForInitializeClients`** — the migration mode. `initialize`-handshake clients get full sessions while `2026-07-28` and later clients are served statelessly on the same endpoint, so you can adopt the new revision progressively instead of waiting for every client to migrate. See [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). > [!TIP] -> If you're not sure which to pick, leave the default (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. +> If you're not sure which to pick, leave the default (`HttpServerSessionMode.Stateless`). You can switch to `HttpServerSessionMode.Stateful` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. + + +> [!NOTE] +> The `bool` property remains a convenient shorthand for the two most common modes: assigning `true` selects `Stateless` and assigning `false` selects `Stateful`, while reading it returns `true` only for `Stateless`. Use `SessionMode` when you need `StatefulForInitializeClients`. Both properties update the same underlying value, so the last assignment wins. ### The 2026-07-28 protocol revision -The `2026-07-28` protocol revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). +The `2026-07-28` protocol revision goes further than stateless mode: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request. +**Server side.** With `SessionMode = HttpServerSessionMode.Stateless` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP `POST` that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints aren't mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `SessionMode = HttpServerSessionMode.Stateful`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. `SessionMode = HttpServerSessionMode.StatefulForInitializeClients` opts out of that downgrade and serves the `2026-07-28` request statelessly instead — see [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only). If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request, in every mode. **Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients. @@ -80,7 +87,7 @@ With the default **Server-side migration.** If you previously relied on `/sse` being mapped automatically, you now need `EnableLegacySse = true` (suppressing the `MCP9004` warning) to keep serving those endpoints. The recommended path is to migrate all clients to Streamable HTTP and then remove `EnableLegacySse`. -**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `Stateless = false`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `Stateless = true`. +**Transition period.** If some clients still need SSE while others have already migrated to Streamable HTTP, set `EnableLegacySse = true` with `SessionMode = HttpServerSessionMode.Stateful`. Both transports are served simultaneously by `MapMcp()` — Streamable HTTP on the root endpoint and SSE on `/sse` and `/message`. Once all clients have migrated, remove `EnableLegacySse` and optionally switch to `SessionMode = HttpServerSessionMode.Stateless`. ## Stateless mode (recommended) @@ -89,12 +96,14 @@ Stateless mode is the recommended default for HTTP-based MCP servers. When enabl ### Enabling stateless mode ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -105,7 +114,7 @@ app.Run(); ### What stateless mode changes -When is `true`: +When is : - is `null`, and the `Mcp-Session-Id` header is not sent or expected - Each HTTP request creates a fresh server context — no state carries over between requests @@ -145,7 +154,7 @@ This means servers that need user confirmation ([elicitation](xref:elicitation)) ## Stateful mode (sessions) -When is `false`, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: +When is , the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: - Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream - [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the `GET` stream @@ -166,6 +175,42 @@ Use stateful mode when your server needs one or more of: The [deployment considerations](#deployment-considerations) section lists real concerns for production, internet-facing services — but many MCP servers don't run in that context. For single-instance servers, internal tools, and dev/test clusters, session affinity and memory overhead are less of a concern, and sessions provide the richest feature set. +## Hybrid mode (sessions for initialize clients only) + + serves both eras on a single endpoint: clients that send the `initialize` handshake (`2025-11-25` and earlier) get a full stateful session, while clients using `2026-07-28` and later are served statelessly, per request. It exists so an existing stateful server can adopt the new protocol revision progressively instead of waiting for every client to migrate first. + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + }) + .WithTools(); +``` + +### What each client sees + +| Client | Behavior | +|---|---| +| `2025-11-25` and earlier (sends `initialize`) | Full stateful session, `Mcp-Session-Id` issued and echoed, `GET` and `DELETE` available, server-to-client requests supported | +| `2026-07-28` and later (sends `server/discover`) | Served per request with no session ID minted or echoed; `GET` and `DELETE` return `405 Method Not Allowed`; no downgrade to `initialize` | + +Without hybrid mode, `HttpServerSessionMode.Stateful` refuses a `2026-07-28` request with `-32022 UnsupportedProtocolVersion` so that dual-path clients fall back to `initialize`. Hybrid mode removes that refusal for clients that don't want (or can't perform) the downgrade. + +### What stays unavailable to `2026-07-28` clients + +A `2026-07-28` request has no session even on a hybrid endpoint, so all of the [stateless mode restrictions](#what-stateless-mode-changes) still apply to it: no [unsolicited notifications](#how-streamable-http-delivers-messages), no resource subscriptions, no server-initiated ping, and no per-client isolation. Use [MRTR](xref:mrtr) for elicitation (and the deprecated sampling and roots) on that half of the endpoint. Legacy sessions on the same endpoint keep all of those features. + +### Lifetimes + +Because the effective mode is decided per request, DI and callback lifetimes follow the request rather than the endpoint: + +- `2026-07-28` requests resolve services from `HttpContext.RequestServices` with request scoping disabled, exactly like [stateless HTTP](#stateless-http). +- `initialize`-handshake sessions resolve services from the application provider and scope each request, exactly like [stateful HTTP](#stateful-http). +- runs once per session for `initialize`-handshake clients and once per HTTP request for `2026-07-28` clients. + +Stateful-only options (`IdleTimeout`, `EventStreamStore`, `SessionMigrationHandler`, and so on) continue to govern the session half of the endpoint and are ignored for `2026-07-28` requests. + ## Comparison | Consideration | Stateless | Stateful | @@ -183,6 +228,9 @@ The [deployment considerations](#deployment-considerations) section lists real c | **State reset on reconnect** | No concept of reconnection — every request stands alone | Client reconnection starts a new session with a clean slate | | **[Tasks](xref:tasks)** | Supported — shared task store, no per-session isolation | Supported — task store scoped per session | +> [!NOTE] +> [Hybrid mode](#hybrid-mode-sessions-for-initialize-clients-only) doesn't add a third column: each request follows the **Stateless** column when the client negotiated `2026-07-28` and the **Stateful** column when the client used the `initialize` handshake. + ## Transports and sessions ### Streamable HTTP @@ -390,7 +438,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Recommended for servers that don't need sessions. - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; // --- Options below only apply to stateful (non-stateless) mode --- @@ -417,7 +465,8 @@ builder.Services.AddMcpServer() | Property | Type | Default | Description | |----------|------|---------|-------------| -| | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` protocol revision. | +| | | `Stateless` | Selects how the server tracks state between requests: `Stateless` (no sessions), `Stateful` (sessions for every client, `2026-07-28` refused), or `StatefulForInitializeClients` ([hybrid](#hybrid-mode-sessions-for-initialize-clients-only)). | +| | `bool` | `true` | Convenience proxy over `SessionMode`: `true` maps to `Stateless`, `false` maps to `Stateful`, and hybrid mode reads as `false`. Use `SessionMode` to select hybrid mode. | | | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | | | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. | | | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all `2026-07-28` requests), this runs on every HTTP request. | @@ -426,7 +475,7 @@ builder.Services.AddMcpServer() | | `ISseEventStreamStore?` | `null` | _Stateful only (`MCP9006`)._ Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | | | `bool` | `false` | _Stateful only (`MCP9006`)._ Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | -The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. +The properties marked _Stateful only_ above carry diagnostic [`MCP9006`](xref:list-of-diagnostics#obsolete-apis) because they have no effect when the request is served without a session (every `2026-07-28` request, plus every request on a server with `SessionMode = HttpServerSessionMode.Stateless`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions @@ -457,7 +506,7 @@ In **stateless mode**, `ConfigureSessionOptions` is called on **every HTTP reque builder.Services.AddMcpServer() .WithHttpTransport(options => { - options.Stateless = true; + options.SessionMode = HttpServerSessionMode.Stateless; options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => { // This runs on every request in stateless mode, so you can use the @@ -746,7 +795,7 @@ In stateless mode, each HTTP request is its own "session", so `mcp.server.sessio The legacy [SSE (Server-Sent Events)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) transport is also supported by `MapMcp()` and always uses stateful mode. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** due to [backpressure concerns](#request-backpressure). To enable them, set to `true` — this property is marked `[Obsolete]` with a diagnostic warning (`MCP9004`) to signal that it should only be used when you need to support legacy SSE-only clients and understand the backpressure implications. Alternatively, set the `ModelContextProtocol.AspNetCore.EnableLegacySse` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) to `true`. > [!NOTE] -> Setting `EnableLegacySse = true` while `Stateless = true` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. +> Setting `EnableLegacySse = true` while `SessionMode = HttpServerSessionMode.Stateless` throws an `InvalidOperationException` at startup, because SSE requires in-memory session state shared between the `GET` and `POST` requests. ### How SSE sessions work @@ -778,7 +827,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session migration is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.SessionMigrationHandler = new MySessionMigrationHandler(); }); ``` @@ -806,7 +855,7 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // Session resumability is a stateful-mode feature. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = new MyEventStreamStore(); }); ``` diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index df8887c..3a68d61 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -39,7 +39,7 @@ Register the tool type when building the server: ```csharp builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = true) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless) .WithTools(); ``` diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 6833193..bb4e155 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -168,13 +168,16 @@ await using var client = await McpClient.ResumeSessionAsync(transport, new Resum Use the `ModelContextProtocol.AspNetCore` package to host an MCP server over HTTP. The method maps the Streamable HTTP endpoint at the specified route (root by default). ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Recommended for servers that don't need server-to-client requests. - options.Stateless = true; + // Stateless mode is the default and recommended for + // servers that don't need server-to-client requests. + options.SessionMode = HttpServerSessionMode.Stateless; }) .WithTools(); @@ -183,7 +186,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `SessionMode = HttpServerSessionMode.Stateful` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. For a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown, see [Stateless and Stateful](xref:stateless). #### Host name validation @@ -297,20 +300,22 @@ SSE-specific configuration options: #### SSE server (ASP.NET Core) -The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** and is marked `[Obsolete]` (diagnostic `MCP9004`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `Stateless = true`. +The ASP.NET Core integration supports SSE transport alongside Streamable HTTP. Legacy SSE endpoints (`/sse` and `/message`) are **disabled by default** and is marked `[Obsolete]` (diagnostic `MCP9004`). SSE always requires stateful mode; legacy SSE endpoints are never mapped when `SessionMode = HttpServerSessionMode.Stateless`. **Why SSE is disabled by default.** The SSE transport separates request and response channels: clients `POST` JSON-RPC messages to `/message` and receive all responses through a long-lived `GET` SSE stream on `/sse`. Because the `POST` endpoint returns `202 Accepted` immediately — before the handler even runs — there is **no HTTP-level backpressure** on handler concurrency. A client (or attacker) can flood the server with tool calls without waiting for prior requests to complete. In contrast, Streamable HTTP holds each `POST` response open until the handler finishes, providing natural backpressure. For a detailed comparison and mitigations if you must use SSE, see [Request backpressure](xref:stateless#request-backpressure). To enable legacy SSE, set `EnableLegacySse` to `true`: ```csharp +using ModelContextProtocol.AspNetCore; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(options => { // SSE requires stateful mode; opt in explicitly because stateless mode is the default. - options.Stateless = false; + options.SessionMode = HttpServerSessionMode.Stateful; #pragma warning disable MCP9004 // EnableLegacySse is obsolete // Enable legacy SSE endpoints for clients that don't support Streamable HTTP. diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 1ad8206..5773342 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -44,5 +44,5 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless and Stateful — Legacy SSE transport](xref:stateless#legacy-sse-transport) for details. | | `MCP9005` | In place | The Roots, Sampling, and Logging features are deprecated as of specification version 2026-07-28 and may be removed in a future version. See [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) for more information. | -| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | +| `MCP9006` | In place | The stateful Streamable HTTP configuration knobs on — `EventStreamStore`, `SessionMigrationHandler`, `PerSessionExecutionContext`, `IdleTimeout`, and `MaxIdleSessionCount` — only apply when the request is served with a session. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `SessionMode` to `HttpServerSessionMode.Stateless`. These options remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | | `MCP9007` | In place | `AuthorizationRedirectDelegate` and `ClientOAuthOptions.AuthorizationRedirectDelegate` are retained for source and binary compatibility but cannot provide the authorization-response state or RFC 9207 issuer. State and issuer validation are skipped when these APIs are used. Use `ClientOAuthOptions.AuthorizationCallbackHandler` for response-bound, issuer-aware authorization flows. | diff --git a/samples/AspNetCoreMcpPerSessionTools/Program.cs b/samples/AspNetCoreMcpPerSessionTools/Program.cs index 983d296..68c37e1 100644 --- a/samples/AspNetCoreMcpPerSessionTools/Program.cs +++ b/samples/AspNetCoreMcpPerSessionTools/Program.cs @@ -1,4 +1,5 @@ using AspNetCoreMcpPerSessionTools.Tools; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; using System.Collections.Concurrent; using System.Reflection; @@ -14,8 +15,8 @@ builder.Services.AddMcpServer() .WithHttpTransport(options => { // This sample demonstrates per-session tool filtering, which requires stateful mode. - // Set Stateless = false explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since sessions are required. + options.SessionMode = HttpServerSessionMode.Stateful; // Configure per-session options to filter tools based on route category options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => diff --git a/samples/AspNetCoreMcpPerSessionTools/README.md b/samples/AspNetCoreMcpPerSessionTools/README.md index e0d9680..3d37568 100644 --- a/samples/AspNetCoreMcpPerSessionTools/README.md +++ b/samples/AspNetCoreMcpPerSessionTools/README.md @@ -65,9 +65,9 @@ The key technique is using `ConfigureSessionOptions` to modify the tool collecti ```csharp .WithHttpTransport(options => { - // Per-session tool filtering requires stateful mode. Set Stateless = false - // explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since per-session + // tool filtering requires sessions. + options.SessionMode = HttpServerSessionMode.Stateful; options.ConfigureSessionOptions = async (httpContext, mcpOptions, cancellationToken) => { var toolCategory = GetToolCategoryFromRoute(httpContext); diff --git a/samples/AspNetCoreMcpServer/Program.cs b/samples/AspNetCoreMcpServer/Program.cs index 3441083..a69f499 100644 --- a/samples/AspNetCoreMcpServer/Program.cs +++ b/samples/AspNetCoreMcpServer/Program.cs @@ -1,4 +1,5 @@ using Azure.Monitor.OpenTelemetry.AspNetCore; +using ModelContextProtocol.AspNetCore; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -26,10 +27,10 @@ builder.Services.AddCors(options => // Note: This sample uses SampleLlmTool which calls server.AsSamplingChatClient() to send // a server-to-client sampling request. This requires stateful (session-based) mode. Set -// Stateless = false explicitly for forward compatibility in case the default changes. +// SessionMode = HttpServerSessionMode.Stateful since sessions are required for sampling. // See https://csharp.sdk.modelcontextprotocol.io/concepts/sessions/sessions.html for details. builder.Services.AddMcpServer() - .WithHttpTransport(o => o.Stateless = false) + .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateful) .WithTools() .WithTools() .WithTools() diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index f8c9752..abccbac 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -3,6 +3,7 @@ using EverythingServer.Prompts; using EverythingServer.Resources; using EverythingServer.Tools; using Microsoft.Extensions.AI; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -58,8 +59,8 @@ builder.Services .WithHttpTransport(options => { // This sample uses subscriptions, SampleLlmTool (sampling), and RunSessionHandler. - // Set Stateless = false explicitly for forward compatibility in case the default changes. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful since sessions are required for sampling. + options.SessionMode = HttpServerSessionMode.Stateful; // Add a RunSessionHandler to remove all subscriptions for the session when it ends #pragma warning disable MCPEXP002 // RunSessionHandler is experimental diff --git a/samples/ProtectedMcpServer/Program.cs b/samples/ProtectedMcpServer/Program.cs index f539e73..59ad352 100644 --- a/samples/ProtectedMcpServer/Program.cs +++ b/samples/ProtectedMcpServer/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.AspNetCore; using ProtectedMcpServer.Tools; using System.Net.Http.Headers; using System.Security.Claims; @@ -90,10 +91,10 @@ builder.Services.AddMcpServer() .WithTools() .WithHttpTransport(options => { - // Stateless mode is recommended for servers that don't need server-to-client - // requests like sampling or elicitation. It enables horizontal scaling without - // session affinity and works with clients that don't send Mcp-Session-Id. - options.Stateless = true; + // Stateless mode is the default and recommended for servers that don't need 2025-11-25 + // protocol revision server-to-client requests like sampling or elicitation. Stateless model enables + // horizontal scaling without session affinity and works with clients that don't send Mcp-Session-Id. + options.SessionMode = HttpServerSessionMode.Stateless; }); // Configure HttpClientFactory for weather.gov API diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 6ecab2b..217ff97 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -44,7 +44,7 @@ internal static class Obsoletions public const string DeprecatedLogging_Message = "The Logging feature is deprecated as of specification version 2026-07-28 and may be removed in a future version. See SEP-2577 for more information."; public const string LegacyStatefulHttp_DiagnosticId = "MCP9006"; - public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for legacy clients. Set HttpServerTransportOptions.Stateless = true (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; + public const string LegacyStatefulHttp_Message = "Stateful Streamable HTTP mode is a back-compat-only escape hatch for 2025-11-25 protocol revision clients and earlier. Set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless (the default as of the 2026-07-28 protocol revision) for new code. See SEP-2567."; public const string LegacyStatefulHttp_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; public const string AuthorizationRedirectDelegate_DiagnosticId = "MCP9007"; diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs new file mode 100644 index 0000000..829b7e8 --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/HttpServerSessionMode.cs @@ -0,0 +1,64 @@ +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Specifies how the Streamable HTTP transport tracks state between requests. +/// +/// +/// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions +/// (SEP-2567 removed Mcp-Session-Id, and SEP-2575 removed the initialize handshake), so requests +/// using that revision or later can only ever be served statelessly. This enumeration allows specification for +/// how the server reconciles that requirement with clients that still rely on the initialize handshake. +/// +public enum HttpServerSessionMode +{ + /// + /// The server never tracks state between requests, allowing for load balancing without session affinity. + /// + /// + /// is , the + /// Mcp-Session-Id header is unused, and + /// are invoked once per request, and the + /// GET, DELETE, and /sse endpoints are unavailable. Unsolicited server-to-client messages and all + /// server-to-client requests are unsupported because any response might arrive at another ASP.NET Core + /// application process. Client sampling, elicitation, and roots capabilities are disabled because the + /// server cannot make requests; use Multi Round-Trip Requests (MRTR) + /// instead. + /// + Stateless, + + /// + /// The server tracks a long-lived session for every client, which requires session affinity. + /// + /// + /// Requests using the 2026-07-28 or later protocol revision are refused with a + /// -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to the + /// initialize handshake and obtains the session the server was configured to provide. Use + /// to serve those clients natively instead of forcing a downgrade. + /// + Stateful, + + /// + /// The server tracks a long-lived session for clients that use the initialize handshake and serves + /// clients using the 2026-07-28 or later protocol revision statelessly on the same endpoint. + /// + /// + /// + /// This hybrid mode allows an application to adopt the latest protocol revision progressively rather than + /// waiting for every client to migrate. Clients using the 2025-11-25 or earlier revisions get a full + /// stateful session with an Mcp-Session-Id and continue to use the GET and DELETE endpoints, while + /// clients using the 2026-07-28 or later revisions are served per request with no session ID minted + /// or echoed, and receive 405 Method Not Allowed for GET and DELETE, exactly as in + /// mode. + /// + /// + /// Because a 2026-07-28 request has no session, the session-only features listed on + /// remain unavailable to those clients even though other clients on the same + /// endpoint have sessions. is invoked once + /// per session for initialize-handshake clients and once per request for 2026-07-28 and later + /// clients. + /// + /// + StatefulForInitializeClients, +} diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 0247722..7ecfc07 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -18,9 +18,11 @@ public class HttpServerTransportOptions /// with access to the of the request that initiated the session. /// /// - /// In stateful mode (the default), this callback is invoked once per session when the client sends the - /// initialize request. In mode, it is invoked on every HTTP request - /// because each request creates a fresh server context. + /// In stateful mode, this callback is invoked once per session when the client sends the + /// initialize request. In mode, it is invoked on + /// every HTTP request because each request creates a fresh server context. In + /// mode, both apply: once per session for + /// initialize-handshake clients and once per request for 2026-07-28 and later clients. /// public Func? ConfigureSessionOptions { get; set; } @@ -39,12 +41,56 @@ public class HttpServerTransportOptions /// of the initializing request with fewer known issues. /// /// + /// In mode, this callback is invoked once per session. In + /// mode, it is invoked once per HTTP request. In + /// mode, both apply: once per session for + /// initialize-handshake clients and once per request for 2026-07-28 and later clients. + /// + /// /// This API is experimental and may be removed or change signatures in a future release. /// /// [System.Diagnostics.CodeAnalysis.Experimental(Experimentals.RunSessionHandler_DiagnosticId, UrlFormat = Experimentals.RunSessionHandler_Url)] public Func? RunSessionHandler { get; set; } + /// + /// Gets or sets a value that indicates how the server tracks state between requests. + /// + /// + /// One of the values. The default is + /// as of the 2026-07-28 protocol revision (SEP-2567). + /// + /// + /// + /// doesn't track state between requests, allowing for load + /// balancing without session affinity. will be null, the + /// "MCP-Session-Id" header will not be used, the will be called once for + /// each request, and the GET, DELETE, and "/sse" endpoints will be disabled. Unsolicited server-to-client + /// messages and all server-to-client requests are also unsupported, because any responses might arrive at + /// another ASP.NET Core application process. Client sampling, elicitation, and roots capabilities are also + /// disabled, because the server cannot make requests. + /// + /// + /// tracks a session for every client, which requires session + /// affinity. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports + /// sessions: the revision removed Mcp-Session-Id (SEP-2567), so such a request is refused with a + /// -32022 UnsupportedProtocolVersion error, and a dual-path client downgrades to the + /// initialize handshake and obtains the session the server was configured to provide. + /// + /// + /// avoids that downgrade by serving + /// 2026-07-28 and later requests statelessly on the same endpoint while initialize-handshake + /// clients still get full sessions. Session-only features remain unavailable to the stateless half of the + /// endpoint; use MRTR for + /// elicitation there. + /// + /// + /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored + /// in every mode; the server must not mint or echo session IDs for those revisions. + /// + /// + public HttpServerSessionMode SessionMode { get; set; } = HttpServerSessionMode.Stateless; + /// /// Gets or sets a value that indicates whether the server runs in a stateless mode that doesn't track state between requests, /// allowing for load balancing without session affinity. @@ -55,22 +101,18 @@ public class HttpServerTransportOptions /// set to only when you need to support legacy clients that rely on session affinity. /// /// - /// If , will be null, and the "MCP-Session-Id" header will not be used, - /// the will be called once for each request, and the "/sse" endpoint will be disabled. - /// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses - /// might arrive at another ASP.NET Core application process. - /// Client sampling, elicitation, and roots capabilities are also disabled in stateless mode, because the server cannot make requests. - /// - /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: - /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served - /// when this property is . When it is , such a request is - /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to - /// the initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; - /// the server must not mint or echo session IDs for those revisions. - /// + /// This property is a convenience proxy over . Reading it returns + /// only when is , + /// so reads as . + /// Assigning selects and assigning + /// selects . Because both properties + /// update the same underlying value, the last assignment wins when both are configured. /// - public bool Stateless { get; set; } = true; + public bool Stateless + { + get => SessionMode is HttpServerSessionMode.Stateless; + set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; + } /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -94,8 +136,9 @@ public class HttpServerTransportOptions /// built-in backpressure. /// /// - /// Setting this to while is also - /// throws an at startup, because SSE requires in-memory session state. + /// Setting this to while is + /// throws an at + /// startup, because SSE requires in-memory session state. /// /// /// This property can also be enabled via the ModelContextProtocol.AspNetCore.EnableLegacySse diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index b11fe81..de11d8a 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -36,7 +36,7 @@ internal sealed partial class IdleTrackingBackgroundService : BackgroundService public override Task StartAsync(CancellationToken cancellationToken) { // In stateless mode there are no sessions to track, so skip starting the periodic timer entirely. - if (_options.Value.Stateless) + if (_options.Value.SessionMode is HttpServerSessionMode.Stateless) { return Task.CompletedTask; } diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index c95a5a8..e5fc3fa 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -32,7 +32,7 @@ public static class McpEndpointRouteBuilderExtensions var options = streamableHttpHandler.HttpServerTransportOptions; #pragma warning disable MCP9004 // EnableLegacySse - reading the obsolete property to check if SSE is enabled - if (options.Stateless && options.EnableLegacySse) + if (options.SessionMode is HttpServerSessionMode.Stateless && options.EnableLegacySse) { throw new InvalidOperationException( "Legacy SSE endpoints cannot be enabled in stateless mode because SSE requires in-memory session state " + @@ -50,10 +50,12 @@ public static class McpEndpointRouteBuilderExtensions .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status202Accepted)); - if (!options.Stateless) + if (options.SessionMode is not HttpServerSessionMode.Stateless) { // The GET endpoint is not mapped in Stateless mode since there's no way to send unsolicited messages. // Resuming streams via GET is currently not supported in Stateless mode. + // In StatefulForInitializeClients mode both endpoints stay mapped for initialize-handshake clients; + // the handlers reject 2026-07-28 and later requests with 405 Method Not Allowed. streamableHttpGroup.MapGet("", streamableHttpHandler.HandleGetRequestAsync) .WithMetadata(new ProducesResponseTypeMetadata(StatusCodes.Status200OK, contentTypes: ["text/event-stream"])); diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index f0b0b1a..50c20a7 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -38,8 +38,8 @@ internal sealed class StreamableHttpHandler( /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path - /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a fully stateful server so a dual-path client falls + /// back to the initialize handshake instead of retrying the 2026-07-28 version. /// private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; @@ -53,6 +53,14 @@ internal sealed class StreamableHttpHandler( public HttpServerTransportOptions HttpServerTransportOptions => httpServerTransportOptions.Value; + /// + /// Returns when no request served by this endpoint can have a session. In + /// mode this is + /// even though individual 2026-07-28 and later requests are still served statelessly, because the + /// endpoint as a whole still tracks sessions for initialize-handshake clients. + /// + private bool IsStatelessOnly => HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateless; + public async Task HandlePostRequestAsync(HttpContext context) { // The Streamable HTTP spec mandates the client MUST accept both application/json and text/event-stream. @@ -100,7 +108,7 @@ internal sealed class StreamableHttpHandler( // Validated after the body parse (rather than first) so the rejection can echo the request's // JSON-RPC id: every error response for a parseable request MUST carry its id. var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest, requestId); return; @@ -191,7 +199,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleGetRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -238,7 +246,7 @@ internal sealed class StreamableHttpHandler( private async Task HandleResumedStreamAsync(HttpContext context, StreamableHttpSession session, string lastEventId) { - if (HttpServerTransportOptions.Stateless) + if (IsStatelessOnly) { await WriteJsonRpcErrorAsync(context, "Bad Request: The Last-Event-ID header is not supported in stateless mode.", @@ -310,7 +318,7 @@ internal sealed class StreamableHttpHandler( public async Task HandleDeleteRequestAsync(HttpContext context) { var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); - if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, HttpServerTransportOptions.Stateless, out var protocolVersionError)) + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, IsStatelessOnly, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -354,7 +362,7 @@ internal sealed class StreamableHttpHandler( { await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id header is required for GET and DELETE requests when the server is using sessions. " + - "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.Stateless = true. " + + "If your server doesn't need sessions, enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; @@ -435,17 +443,18 @@ internal sealed class StreamableHttpHandler( // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: if (RequiresPerRequestMetadataProtocol(context)) { - if (!HttpServerTransportOptions.Stateless) + if (HttpServerTransportOptions.SessionMode is HttpServerSessionMode.Stateful) { - // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-path client falls back to the - // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). + // The author explicitly opted into sessions for every client, which the 2026-07-28 revision + // cannot provide. Refuse it so a dual-path client falls back to the initialize handshake and + // gets the session it asked for (SEP-2575 fallback semantics). StatefulForInitializeClients + // opts out of that downgrade and serves these requests statelessly instead. await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); return null; } - // The default (stateless) HTTP transport serves these requests natively. - return await StartNewSessionAsync(context); + // Stateless and StatefulForInitializeClients both serve these requests natively, without a session. + return await StartNewSessionAsync(context, serveStatelessly: true); } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); @@ -453,20 +462,20 @@ internal sealed class StreamableHttpHandler( { // In stateful mode, only allow creating new sessions for initialize requests. // In stateless mode, every request is independent, so we always create a new session. - if (!HttpServerTransportOptions.Stateless && !AllowNewSessionForNonInitializeRequests + if (!IsStatelessOnly && !AllowNewSessionForNonInitializeRequests && message is not JsonRpcRequest { Method: RequestMethods.Initialize }) { await WriteJsonRpcErrorAsync(context, "Bad Request: A new session can only be created by an initialize request. Include a valid Mcp-Session-Id header for non-initialize requests, " + - "or enable stateless mode by setting HttpServerTransportOptions.Stateless = true if your server doesn't need sessions. " + + "or enable stateless mode by setting HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateless if your server doesn't need sessions. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", StatusCodes.Status400BadRequest, requestId: requestId); return null; } - return await StartNewSessionAsync(context); + return await StartNewSessionAsync(context, serveStatelessly: IsStatelessOnly); } - else if (HttpServerTransportOptions.Stateless) + else if (IsStatelessOnly) { // In stateless mode, we should not be getting existing sessions via sessionId // This path should not be reached in stateless mode @@ -491,12 +500,12 @@ internal sealed class StreamableHttpHandler( return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } - private async ValueTask StartNewSessionAsync(HttpContext context) + private async ValueTask StartNewSessionAsync(HttpContext context, bool serveStatelessly) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + if (!serveStatelessly) { sessionId = MakeNewSessionId(); #pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. @@ -525,23 +534,24 @@ internal sealed class StreamableHttpHandler( }; } - return await CreateSessionAsync(context, transport, sessionId); + return await CreateSessionAsync(context, transport, sessionId, serveStatelessly); } private async ValueTask CreateSessionAsync( HttpContext context, StreamableHttpServerTransport transport, string sessionId, + bool serveStatelessly, Action? configureOptions = null) { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + if (serveStatelessly || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (HttpServerTransportOptions.Stateless) + if (serveStatelessly) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -589,7 +599,7 @@ internal sealed class StreamableHttpHandler( context.Response.Headers[McpSessionIdHeaderName] = sessionId; - return await CreateSessionAsync(context, transport, sessionId, options => + return await CreateSessionAsync(context, transport, sessionId, serveStatelessly: false, options => { options.KnownClientInfo = initializeParams.ClientInfo; options.KnownClientCapabilities = initializeParams.Capabilities; @@ -916,11 +926,13 @@ internal sealed class StreamableHttpHandler( } /// - /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that - /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to - /// sessions; we return with a supported-versions list - /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake - /// (SEP-2575). + /// Refuses a 2026-07-28 (or later) request on a fully stateful server + /// (). Starting with that revision, Streamable HTTP no longer + /// has sessions (SEP-2567), so it cannot honor the author's opt-in to sessions; we return + /// with a supported-versions list that excludes + /// 2026-07-28 and later. A dual-path client then falls back to the initialize handshake (SEP-2575). + /// serves the request statelessly instead + /// of refusing it. /// private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) { @@ -928,8 +940,8 @@ internal sealed class StreamableHttpHandler( var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + - "Use the initialize handshake with a protocol version that still supports sessions instead.", + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.SessionMode = HttpServerSessionMode.Stateful). " + + "Use the initialize handshake with a protocol version that still supports sessions instead, or set HttpServerTransportOptions.SessionMode = HttpServerSessionMode.StatefulForInitializeClients to serve this version statelessly.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs new file mode 100644 index 0000000..ae5e19f --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerTransportOptionsTests.cs @@ -0,0 +1,68 @@ +namespace ModelContextProtocol.AspNetCore.Tests; + +public class HttpServerTransportOptionsTests +{ + [Fact] + public void SessionMode_DefaultsToStateless() + { + var options = new HttpServerTransportOptions(); + + Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode); + Assert.True(options.Stateless); + } + + [Theory] + [InlineData(true, HttpServerSessionMode.Stateless)] + [InlineData(false, HttpServerSessionMode.Stateful)] + public void SettingStateless_SelectsEquivalentSessionMode(bool stateless, HttpServerSessionMode expected) + { + var options = new HttpServerTransportOptions { Stateless = stateless }; + + Assert.Equal(stateless, options.Stateless); + Assert.Equal(expected, options.SessionMode); + } + + [Theory] + [InlineData(HttpServerSessionMode.Stateless, true)] + [InlineData(HttpServerSessionMode.Stateful, false)] + public void ReadingStateless_ReflectsSessionMode(HttpServerSessionMode sessionMode, bool expected) + { + var options = new HttpServerTransportOptions { SessionMode = sessionMode }; + + Assert.Equal(expected, options.Stateless); + } + + [Fact] + public void ReadingStateless_ReturnsFalseForHybridMode() + { + var options = new HttpServerTransportOptions + { + SessionMode = HttpServerSessionMode.StatefulForInitializeClients, + }; + + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode); + } + + [Fact] + public void AssigningBothProperties_DoesNotThrow_AndLastAssignmentWins() + { + var options = new HttpServerTransportOptions(); + + options.Stateless = false; + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.Stateful, options.SessionMode); + + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.StatefulForInitializeClients, options.SessionMode); + + options.Stateless = true; + Assert.True(options.Stateless); + Assert.Equal(HttpServerSessionMode.Stateless, options.SessionMode); + + options.SessionMode = HttpServerSessionMode.Stateful; + Assert.False(options.Stateless); + Assert.Equal(HttpServerSessionMode.Stateful, options.SessionMode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index ab9d04b..365cf28 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -24,10 +24,10 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" }; }).WithHttpTransport(options => { - // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions. Starting with + // SessionMode = HttpServerSessionMode.Stateful maps the GET/DELETE endpoints and opts the author into sessions. Starting with // the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so such a request is - // refused on a session-enabled server. Stateless = true (the default) serves them natively. - options.Stateless = stateless; + // refused on a session-enabled server. SessionMode = HttpServerSessionMode.Stateless (the default) serves them natively. + options.SessionMode = stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; }); _app = Builder.Build(); @@ -69,7 +69,7 @@ public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), - // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that + // so the server cannot honor it when configured with sessions (SessionMode = HttpServerSessionMode.Stateful). The server refuses that // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back // to the initialize handshake. await StartAsync(stateless: false); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs new file mode 100644 index 0000000..01a4b26 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHybridSessionModeTests.cs @@ -0,0 +1,344 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// End-to-end coverage for : a single endpoint +/// that serves initialize-handshake clients with full stateful sessions while serving 2026-07-28 +/// and later clients statelessly, without forcing them to downgrade +/// (). +/// +[McpServerToolType] +public class July2026ProtocolHybridSessionModeTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + private int _configureSessionOptionsCount; + private int _runSessionHandlerCount; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [McpServerTool(Name = "greet")] + public static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "greet_via_elicit")] + public static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) + { + // Server to client requests only work over a stateful session, so this proves the initialize-handshake + // half of a hybrid endpoint keeps its session even though the endpoint also serves stateless requests. + var elicitResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new(), + }, cancellationToken); + + var name = elicitResult.Content?.TryGetValue("answer", out var answer) == true + ? answer.GetString() + : "stranger"; + + return $"Hello, {name}!"; + } + + [McpServerTool(Name = "scope_state")] + public static string ScopeState(ScopedService scopedService) => scopedService.State ?? ""; + + private async Task StartHybridServerAsync(bool trackRunSessionHandler = false) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHybridSessionModeTests), Version = "1" }; + }) + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; + options.ConfigureSessionOptions = (httpContext, mcpServerOptions, cancellationToken) => + { + Interlocked.Increment(ref _configureSessionOptionsCount); + return Task.CompletedTask; + }; + + if (trackRunSessionHandler) + { +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental. + options.RunSessionHandler = async (httpContext, server, cancellationToken) => + { + Interlocked.Increment(ref _runSessionHandlerCount); + await server.RunAsync(cancellationToken); + }; +#pragma warning restore MCPEXP002 + } + }) + .WithTools(); + + Builder.Services.AddScoped(); + + _app = Builder.Build(); + + _app.Use(next => context => + { + context.RequestServices.GetRequiredService().State = "From request middleware!"; + return next(context); + }); + + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private Task ConnectClientAsync(string? protocolVersion = null, Action? configureClient = null) + { + var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:5000/"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + // A null ProtocolVersion prefers 2026-07-28 and probes with server/discover before considering a + // fallback to the initialize handshake. Pinning an older version forces the initialize handshake. + var clientOptions = new McpClientOptions { ProtocolVersion = protocolVersion }; + configureClient?.Invoke(clientOptions); + return McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ModernAndLegacyClients_ShareOneEndpoint_AndModernDoesNotDowngrade() + { + await StartHybridServerAsync(); + + await using var modernClient = await ConnectClientAsync(); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // The whole point of the hybrid mode: the default client keeps 2026-07-28 instead of downgrading. + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, modernClient.NegotiatedProtocolVersion); + Assert.Null(modernClient.SessionId); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, legacyClient.NegotiatedProtocolVersion); + Assert.False(string.IsNullOrEmpty(legacyClient.SessionId)); + + // Both halves of the endpoint remain usable while the other is connected. + var modernResult = await modernClient.CallToolAsync("greet", + new Dictionary { ["name"] = "Modern" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Hello, Modern!", Assert.IsType(Assert.Single(modernResult.Content)).Text); + + var legacyResult = await legacyClient.CallToolAsync("greet", + new Dictionary { ["name"] = "Legacy" }, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Hello, Legacy!", Assert.IsType(Assert.Single(legacyResult.Content)).Text); + } + + [Fact] + public async Task LegacyClient_OnHybridServer_StillSupportsServerToClientElicitation() + { + await StartHybridServerAsync(); + + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion, options => + { + options.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(), + }, + }); + }); + + var result = await legacyClient.CallToolAsync("greet_via_elicit", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError is not true); + Assert.Equal("Hello, Bob!", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task ModernRequests_UseRequestScopedServices_WhileLegacySessionsUseApplicationServices() + { + await StartHybridServerAsync(); + + await using var modernClient = await ConnectClientAsync(); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // Stateless requests resolve services from HttpContext.RequestServices, so the tool observes the state + // that the ASP.NET Core middleware set on the request-scoped service. + var modernResult = await modernClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("From request middleware!", Assert.IsType(Assert.Single(modernResult.Content)).Text); + + // Stateful sessions outlive the HTTP request, so they scope requests off the application services + // instead and never see the middleware's request-scoped state. + var legacyResult = await legacyClient.CallToolAsync("scope_state", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("", Assert.IsType(Assert.Single(legacyResult.Content)).Text); + } + + [Fact] + public async Task ConfigureSessionOptions_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients() + { + await StartHybridServerAsync(); + + var beforeLegacyConnect = Volatile.Read(ref _configureSessionOptionsCount); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + + // The initialize request creates the session; notifications/initialized reuses it. + Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyConnect); + + var beforeLegacyCalls = Volatile.Read(ref _configureSessionOptionsCount); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + + // Subsequent requests reuse the session, so the callback does not run again. + Assert.Equal(0, Volatile.Read(ref _configureSessionOptionsCount) - beforeLegacyCalls); + + await using var modernClient = await ConnectClientAsync(); + + var beforeModernCall = Volatile.Read(ref _configureSessionOptionsCount); + await modernClient.CallToolAsync("greet", new Dictionary { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken); + + // Each 2026-07-28 POST creates a fresh per-request server, so the callback runs again. + Assert.Equal(1, Volatile.Read(ref _configureSessionOptionsCount) - beforeModernCall); + } + + [Fact] + public async Task RunSessionHandler_RunsPerRequestForModernClients_AndOncePerSessionForLegacyClients() + { + await StartHybridServerAsync(trackRunSessionHandler: true); + + var beforeLegacyConnect = Volatile.Read(ref _runSessionHandlerCount); + await using var legacyClient = await ConnectClientAsync(McpProtocolVersions.November2025ProtocolVersion); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyConnect); + + var beforeLegacyCalls = Volatile.Read(ref _runSessionHandlerCount); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + await legacyClient.CallToolAsync("greet", new Dictionary { ["name"] = "Legacy" }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, Volatile.Read(ref _runSessionHandlerCount) - beforeLegacyCalls); + + var beforeModernConnect = Volatile.Read(ref _runSessionHandlerCount); + await using var modernClient = await ConnectClientAsync(); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernConnect); + + var beforeModernCall = Volatile.Read(ref _runSessionHandlerCount); + await modernClient.CallToolAsync("greet", new Dictionary { ["name"] = "Modern" }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(1, Volatile.Read(ref _runSessionHandlerCount) - beforeModernCall); + } + + [Fact] + public async Task ModernPost_DoesNotMintSessionId_WhileLegacyInitializeDoes() + { + await StartHybridServerAsync(); + + using var modernResponse = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, mcpMethod: "server/discover"); + Assert.Equal(HttpStatusCode.OK, modernResponse.StatusCode); + Assert.False(modernResponse.Headers.Contains("Mcp-Session-Id"), "2026-07-28 responses must not include Mcp-Session-Id."); + + using var legacyResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest); + Assert.Equal(HttpStatusCode.OK, legacyResponse.StatusCode); + Assert.False(string.IsNullOrEmpty(Assert.Single(legacyResponse.Headers.GetValues("Mcp-Session-Id")))); + } + + [Fact] + public async Task ModernPost_IgnoresMcpSessionIdHeader() + { + await StartHybridServerAsync(); + + // SEP-2567 removed sessions from the 2026-07-28 revision, so a stray session ID must neither be honored + // nor looked up against the stateful session manager the hybrid endpoint keeps for legacy clients. + using var response = await SendAsync(HttpMethod.Post, McpProtocolVersions.July2026ProtocolVersion, DiscoverRequest, + mcpMethod: "server/discover", sessionId: "non-existent-session-id"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); + } + + [Fact] + public async Task LegacyGetAndDelete_RemainAvailable_WhileModernGetAndDeleteReturn405() + { + await StartHybridServerAsync(); + + using var initializeResponse = await SendAsync(HttpMethod.Post, protocolVersion: null, InitializeRequest); + var sessionId = Assert.Single(initializeResponse.Headers.GetValues("Mcp-Session-Id")); + + // The GET and DELETE endpoints are still mapped, so legacy clients keep the unsolicited-message stream + // and explicit session termination. + using var legacyGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId); + Assert.Equal(HttpStatusCode.OK, legacyGet.StatusCode); + + using var modernGet = await SendAsync(HttpMethod.Get, McpProtocolVersions.July2026ProtocolVersion, content: null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, modernGet.StatusCode); + Assert.Equal(["POST"], modernGet.Content.Headers.Allow); + + using var modernDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.July2026ProtocolVersion, content: null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, modernDelete.StatusCode); + Assert.Equal(["POST"], modernDelete.Content.Headers.Allow); + + using var legacyDelete = await SendAsync(HttpMethod.Delete, McpProtocolVersions.November2025ProtocolVersion, content: null, sessionId: sessionId); + Assert.Equal(HttpStatusCode.OK, legacyDelete.StatusCode); + + // The session is gone, which proves the legacy DELETE was honored rather than short-circuited. + using var afterDelete = await SendAsync(HttpMethod.Post, McpProtocolVersions.November2025ProtocolVersion, ListToolsRequest, sessionId: sessionId); + Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode); + } + + private Task SendAsync( + HttpMethod method, + string? protocolVersion, + string? content = null, + string? mcpMethod = null, + string? sessionId = null) + { + var request = new HttpRequestMessage(method, ""); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (protocolVersion is not null) + { + request.Headers.Add("MCP-Protocol-Version", protocolVersion); + } + + if (mcpMethod is not null) + { + request.Headers.Add("Mcp-Method", mcpMethod); + } + + if (sessionId is not null) + { + request.Headers.Add("Mcp-Session-Id", sessionId); + } + + if (content is not null) + { + request.Content = new StringContent(content, Encoding.UTF8, "application/json"); + } + + return HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + } + + private static string DiscoverRequest => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"HybridTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; + + private static string InitializeRequest => """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"HybridTestClient","version":"1.0"}}} + """; + + private static string ListToolsRequest => """ + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + """; + + public class ScopedService + { + public string? State { get; set; } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index d593ff2..142536b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -11,12 +11,13 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// End-to-end coverage for a default (2026-07-28-first) client connecting to a real C# Streamable HTTP -/// server that deliberately opted into sessions ( -/// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports -/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. -/// The client must then auto-downgrade to the initialize handshake, obtain the stateful session -/// the server author opted into, and continue to work, including a server→client elicitation round-trip -/// resolved over the stateful session via the initialize-handshake backcompat resolver. +/// server that deliberately opted into sessions using either +/// or . Starting with the 2026-07-28 protocol revision, +/// Streamable HTTP no longer supports sessions (SEP-2567 / SEP-2575), so the server refuses the probe with +/// -32022 UnsupportedProtocolVersion. The client must then auto-downgrade to the initialize +/// handshake, obtain the stateful session the server author opted into, and continue to work, including a +/// server-to-client elicitation round-trip resolved over the stateful session via the initialize-handshake +/// backcompat resolver. /// public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -37,7 +38,7 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe [McpServerTool(Name = "greet_via_elicit")] private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { - // Server→client round-trip: only works when the session is stateful, which is exactly what + // Server-to-client round-trip: only works when the session is stateful, which is exactly what // the initialize fallback re-establishes for the 2026-07-28-first client. var elicitResult = await server.ElicitAsync(new ElicitRequestParams { @@ -52,15 +53,19 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe return $"Hello, {name}!"; } - private async Task StartStatefulServerAsync() + private Task StartStatefulServerAsync() => + StartStatefulServerAsync(options => options.Stateless = false); + + private Task StartStatefulSessionModeServerAsync() => + StartStatefulServerAsync(options => options.SessionMode = HttpServerSessionMode.Stateful); + + private async Task StartStatefulServerAsync(Action configureTransport) { Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" }; }) - // Stateless = false is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision, - // Streamable HTTP can never be served statefully, so the server refuses the probe and the client downgrades. - .WithHttpTransport(options => options.Stateless = false) + .WithHttpTransport(configureTransport) .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); _app = Builder.Build(); @@ -88,6 +93,19 @@ public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelpe { await StartStatefulServerAsync(); + await AssertDefaultClientDowngradesAndToolsWorkAsync(); + } + + [Fact] + public async Task DefaultClient_AgainstStatefulSessionMode_DowngradesToInitialize_AndToolsWork() + { + await StartStatefulSessionModeServerAsync(); + + await AssertDefaultClientDowngradesAndToolsWorkAsync(); + } + + private async Task AssertDefaultClientDowngradesAndToolsWorkAsync() + { await using var client = await ConnectDefaultClientAsync(); // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 3d8abb0..03af131 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // the handler refuses a request when the server opted into sessions (Stateless = false), so a client pinned + // the handler refuses a request when the server opted into sessions (SessionMode = HttpServerSessionMode.Stateful), so a client pinned // to that revision downgrades to legacy instead of negotiating 2026-07-28. These MRTR tests therefore can't // run on the stateful Streamable HTTP fixture; the same coverage runs on the stateless and legacy-SSE fixtures. private const string July2026StatefulStreamableHttpSkipReason = diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index aa724bb..df5d4bd 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -293,7 +293,7 @@ public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemory Name = "backcompat-roots-tool", Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", }), - ]).WithHttpTransport(options => options.Stateless = false); + ]).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); _app = Builder.Build(); _app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 8520f92..0fce6dc 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -441,7 +441,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn request.Headers.Accept.Add(new("text/event-stream")); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - // Stateless=true (the new default) doesn't map the GET endpoint - per SEP-2567 the standalone SSE + // SessionMode = HttpServerSessionMode.Stateless doesn't map the GET endpoint - per SEP-2567 the standalone SSE // stream is replaced by subscriptions/listen POST requests. Existing routing in // McpEndpointRouteBuilderExtensions only maps GET when Stateless == false. Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 73d0007..166622d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -35,10 +35,7 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes { options.ServerInfo = new Implementation { Name = nameof(RequestAbortCancellationTests), Version = "1" }; }) - .WithHttpTransport(options => - { - options.Stateless = stateless; - }) + .WithHttpTransport(options => options.Stateless = stateless) .WithTools([McpServerTool.Create( async (CancellationToken cancellationToken) => { @@ -86,8 +83,8 @@ public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : Kes public async Task July2026Request_AbortFlowsCancellationToToolHandler() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is - // served natively only on a stateless server; a Stateless=false server refuses these requests so dual-era - // clients fall back to initialize. + // served natively only on a stateless server; a stateful server refuses these requests to allow + // a client to fall back to `initialize` if it supports it. await StartAsync(stateless: true); using var request = CreateBlockingToolRequest(july2026Protocol: true); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 29e6948..8721820 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,8 +490,8 @@ public abstract class ResumabilityIntegrationTestsBase(ITestOutputHelper testOut var serverBuilder = Builder.Services.AddMcpServer() .WithHttpTransport(options => { - // Resumability is a stateful concern; pin Stateless = false now that the new default is true. - options.Stateless = false; + // Resumability is a stateful concern; pin SessionMode = HttpServerSessionMode.Stateful since the session is required. + options.SessionMode = HttpServerSessionMode.Stateful; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index 7609e82..56fe09b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ public class SessionMigrationTests(ITestOutputHelper outputHelper) : KestrelInMe Name = "SessionMigrationTestServer", Version = "1.0.0", }; - }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); + }).WithTools(Tools).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateful); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index f93b6ab..030f056 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Connections; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Serilog; @@ -378,7 +379,7 @@ public class Program serviceCollection.AddSingleton(app.ApplicationServices.GetRequiredService()); serviceCollection.AddRoutingCore(); - serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.Stateless = true); + serviceCollection.AddMcpServer(ConfigureOptions).WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless); var appBuilder = new ApplicationBuilder(serviceCollection.BuildServiceProvider()); appBuilder.UseRouting(); @@ -428,8 +429,8 @@ public class Program .WithHttpTransport(options => { // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). - // Set Stateless = false explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true. - options.Stateless = false; + // Set SessionMode = HttpServerSessionMode.Stateful explicitly since sessions are required. + options.SessionMode = HttpServerSessionMode.Stateful; options.EnableLegacySse = true; }); From 6fa3825973949a9c4f0cd8af344e15a8db09dc35 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 13 Aug 2026 01:23:07 -0700 Subject: [PATCH 50/58] Release v2.2.0 (#1813) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Directory.Build.props | 2 +- src/PACKAGE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0df4b7c..2b120c4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ https://csharp.sdk.modelcontextprotocol.io https://github.com/modelcontextprotocol/csharp-sdk git - 2.1.0 + 2.2.0 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 9f0ada8..b3c1d91 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -4,7 +4,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. -See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.1.0) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.2.0) for what's new in this version. ## Packages From 8754e19f8084669c6505068423043ab4310e33d5 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Fri, 14 Aug 2026 11:40:57 -0400 Subject: [PATCH 51/58] Fix protocol version tag for stateless server spans (#1810) Co-authored-by: Luis Rodriguez <25299418+luisangelrod@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- .../McpSessionHandler.cs | 5 +- .../MapMcpStatelessTests.cs | 53 ++++++++++++++++++ .../MapMcpStreamableHttpTests.cs | 56 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 61a1872..92b42de 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -962,9 +962,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable tags.Add("network.protocol.name", "http"); } - if (NegotiatedProtocolVersion is not null) + string? protocolVersion = (message as JsonRpcRequest)?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; + if (protocolVersion is not null) { - tags.Add("mcp.protocol.version", NegotiatedProtocolVersion); + tags.Add("mcp.protocol.version", protocolVersion); } if (activity is { IsAllDataRequested: true }) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs index ac19953..fbf1a5f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using OpenTelemetry.Trace; +using System.Diagnostics; namespace ModelContextProtocol.AspNetCore.Tests; @@ -10,6 +12,57 @@ public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStream protected override bool UseStreamableHttp => true; protected override bool Stateless => true; + // In stateless mode each HTTP request is served by a fresh, un-negotiated McpServer, so the + // session-level NegotiatedProtocolVersion is null when the server span is tagged. The tag must + // therefore come from the per-request MCP-Protocol-Version header / _meta value. Exercising more + // than one version proves the tag tracks the per-request value rather than coincidentally matching + // a single hard-coded version (and would regress to an absent tag if the per-request fallback were + // removed, since the negotiated value is null at tagging time). + [Theory] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ServerActivity_TagsPerRequestProtocolVersion_InStatelessMode(string protocolVersion) + { + var activities = new List(); + string? capturedNegotiatedProtocolVersion = null; + + var protocolVersionTool = McpServerTool.Create( + (RequestContext context) => + { + capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; + return "ok"; + }, + new() { Name = "stateless-capture-version" }); + + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.ModelContextProtocol") + .AddInMemoryExporter(activities) + .Build()) + { + Builder.Services.AddMcpServer() + .WithHttpTransport(ConfigureStateless) + .WithTools([protocolVersionTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(configureClient: options => + options.ProtocolVersion = protocolVersion); + + await client.CallToolAsync("stateless-capture-version", cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Contains(activities, activity => + activity.DisplayName == "tools/call stateless-capture-version" && + activity.Kind == ActivityKind.Server && + activity.GetTagItem("mcp.protocol.version") as string == protocolVersion); + + // Once the per-request version is applied, the request-scoped server settles on that same version, + // so the value tagged on the span and the version the session ends up negotiating agree. + Assert.Equal(protocolVersion, capturedNegotiatedProtocolVersion); + } + [Fact] public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 889a7da..0a0a6bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -6,7 +6,9 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using OpenTelemetry.Trace; using System.Collections.Concurrent; +using System.Diagnostics; using System.Net; using System.Security.Claims; using System.Threading; @@ -46,6 +48,60 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT Assert.Equal("TestCustomRouteServer", mcpClient.ServerInfo.Name); } + // 2025-11-25 is negotiated through the initialize handshake, so in the (default) stateful configuration + // the session-level NegotiatedProtocolVersion is populated and the client sends a matching + // MCP-Protocol-Version header on every follow-up request. This asserts the span carries the negotiated + // version and that the tagged value agrees with the session's NegotiatedProtocolVersion. + // + // A scenario where the per-request and negotiated versions differ is intentionally not asserted here: + // a single MCP session must not change protocol versions, so the server rejects a follow-up request + // whose header/_meta version differs from the negotiated one, and a conformant client never sends a + // differing header. Reaching that divergence would require a hand-crafted request on an invalid + // (failing) code path and would pin the current ordering of tagging relative to version-change + // validation. Instead the two inputs to the tag are covered independently: the per-request path in + // stateless mode (MapMcpStatelessTests, where NegotiatedProtocolVersion is null at tagging time) and the + // negotiated-only fallback over a header-less transport (DiagnosticTests). + [Fact] + public async Task ServerActivity_TagsNegotiatedProtocolVersion() + { + var activities = new List(); + string? capturedNegotiatedProtocolVersion = null; + + var protocolVersionTool = McpServerTool.Create( + (RequestContext context) => + { + capturedNegotiatedProtocolVersion = context.Server.NegotiatedProtocolVersion; + return "ok"; + }, + new() { Name = "negotiated-capture-version" }); + + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.ModelContextProtocol") + .AddInMemoryExporter(activities) + .Build()) + { + Builder.Services.AddMcpServer() + .WithHttpTransport(ConfigureStateless) + .WithTools([protocolVersionTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var client = await ConnectAsync(configureClient: options => + options.ProtocolVersion = "2025-11-25"); + + await client.CallToolAsync("negotiated-capture-version", cancellationToken: TestContext.Current.CancellationToken); + } + + Assert.Contains(activities, activity => + activity.DisplayName == "tools/call negotiated-capture-version" && + activity.Kind == ActivityKind.Server && + activity.GetTagItem("mcp.protocol.version") as string == "2025-11-25"); + + Assert.Equal("2025-11-25", capturedNegotiatedProtocolVersion); + } + [Fact] public async Task StreamableHttpMode_Works_WithRootEndpoint() { From 20cdd493a4554e39a052f340c9d8edd3b9aecd77 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 18 Aug 2026 17:58:17 -0700 Subject: [PATCH 52/58] Document standing support for new MCP revisions (#1826) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index af0f075..e012cc3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ The C# SDK tracks implementation of MCP spec components using the [modelcontextp ### Next spec revision -The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). +The C# SDK's standing plan is to release support for each new MCP specification revision alongside the specification, as it did for the 2026-07-28 revision. Accepted SEPs are tracked on the revision's project board throughout development so implementation is ready when the revision is published. The next revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). ### Feedback and end-to-end scenarios From 609499b2988e3f4a4c732552290bc9241fb90db9 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 19 Aug 2026 17:12:21 -0700 Subject: [PATCH 53/58] Add docs source selector for manual publishes (#1828) Copilot-Session: 155ceb73-c05f-4eb6-9968-f5bbf90ed6d0 --- .github/release-process.md | 5 +- .github/skills/verify-release/SKILL.md | 29 ++++++---- .github/workflows/docs.yml | 79 +++++++++++++++----------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/.github/release-process.md b/.github/release-process.md index 79a91d1..d7418b0 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -49,4 +49,7 @@ monitor both and confirm their published outputs: published release tags and deploys it to [csharp.sdk.modelcontextprotocol.io](https://csharp.sdk.modelcontextprotocol.io). Verify the new version appears in the version picker and that its major-version path serves the updated content. - A content-only docs refresh can be run later via manual dispatch with the `docs_ref` input. + A content-only docs refresh can be run later via manual dispatch. Its `docs_source` input defaults + to `release-tags` to mirror a release deployment; select `latest-branches` to refresh every + published major from its current `release/{MAJOR}.x` branch, using `main` for its + matching major. diff --git a/.github/skills/verify-release/SKILL.md b/.github/skills/verify-release/SKILL.md index 1f8a706..ad54776 100644 --- a/.github/skills/verify-release/SKILL.md +++ b/.github/skills/verify-release/SKILL.md @@ -103,11 +103,14 @@ Report the run's conclusion, accounting for these docs-specific behaviors: - **Version discovery reads published releases.** For each major version >= 1, the workflow takes the most recently published non-draft release tagged `v{MAJOR}.*`. A draft release contributes nothing. -- **Every major is rebuilt.** Each major's docs are built from that major's latest release tag into - its own path (`/v1/`, `/v2/`). A new MAJOR adds a new path; the site root redirects to the newest - release, prereleases included. +- **Every major is rebuilt.** A release-triggered run builds each major from its latest release tag + into its own path (`/v1/`, `/v2/`). A manual dispatch defaults to the same release-tag content, + or can build every major from `release/{MAJOR}.x`, using `main` for its matching major, + by selecting `docs_source=latest-branches`. A new MAJOR adds a new path; the site root redirects + to the newest release, prereleases included. - **Orchestration comes from `main`.** The scripts and picker assets are always checked out from - `main`, while each version's content comes from its release tag. A docs fix that lives only in a + `main`. Release-triggered and default manual runs use release-tag content; a manual + `docs_source=latest-branches` run uses source-branch content. A docs fix that lives only in a release branch will not affect orchestration. If it failed, summarize the failing step. Common causes are a docs build failure in one version's @@ -164,17 +167,21 @@ Both remediations require explicit user confirmation. gh run rerun {run-id} --failed ``` -**Refresh the docs without a new release** — when documentation content needs correcting after the -release, the docs workflow accepts a manual dispatch that rebuilds one major version's content from -an arbitrary ref, without minting a product release: +**Rebuild the released docs** — use the default `release-tags` source to retry a failed Pages +deployment after a release, without minting a product release: ``` -gh workflow run docs.yml --field docs_ref={branch-tag-or-commit} +gh workflow run docs.yml --field docs_source=release-tags ``` -The ref's major version, read from `src/Directory.Build.props`, must have a published release; the -workflow fails fast if it does not. This replaces only the matching major's HTML — orchestration -and all other versions are unaffected. +**Refresh docs without a new release** — select `latest-branches` to rebuild every published major +from its current source branch (`release/{MAJOR}.x`, using `main` for its matching major): + +``` +gh workflow run docs.yml --field docs_source=latest-branches +``` + +The workflow fails instead of silently using a release tag when any major has no matching branch. ## Edge Cases diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1e187ef..458e3fc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,8 +6,8 @@ name: Publish Docs # latest release, by date, for each major >= 1), builds each release tag into # its own sub-path (e.g. /v1/ and /v2/) with make generate-docs, injects a # version-picker widget into each page, adds a root redirect to the default -# version, and deploys the combined site. A manual dispatch can also rebuild -# the matching major-version path from a specified branch, tag, or commit. +# version, and deploys the combined site. A manual dispatch can build every +# major-version path from release tags or current source branches. # Triggers on release publish and manual dispatch. on: @@ -15,10 +15,14 @@ on: types: [published] workflow_dispatch: inputs: - docs_ref: - description: Branch, tag, or commit whose docs should refresh its matching major version - required: false - type: string + docs_source: + description: Source for every published major-version path + required: true + default: release-tags + type: choice + options: + - release-tags + - latest-branches # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -86,7 +90,7 @@ jobs: - name: Build all versions shell: bash env: - CONTENT_REF: ${{ inputs.docs_ref }} + USE_SOURCE_BRANCHES: ${{ inputs.docs_source == 'latest-branches' }} run: | set -euo pipefail ROOT="$PWD" @@ -96,28 +100,43 @@ jobs: # Orchestration (discovered docs-versions.json, scripts, picker assets) # always comes from THIS branch. Normally each version's HTML is produced - # by its release tag. A manual docs_ref replaces the matching major's - # HTML with content built from that ref, allowing content-only refreshes - # without minting a product release. - git fetch --tags --force origin - CONTENT_REF="${CONTENT_REF:-}" - CONTENT_WORKTREE="" - CONTENT_SLUG="" - if [[ -n "$CONTENT_REF" ]]; then - CONTENT_WORKTREE="$ROOT/../work-content" - git worktree add --force --detach "$CONTENT_WORKTREE" "$CONTENT_REF" - CONTENT_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$CONTENT_WORKTREE/src/Directory.Build.props")" - if ! node "$ROOT/scripts/list-versions.mjs" | cut -f1 | grep -Fxq "$CONTENT_SLUG"; then - echo "::error::The docs ref '$CONTENT_REF' has major '$CONTENT_SLUG', which has no published release." - exit 1 - fi - echo "Refreshing $CONTENT_SLUG docs from $CONTENT_REF" + # by its release tag. A manual dispatch can instead build every major from + # its current source branch. + git fetch --tags --force --prune origin "+refs/heads/*:refs/remotes/origin/*" + USE_SOURCE_BRANCHES="${USE_SOURCE_BRANCHES:-false}" + + MAIN_SLUG="" + if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then + MAIN_PROPS="$(mktemp)" + git show refs/remotes/origin/main:src/Directory.Build.props > "$MAIN_PROPS" + MAIN_SLUG="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$MAIN_PROPS")" + rm -f "$MAIN_PROPS" + echo "Refreshing all published docs from source branches (main serves $MAIN_SLUG)" + else + echo "Refreshing all published docs from release tags" fi while IFS=$'\t' read -r slug ref; do - if [[ "$slug" == "$CONTENT_SLUG" ]]; then - echo "::group::Build $slug (from ref $CONTENT_REF)" - wt="$CONTENT_WORKTREE" + if [[ "$USE_SOURCE_BRANCHES" == "true" ]]; then + source_branch="release/${slug#v}.x" + if ! git show-ref --verify --quiet "refs/remotes/origin/$source_branch"; then + if [[ "$slug" == "$MAIN_SLUG" ]]; then + source_branch="main" + else + echo "::error::No source branch found for $slug. Expected release/${slug#v}.x, or main with VersionPrefix for $slug." + exit 1 + fi + fi + + echo "::group::Build $slug (from branch $source_branch)" + wt="$ROOT/../work-$slug" + git worktree add --force --detach "$wt" "refs/remotes/origin/$source_branch" + source_slug="$(node "$ROOT/scripts/get-docs-version-slug.mjs" "$wt/src/Directory.Build.props")" + if [[ "$source_slug" != "$slug" ]]; then + git worktree remove --force "$wt" + echo "::error::Source branch '$source_branch' has major '$source_slug', not '$slug'." + exit 1 + fi else echo "::group::Build $slug (from tag $ref)" wt="$ROOT/../work-$slug" @@ -130,16 +149,10 @@ jobs: cp -a "$wt/artifacts/_site/." "$COMBINED/$slug/" node "$ROOT/scripts/inject-version-picker.mjs" "$COMBINED/$slug" "$slug" --base / - if [[ "$wt" != "$CONTENT_WORKTREE" ]]; then - git worktree remove --force "$wt" - fi + git worktree remove --force "$wt" echo "::endgroup::" done < <(node "$ROOT/scripts/list-versions.mjs") - if [[ -n "$CONTENT_WORKTREE" ]]; then - git worktree remove --force "$CONTENT_WORKTREE" - fi - node "$ROOT/scripts/finalize-docs-site.mjs" "$COMBINED" echo "Combined site contents:" From df11d7da3bffbbed6f7e95ba7894c79384a44ca3 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Thu, 3 Sep 2026 16:57:31 -0700 Subject: [PATCH 54/58] Fix HTTP fallback error handling (#1791) --- .../AutoDetectingClientSessionTransport.cs | 19 +++--- .../Client/McpClientImpl.cs | 8 --- .../StreamableHttpClientSessionTransport.cs | 12 ++-- .../July2026ProtocolHttpFallbackTests.cs | 68 ++++++++++++++++++- .../Client/July2026ProtocolFallbackTests.cs | 63 +++++++++++++++++ 5 files changed, 146 insertions(+), 24 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index c9e821a..b3041ec 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -77,16 +77,19 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) { // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server. - // It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion, - // -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other - // application-level error). Don't fall back to SSE — that would mask the real signal - // and surface a misleading "session id required" error from the SSE GET path. - // Adopt the Streamable HTTP transport and throw the structured exception so the - // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport - // first makes the catch filter below leave the now-owned transport alone. + // Adopt it before surfacing the failure so the catch filter leaves the now-owned + // transport alone, and never mask the response by attempting deprecated SSE. LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; - throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + + if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + + // TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response + // body and status without consuming the network stream a second time. + throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); } else { diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index a62ad0e..d1f2a9d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -367,14 +367,6 @@ internal sealed partial class McpClientImpl : McpClient // fallback): falling back to initialize wouldn't fix a malformed envelope. throw; } - catch (McpProtocolException ex) when ( - ex.ErrorCode == McpErrorCode.InvalidRequest && - ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) - { - // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. - // This is not evidence of an initialize-handshake server, so do not fall back. - throw; - } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index db67b2e..5b29449 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -80,8 +80,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && - (response.StatusCode == HttpStatusCode.BadRequest || - IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -89,10 +88,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => - code is McpErrorCode.UnsupportedProtocolVersion - or McpErrorCode.MissingRequiredClientCapability - or McpErrorCode.HeaderMismatch; + internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) => + statusCode == HttpStatusCode.BadRequest || + (McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; /// /// Reads a JSON-RPC error envelope from an application/json response body, returning diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 599bdd9..9126331 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -57,7 +57,7 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : base.Dispose(); } - private async Task StartServerAsync(RequestDelegate handler) + private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false) { Builder.Services.Configure(options => { @@ -65,7 +65,14 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : }); _app = Builder.Build(); - _app.MapPost("/mcp", handler); + if (acceptGet) + { + _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler); + } + else + { + _app.MapPost("/mcp", handler); + } await _app.StartAsync(TestContext.Current.CancellationToken); } @@ -302,6 +309,63 @@ public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : Assert.False(initializeReceived); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode) + { + var ct = TestContext.Current.CancellationToken; + var initializeRequests = 0; + var sseGetRequests = 0; + + await StartServerAsync(async context => + { + if (HttpMethods.IsGet(context.Request.Method)) + { + sseGetRequests++; + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeRequests++; + } + + await WriteJsonRpcErrorAsync( + context, + statusCode, + code: (int)McpErrorCode.InvalidRequest, + message: "non-modern structured error"); + }, acceptGet: true); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.AutoDetect, + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions(), + loggerFactory: LoggerFactory, + cancellationToken: ct); + }); + + Assert.Equal(statusCode, exception.StatusCode); + Assert.Contains("non-modern structured error", exception.Message); + Assert.Equal(0, initializeRequests); + Assert.Equal(0, sseGetRequests); + } + [Fact] public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() { diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index fed5df6..557dc56 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -239,6 +239,28 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } + [Theory] + [InlineData(HttpTransportMode.StreamableHttp)] + [InlineData(HttpTransportMode.AutoDetect)] + public async Task Client_OnStructuredInvalidRequestFromHttpProbe_FallsBackTo_Initialize( + HttpTransportMode transportMode) + { + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateStructuredInvalidRequestProbeServer( + () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(initializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Theory] [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] @@ -317,6 +339,47 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : } }; + private static Func> CreateStructuredInvalidRequestProbeServer( + Action onInitialize) + => async request => + { + if (request.Method == HttpMethod.Get) + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("method", out var methodElement)) + return EmptyResponse(HttpStatusCode.Accepted); + + if (methodElement.GetString() == RequestMethods.ServerDiscover) + { + var id = doc.RootElement.GetProperty("id").GetRawText(); + var error = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"error\":{\"code\":-32600,\"message\":\"Mcp-Session-Id header is required\"}}"; + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(error, Encoding.UTF8, "application/json"), + }; + } + + if (methodElement.GetString() == RequestMethods.Initialize) + { + onInitialize(); + var id = doc.RootElement.GetProperty("id").GetRawText(); + var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + + "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result, Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "test-session"); + return response; + } + + return EmptyResponse(HttpStatusCode.Accepted); + }; + private static HttpResponseMessage EmptyResponse(HttpStatusCode status) => new(status) { Content = new StringContent(string.Empty) }; From cb3e6e87fe7d79fd6c324a06f2ad4743e5becf94 Mon Sep 17 00:00:00 2001 From: King Star Date: Thu, 10 Sep 2026 03:25:54 +0800 Subject: [PATCH 55/58] fix(aspnetcore): make SEP-2575 status mapping deterministic (#1837) --- .../Server/StreamableHttpPostTransport.cs | 86 +++---------------- .../Server/StreamableHttpServerTransport.cs | 14 +-- .../MapMcpTests.cs | 13 +-- .../RawHttpConformanceTests.cs | 32 +++++++ 4 files changed, 60 insertions(+), 85 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 95411f7..5b105c3 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -76,9 +76,6 @@ internal sealed partial class StreamableHttpPostTransport( return false; } - CancellationTokenSource? deferredFlushCts = null; - Task? deferredFlushTask = null; - bool deferHeaderFlush = false; using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false)) { var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false); @@ -87,88 +84,33 @@ internal sealed partial class StreamableHttpPostTransport( await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false); } - else if (onResponseStarting is null) + else if (onResponseStarting is not null && + McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion)) { - // If there's no priming write, flush the stream to ensure HTTP response headers are - // sent to the client now that the server is ready to process the request. - // This prevents HttpClient timeout for long-running requests. - await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + // Per-request-metadata protocol revisions map the first JSON-RPC error onto the HTTP + // status line. Keep the headers uncommitted until that message arrives so the mapping + // cannot depend on how long dispatch takes. } else { - deferHeaderFlush = true; + // Earlier protocol revisions keep the eager header flush for long-running handlers. + // Mark the response as started before flushing because any later JSON-RPC error must + // not attempt to change an already committed status line. + await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); + await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } // Ensure that we've sent the priming event before processing the incoming request. await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false); } - if (deferHeaderFlush) - { - // Defer the flush (and the header commit it implies) so the callback can still choose - // the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period - // only after the request has been queued for dispatch. - deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token); - } - - try - { - // Wait for the response to be written before returning from the handler. - // This keeps the HTTP response open until the final response message is sent. - await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - if (deferredFlushCts is not null) - { - deferredFlushCts.Cancel(); - await deferredFlushTask!.ConfigureAwait(false); - deferredFlushCts.Dispose(); - } - } + // Wait for the response to be written before returning from the handler. + // This keeps the HTTP response open until the final response message is sent. + await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); return true; } - /// - /// Bounds the deferred header flush: after a short grace window, flushes the response headers - /// if no response message has been written yet. Immediate rejections land well inside the - /// window, so the response-starting callback can still map their JSON-RPC error codes onto the - /// HTTP status line; a handler that runs longer commits the headers here so clients see them - /// promptly (long-running tool calls must not trip HttpClient's response timeout). - /// - private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken) - { - try - { - await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false); - using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false); - if (!_httpResponseStarted && !_httpResponseCompleted) - { - await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); - await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // The response was written or the request ended before the grace window elapsed. - } - catch (Exception ex) - { - // Surface the failure to the awaiting HandlePostAsync when possible. If the response - // future has already been resolved (the response started or completed on another path), - // TrySetException is a no-op, so log here to keep the deferred-flush failure diagnosable. - if (!_httpResponseTcs.TrySetException(ex)) - { - LogDeferredHeaderFlushFailed(ex); - } - } - } - - /// How long the response-header flush may be deferred waiting for the first response message. - internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250); - /// /// Invokes the response-starting callback exactly once, immediately before the first write to /// the HTTP response stream, so the HTTP application can still set the response status line. @@ -343,6 +285,4 @@ internal sealed partial class StreamableHttpPostTransport( [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")] private partial void LogStoreStreamDisposalFailed(Exception exception); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")] - private partial void LogDeferredHeaderFlushFailed(Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index f143eaa..7f9a195 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -216,14 +216,14 @@ public sealed partial class StreamableHttpServerTransport : ITransport /// This overload additionally reports the first JSON-RPC message written to the response via /// , before any response bytes are written, so the HTTP /// application can still choose the response status line (SEP-2575 maps some JSON-RPC error - /// codes to HTTP statuses). When is provided, the eager - /// response-header flush that normally precedes request processing is deferred until that first - /// message; the callback receives when the first write is not a JSON-RPC - /// message (e.g. a resumability priming event). + /// codes to HTTP statuses). When is provided for a + /// per-request-metadata protocol revision, the eager response-header flush that normally precedes + /// request processing is deferred until that first message; the callback receives + /// when the first write is not a JSON-RPC message (e.g. a resumability + /// priming event). /// The status line can only be influenced by the FIRST write: when a handler streams a - /// notification (e.g. progress) before failing, or runs past the transport's bounded - /// header-flush grace window, the status is already committed and a later JSON-RPC error - /// rides the committed status. + /// notification (e.g. progress) before failing, the status is already committed and a later + /// JSON-RPC error rides the committed status. /// /// The JSON-RPC message to process. /// The response stream to write any JSON-RPC responses to. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index ef68321..43a3c12 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -239,10 +239,9 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : [Fact] public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() { - // Regression test for: Tool calls that last over HttpClient timeout without producing - // intermediate notifications will timeout because HttpClient doesn't see the 200 response - // until the first message is written. When primingItem is null (no ISseEventStreamStore), - // we should flush the response stream so HttpClient sees the 200 immediately. + // Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing + // intermediate notifications need an eager response flush. Per-request-metadata protocol + // revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status. Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -274,7 +273,11 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : TransportMode = transportMode, }, shortTimeoutClient, LoggerFactory); - await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var mcpClient = await McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = "2025-11-25" }, + LoggerFactory, + TestContext.Current.CancellationToken); // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout // because the response stream is flushed immediately after receiving the request diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 0fce6dc..b57852f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -207,6 +207,27 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); } + [Fact] + public async Task July2026Post_SlowHandler_MissingRequiredCapability_Returns400() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":20,""method"":""tools/call"",""params"":{""name"":""slow_requires_sampling"",""arguments"":{}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "slow_requires_sampling"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal(20, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); + } + [Fact] public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() { @@ -500,10 +521,21 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn [McpServerToolType] private sealed class CapabilityTools { + private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1); + [McpServerTool(Name = "requires_sampling")] public static string RequiresSampling() => throw new MissingRequiredClientCapabilityException( new ClientCapabilities { Sampling = new() }, "sampling capability required but not declared by client"); + + [McpServerTool(Name = "slow_requires_sampling")] + public static async Task SlowRequiresSampling(CancellationToken cancellationToken) + { + await Task.Delay(SlowHandlerDelay, cancellationToken); + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } } } From 6001d5ac7ab6a63fa4d0eb79c7fb6b5d5d2bc30d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sat, 12 Sep 2026 14:12:28 -0700 Subject: [PATCH 56/58] Update SourceLink to resolve vulnerable Git build dependency (#1873) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 69ed858..565d016 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -58,7 +58,7 @@ - + From 324ccd83c357acf611e1cf3a6935a945ee600442 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sun, 13 Sep 2026 16:30:42 -0700 Subject: [PATCH 57/58] Relax legacy request metadata validation (#1852) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> --- .../StreamableHttpHandler.cs | 18 +- .../McpSessionHandler.cs | 67 --- .../Protocol/JsonRpcMessageContext.cs | 20 +- .../Server/DestinationBoundMcpServer.cs | 12 +- .../Server/McpServer.Methods.cs | 26 +- .../Server/McpServer.cs | 7 +- .../Server/McpServerImpl.cs | 412 ++++++++++-------- .../OutgoingRequestInterceptingMcpServer.cs | 5 + .../RawHttpConformanceTests.cs | 132 +++++- .../StatelessServerTests.cs | 93 +++- .../Client/McpClientMetaTests.cs | 51 ++- .../Server/McpServerTests.cs | 72 ++- .../Server/NegotiatedProtocolVersionTests.cs | 216 ++++++++- .../Server/RawStreamConformanceTests.cs | 36 +- .../Server/TaskProtocolGatingTests.cs | 14 +- 15 files changed, 785 insertions(+), 396 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 50c20a7..f26e9ed 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -846,23 +846,17 @@ internal sealed class StreamableHttpHandler( return false; } - bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); - - if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && - !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) + // Legacy headers leave reserved metadata opaque. Without a header, let the core server resolve + // the version from the session or metadata, falling back to legacy handling when neither selects one. + if (string.IsNullOrEmpty(protocolVersionHeader) || + McpProtocolVersions.SupportsInitializeHandshake(protocolVersionHeader)) { errorDetail = null; return true; } - 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) + // Header validity was checked first, so only supported modern headers reach this point. + if (!TryGetProtocolVersionMeta(message, out var protocolVersionMeta)) { // Notifications are exempt from the required-_meta rejection: they are fire-and-forget // (a JSON-RPC error response has no recipient), and rejecting them silently drops diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 92b42de..d2b0745 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -401,14 +401,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { - // Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any - // filters run so they (and downstream handlers) can read client info / capabilities / - // protocol version / log level without re-parsing. - if (_isServer && message is JsonRpcRequest incomingRequest) - { - PopulateContextFromMeta(incomingRequest); - } - Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); @@ -544,65 +536,6 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable return result; } - /// - /// Reads the 2026-07-28 protocol's per-request _meta fields off the request and projects them onto - /// so they're available without re-parsing throughout the pipeline. - /// - /// - /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, - /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field - /// that's already set on the context (e.g., - /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone - /// unless explicitly overwritten by a non-null value parsed here. - /// - internal static void PopulateContextFromMeta(JsonRpcRequest request) - { - if (request.Params is not JsonObject paramsObj) - { - return; - } - - if (paramsObj["_meta"] is not JsonObject metaObj) - { - return; - } - - var context = request.Context ??= new JsonRpcMessageContext(); - - if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && - protocolVersion.TryGetValue(out string? protocolVersionValue)) - { - // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already - // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with - // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather - // than mistaking it for an initialize-handshake server and falling back to initialize. - if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) - { - throw new McpProtocolException( - $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", - McpErrorCode.HeaderMismatch); - } - - context.ProtocolVersion = protocolVersionValue; - } - - if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) - { - context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); - } - - if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) - { - context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); - } - - if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) - { - context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); - } - } - /// /// Injects the 2026-07-28 protocol's per-request _meta fields into an outgoing request. /// Protocol version and client info overwrite any existing values; client capabilities are merged diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index def9b89..0b56caa 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -88,13 +88,12 @@ public sealed class JsonRpcMessageContext public string? RoutingName { get; set; } /// - /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version) - /// that accompanied this JSON-RPC message. + /// Gets or sets the authoritative protocol version for this JSON-RPC message. /// /// - /// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the initialize - /// handshake because each request creates a new server instance. This property allows the transport layer - /// to flow the protocol version header so the server can determine client capabilities. + /// The transport may populate this from a header such as Mcp-Protocol-Version. For modern revisions, + /// the server validates and projects the matching per-request _meta value. Under an established legacy + /// revision, future reserved metadata remains opaque and does not establish or change the negotiated session version. /// public string? ProtocolVersion { get; set; } @@ -104,7 +103,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). When the request was made under the 2026-07-28 or later revision, - /// the server uses this in lieu of the value previously captured during the initialize handshake. + /// the server uses this in lieu of the value previously captured during the initialize handshake. Future reserved + /// metadata remains opaque under legacy revisions, which continue to use their initialized identity. /// public Implementation? ClientInfo { get; set; } @@ -114,7 +114,9 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client - /// capabilities from previous requests; the authoritative value is the one declared on each request. + /// capabilities from previous modern requests; the authoritative value is the one declared on each request. + /// Future reserved metadata remains opaque under legacy revisions, which continue to use the capabilities + /// negotiated during initialization. /// public ClientCapabilities? ClientCapabilities { get; set; } @@ -124,8 +126,8 @@ public sealed class JsonRpcMessageContext /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Replaces the legacy - /// RPC. When absent, the server MUST NOT emit log notifications - /// for the request. + /// RPC. When absent from a modern request, the server MUST NOT emit + /// log notifications for the request. Legacy requests continue to use their negotiated logging behavior. /// public LoggingLevel? LogLevel { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 7aab348..06a8777 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -23,14 +23,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport { 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 @@ -42,11 +34,13 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport } // 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. + // pre-populated migration data). Future reserved metadata remains opaque under legacy semantics. return server.ClientCapabilities; } } + internal override bool SupportsServerToClientRequests => server.SupportsServerToClientRequests; + public override Implementation? ClientInfo { get diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index a9a5ddd..2e51f51 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -25,6 +25,8 @@ public abstract partial class McpServer : McpSession internal virtual Func>? OutgoingRequestInterceptor => null; + internal virtual bool SupportsServerToClientRequests => true; + /// /// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor. /// @@ -555,26 +557,26 @@ public abstract partial class McpServer : McpSession private void ThrowIfSamplingUnsupported() { + if (!SupportsServerToClientRequests) + { + throw new InvalidOperationException("Sampling is not supported in stateless mode."); + } + if (ClientCapabilities?.Sampling is null) { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Sampling is not supported in stateless mode."); - } - throw new InvalidOperationException("Client does not support sampling."); } } private void ThrowIfRootsUnsupported() { + if (!SupportsServerToClientRequests) + { + throw new InvalidOperationException("Roots are not supported in stateless mode."); + } + if (ClientCapabilities?.Roots is null) { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Roots are not supported in stateless mode."); - } - throw new InvalidOperationException("Client does not support roots."); } } @@ -615,12 +617,12 @@ public abstract partial class McpServer : McpSession private void ThrowIfElicitationUnsupported(ElicitRequestParams request) { - if (ClientCapabilities is null) + if (!SupportsServerToClientRequests) { throw new InvalidOperationException("Elicitation is not supported in stateless mode."); } - var elicitationCapability = ClientCapabilities.Elicitation; + var elicitationCapability = ClientCapabilities?.Elicitation; if (elicitationCapability is null) { throw new InvalidOperationException("Client does not support elicitation requests."); diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 4797ce1..29f524a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -32,12 +32,11 @@ public abstract partial class McpServer : McpSession /// 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 - /// are available when interacting with the client. + /// This property reports capabilities declared by the client. Their presence does not guarantee that the + /// transport supports server-to-client requests. Methods such as sampling, roots, and elicitation reject + /// those requests when the transport cannot safely deliver them, including in stateless mode. /// /// public abstract ClientCapabilities? ClientCapabilities { get; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2ce8387..76a65ed 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -33,14 +33,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly SemaphoreSlim _disposeLock = new(1, 1); 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; @@ -122,7 +114,7 @@ internal sealed partial class McpServerImpl : McpServer // 2026-07-28+ client) is decided per response in GetAdvertisedCapabilities rather than cleared here, // because the same ServerCapabilities feeds both the legacy initialize handshake (which can never // deliver it) and server/discover (which can, given a custom handler). - if (HasStatefulTransport()) + if (HasStatefulTransport) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification); @@ -160,124 +152,46 @@ internal sealed partial class McpServerImpl : McpServer /// /// 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. + /// classifies and projects modern per-request _meta, synchronizes server-side state + /// (, ), and validates protocol + /// boundaries before delegating to user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so the protocol /// version and client capabilities MUST be populated per-request. Client info is optional. 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). + /// best-effort and used only to derive the session endpoint name for logging/telemetry. Under initialize-handshake + /// revisions, reserved per-request metadata is opaque and is not parsed or projected. Modern envelopes remain + /// strictly parsed. /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { - if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest) + if (message is JsonRpcRequest request) { - 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 (request.Method == RequestMethods.Initialize) { - 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 (!_supportedProtocolVersions.Contains(protocolVersion)) - { - var supportedVersions = - hasProtocolVersionMeta && _perRequestMetadataProtocolVersions.Length > 0 ? - _perRequestMetadataProtocolVersions : - _supportedProtocolVersions; - - throw new UnsupportedProtocolVersionException( - requested: protocolVersion, - supported: supportedVersions); - } - - if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) - { - ValidateRequiredPerRequestMetadata( - protocolVersion, - hasProtocolVersionMeta, - 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); - } + ValidateInitializeRequestBoundary(request); } - else if (_negotiatedProtocolVersion is null) + else { - if (request.Method == RequestMethods.ServerDiscover) + ReadRequestMetadata(request); + ValidateRequestMethodBoundary(request); + + var context = request.Context; + if (McpProtocolVersions.RequiresPerRequestMetadata(context?.ProtocolVersion ?? _negotiatedProtocolVersion) && + context?.ClientInfo is { } clientInfo && + (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || + !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { - throw new McpProtocolException( - $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", - McpErrorCode.InvalidParams); + // Modern handlers resolve client info request-scoped through DestinationBoundMcpServer. + // This shared write is only for endpoint logging. + _clientInfo = clientInfo; + UpdateEndpointNameWithClientInfo(); + _sessionHandler.EndpointName = _endpointName; } - - if (hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); - } - } - else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) - { - ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); - } - - 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; - } - - if (endpointNameNeedsRefresh) - { - UpdateEndpointNameWithClientInfo(); - _sessionHandler.EndpointName = _endpointName; } } else if (message is JsonRpcNotification notification) @@ -291,56 +205,158 @@ internal sealed partial class McpServerImpl : McpServer return next => metaReadingFilter(inner(next)); } - private static void ValidateRequiredPerRequestMetadata( - string protocolVersion, - bool hasProtocolVersionMeta, - bool hasClientCapabilitiesMeta) + private void ReadRequestMetadata(JsonRpcRequest request) { - if (!hasProtocolVersionMeta) + string? transportProtocolVersion = request.Context?.ProtocolVersion; + + // An established legacy session or supported legacy transport header selects initialize-handshake + // semantics. Continue validating an authoritative transport version, but treat future body metadata + // as opaque under the negotiated legacy revision. + if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) || + McpProtocolVersions.SupportsInitializeHandshake(transportProtocolVersion)) { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); + if (transportProtocolVersion is not null) + { + if (!_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + SetNegotiatedProtocolVersion(transportProtocolVersion); + } + + return; } - // clientInfo is optional: requests whose _meta omits it are served, not rejected. + JsonObject? meta = request.Params is JsonObject paramsObj ? paramsObj["_meta"] as JsonObject : null; + // An unreadable value cannot select a protocol; only the modern path requires a usable version. + string? metadataProtocolVersion = + meta?[MetaKeys.ProtocolVersion] is JsonValue value && value.TryGetValue(out string? version) ? version : null; - if (!hasClientCapabilitiesMeta) + if (transportProtocolVersion is not null && + metadataProtocolVersion is not null && + !string.Equals(transportProtocolVersion, metadataProtocolVersion, StringComparison.Ordinal)) { - ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{metadataProtocolVersion}' does not match the MCP-Protocol-Version header value '{transportProtocolVersion}'.", + McpErrorCode.HeaderMismatch); + } + + if (McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) || + transportProtocolVersion is not null || + (metadataProtocolVersion is not null && !McpProtocolVersions.SupportsInitializeHandshake(metadataProtocolVersion)) || + _initializeHandshakeProtocolVersions.Length == 0) + { + if (metadataProtocolVersion is null) + { + if (transportProtocolVersion is not null && + !_supportedProtocolVersions.Contains(transportProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: transportProtocolVersion, + supported: _supportedProtocolVersions); + } + + throw MissingPerRequestMetadata( + transportProtocolVersion ?? _negotiatedProtocolVersion ?? _perRequestMetadataProtocolVersions[0], + MetaKeys.ProtocolVersion); + } + + if (!_supportedProtocolVersions.Contains(metadataProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: metadataProtocolVersion, + supported: _perRequestMetadataProtocolVersions.Length > 0 + ? _perRequestMetadataProtocolVersions + : _supportedProtocolVersions); + } + + // Reject version changes before parsing, but establish a new version only after parsing succeeds. + bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; + if (protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(metadataProtocolVersion); + } + + ProjectModernMetadata(request, meta!, metadataProtocolVersion); + + if (!protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(metadataProtocolVersion); + } + + return; + } + + if (request.Method == RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", + McpErrorCode.InvalidParams); + } + + // With no established era, warn in case a modern peer accidentally fell back to legacy handling. + if (metadataProtocolVersion is null && meta?.ContainsKey(MetaKeys.ProtocolVersion) is true) + { + LogIgnoredUnreadableProtocolVersionMetadata(_endpointName, request.Method); } } - private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => - throw new McpProtocolException( + private static void ProjectModernMetadata(JsonRpcRequest request, JsonObject meta, string protocolVersion) + { + if (!meta.ContainsKey(MetaKeys.ClientCapabilities)) + { + throw MissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + } + + var context = request.Context ??= new(); + context.ProtocolVersion = protocolVersion; + context.ClientInfo = meta[MetaKeys.ClientInfo] is JsonNode clientInfoNode + ? DeserializeModernMetadata( + clientInfoNode, + McpJsonUtilities.JsonContext.Default.Implementation, + MetaKeys.ClientInfo) + : null; + context.ClientCapabilities = meta[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode + ? DeserializeModernMetadata( + clientCapabilitiesNode, + McpJsonUtilities.JsonContext.Default.ClientCapabilities, + MetaKeys.ClientCapabilities) + : throw InvalidMetadata(MetaKeys.ClientCapabilities); + context.LogLevel = meta[MetaKeys.LogLevel] is JsonNode logLevelNode + ? DeserializeModernMetadata( + logLevelNode, + McpJsonUtilities.JsonContext.Default.LoggingLevel, + MetaKeys.LogLevel) + : null; + } + + private static T DeserializeModernMetadata(JsonNode node, JsonTypeInfo typeInfo, string key) + { + try + { + T? value = JsonSerializer.Deserialize(node, typeInfo); + return value is not null ? value : throw new JsonException(); + } + catch (JsonException ex) + { + throw new McpProtocolException( + $"The per-request metadata key '_meta/{key}' has an invalid value.", + ex, + McpErrorCode.InvalidParams); + } + } + + private static McpProtocolException InvalidMetadata(string key) => + new($"The per-request metadata key '_meta/{key}' has an invalid value.", McpErrorCode.InvalidParams); + + private static McpProtocolException MissingPerRequestMetadata(string protocolVersion, string key) => + new( $"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); - /// /// Adds the server identity to every successful result on per-request-metadata protocol revisions. /// The filter runs before application filters so they can inspect or intentionally remove the metadata. @@ -372,12 +388,17 @@ internal sealed partial class McpServerImpl : McpServer private void ValidateInitializeRequestBoundary(JsonRpcRequest request) { - // Per-request-metadata revisions (SEP-2575) removed the initialize handshake entirely: - // the request is for a method the server does not implement on that revision. - if (McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion)) + // Modern revisions removed initialize. An established modern session is authoritative even when + // the new request has no version header; a failed discovery probe does not establish a version. + string? modernProtocolVersion = + McpProtocolVersions.RequiresPerRequestMetadata(request.Context?.ProtocolVersion) ? request.Context!.ProtocolVersion : + McpProtocolVersions.RequiresPerRequestMetadata(_negotiatedProtocolVersion) ? _negotiatedProtocolVersion : + null; + + if (modernProtocolVersion is not null) { throw new McpProtocolException( - $"Method '{RequestMethods.Initialize}' is not available on protocol version '{request.Context?.ProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", + $"Method '{RequestMethods.Initialize}' is not available on protocol version '{modernProtocolVersion}'. Use '{RequestMethods.ServerDiscover}' and per-request metadata instead.", McpErrorCode.MethodNotFound); } @@ -389,23 +410,6 @@ internal sealed partial class McpServerImpl : McpServer 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) @@ -511,13 +515,17 @@ internal sealed partial class McpServerImpl : McpServer public ServerCapabilities ServerCapabilities { get; } /// - /// Returns the to advertise in a specific response, suppressing the - /// listChanged flags the server has no way to honor. + /// Returns the to advertise in a specific response, suppressing + /// capabilities that are not available on that response's protocol path. /// /// /// when the client this response targets can receive */list_changed /// notifications over a subscriptions/listen stream. /// + /// + /// for legacy initialize responses that support logging/setLevel; + /// for modern discover responses, where that method is unavailable. + /// /// /// A stateless HTTP server has no session-wide channel to push unsolicited */list_changed /// notifications. It can only deliver them over a subscriptions/listen stream, which requires both @@ -525,11 +533,15 @@ internal sealed partial class McpServerImpl : McpServer /// to own that stream (the built-in stateless /// handler grants no notifications). When neither the transport is stateful nor that stream can carry /// them, the listChanged flags are dropped so the server never advertises a capability it cannot - /// deliver. Everything else (for example resources.subscribe) is preserved. + /// deliver. The deprecated logging capability is likewise omitted from modern discovery because this SDK + /// rejects the legacy logging/setLevel method on that path. Everything else is preserved. /// - private ServerCapabilities GetAdvertisedCapabilities(bool listenStreamCanDeliverListChanged) + private ServerCapabilities GetAdvertisedCapabilities( + bool listenStreamCanDeliverListChanged, + bool includeDeprecatedLogging) { - if (HasStatefulTransport() || listenStreamCanDeliverListChanged) + bool includeListChanged = HasStatefulTransport || listenStreamCanDeliverListChanged; + if (includeListChanged && includeDeprecatedLogging) { return ServerCapabilities; } @@ -539,20 +551,32 @@ internal sealed partial class McpServerImpl : McpServer return new ServerCapabilities { Experimental = ServerCapabilities.Experimental, - Logging = ServerCapabilities.Logging, + Logging = includeDeprecatedLogging ? ServerCapabilities.Logging : null, Completions = ServerCapabilities.Completions, Extensions = ServerCapabilities.Extensions, - Prompts = ServerCapabilities.Prompts is null ? null : new PromptsCapability { ListChanged = null }, + Prompts = ServerCapabilities.Prompts is null + ? null + : includeListChanged + ? ServerCapabilities.Prompts + : new PromptsCapability { ListChanged = null }, Resources = ServerCapabilities.Resources is { } resources - ? new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } + ? includeListChanged + ? resources + : new ResourcesCapability { Subscribe = resources.Subscribe, ListChanged = null } : null, - Tools = ServerCapabilities.Tools is null ? null : new ToolsCapability { ListChanged = null }, + Tools = ServerCapabilities.Tools is null + ? null + : includeListChanged + ? ServerCapabilities.Tools + : new ToolsCapability { ListChanged = null }, }; } /// public override ClientCapabilities? ClientCapabilities => _clientCapabilities; + internal override bool SupportsServerToClientRequests => HasStatefulTransport; + /// public override Implementation? ClientInfo => _clientInfo; @@ -688,11 +712,8 @@ internal sealed partial class McpServerImpl : McpServer 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. + // initialize may supersede a legacy transport version. ValidateInitializeRequestBoundary + // prevents it from downgrading an established modern session. _negotiatedProtocolVersion = negotiatedProtocolVersion; _sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion; @@ -705,7 +726,9 @@ internal sealed partial class McpServerImpl : McpServer // The initialize handshake only serves pre-2026-07-28 clients, which cannot open a // subscriptions/listen stream, so a stateless server has no way to deliver list-changed // notifications to them regardless of any custom handler. - Capabilities = GetAdvertisedCapabilities(listenStreamCanDeliverListChanged: false), + Capabilities = GetAdvertisedCapabilities( + listenStreamCanDeliverListChanged: false, + includeDeprecatedLogging: true), // resultType is a 2026-07-28 result field. The initialize handshake is only available on // 2025-11-25 and earlier revisions (2026-07-28+ negotiate via server/discover and throw @@ -738,7 +761,8 @@ internal sealed partial class McpServerImpl : McpServer // author supplied a custom handler to own that stream (the built-in stateless handler // grants nothing, so it cannot). Capabilities = GetAdvertisedCapabilities( - listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null), + listenStreamCanDeliverListChanged: options.Handlers.SubscriptionsListenHandler is not null, + includeDeprecatedLogging: false), Instructions = options.ServerInstructions, // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to // the safest values (immediately stale, not shareable) so existing servers keep @@ -841,7 +865,7 @@ internal sealed partial class McpServerImpl : McpServer // request granting no notifications and complete immediately. This runs after protocol // negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the // initialize handshake. - if (!HasStatefulTransport()) + if (!HasStatefulTransport) { var statelessSubscription = new ActiveSubscription( jsonRpcRequest.Id, @@ -2048,7 +2072,7 @@ internal sealed partial class McpServerImpl : McpServer /// sites where the intent is "the client understands " rather than /// "the peer speaks the 2026-07-28 or later revision". /// - internal bool ClientSupportsMrtr() => IsJuly2026OrLaterProtocol(); + private bool ClientSupportsMrtr => IsJuly2026OrLaterProtocol(); /// /// Returns when the session is stateful - the same server instance handles @@ -2057,8 +2081,9 @@ internal sealed partial class McpServerImpl : McpServer /// elicitation/create / sampling/createMessage / roots/list to the client and /// retry the handler with the responses. /// - internal bool HasStatefulTransport() => + private 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 @@ -2073,7 +2098,7 @@ internal sealed partial class McpServerImpl : McpServer requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// - public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport(); + public override bool IsMrtrSupported => ClientSupportsMrtr || HasStatefulTransport; /// /// Invokes a handler and catches to convert it to an @@ -2117,7 +2142,7 @@ internal sealed partial class McpServerImpl : McpServer // If the client natively supports MRTR, serialize and return directly - // the client will drive the retry loop. - if (ClientSupportsMrtr()) + if (ClientSupportsMrtr) { return SerializeInputRequiredResult(inputRequiredResult); } @@ -2125,7 +2150,7 @@ internal sealed partial class McpServerImpl : McpServer // 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()) + if (!HasStatefulTransport) { throw new McpException( "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + @@ -2364,7 +2389,7 @@ internal sealed partial class McpServerImpl : McpServer // For all other cases - legacy clients, stateless sessions - fall through to the // exception-based path, which transparently resolves InputRequiredException via // legacy JSON-RPC requests when the client doesn't speak MRTR. - if (!ClientSupportsMrtr() || !HasStatefulTransport()) + if (!ClientSupportsMrtr || !HasStatefulTransport) { return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); } @@ -2527,6 +2552,9 @@ internal sealed partial class McpServerImpl : McpServer [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} ignored an unreadable '_meta/io.modelcontextprotocol/protocolVersion' value on '{Method}' and selected initialize-handshake semantics. The client may not be spec-compliant.")] + private partial void LogIgnoredUnreadableProtocolVersionMetadata(string endpointName, string method); + [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")] private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs index 93b7e5e..e32a160 100644 --- a/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs @@ -17,6 +17,11 @@ internal sealed class OutgoingRequestInterceptingMcpServer( public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; + // The interceptor replaces the session's server-to-client channel, so it can deliver requests even + // when the underlying transport cannot (for example a stateless HTTP request, where a background task + // parks the request in an IMcpTaskStore and the client answers it on a later, unrelated request). + internal override bool SupportsServerToClientRequests => true; + public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index b57852f..d3848e0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -132,6 +132,9 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + var capabilities = json["result"]!["capabilities"]!.AsObject(); + Assert.False(capabilities.ContainsKey("logging")); + Assert.True(capabilities.ContainsKey("tools")); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. @@ -349,12 +352,19 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); } - [Fact] - public async Task July2026Post_MissingBodyProtocolVersion_ReturnsInvalidParams_Minus32602() + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + public async Task July2026Post_MissingOrMalformedBodyProtocolVersion_ReturnsInvalidParams_Minus32602(string? protocolVersionJson) { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; + var versionProperty = protocolVersionJson is null ? "" : @"""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + ","; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{" + versionProperty + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); @@ -370,8 +380,24 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); } + [Theory] + [InlineData("{}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":{}}", McpErrorCode.InvalidParams)] + [InlineData(@"{""io.modelcontextprotocol/protocolVersion"":""2025-11-25""}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task ModernOnlyServer_HeaderlessRequest_StillRequiresModernMetadata(string metaJson, McpErrorCode expectedError) + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":" + metaJson + "}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)expectedError, json["error"]!["code"]!.GetValue()); + } + [Fact] - public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() + public async Task MissingProtocolVersionHeader_WithModernMetadata_IsServedFromBodyMetadata() { await StartAsync(); @@ -381,10 +407,61 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + // The 2025-03-26 header default only applies when the server has "no other way to identify the + // version". The body metadata is another way, so the transport does not reject the request on the + // strength of a reserved _meta value. stdio, which has no header at all, behaves identically. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); - Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); - Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + Assert.NotNull(json["result"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithUnsupportedMetadata_ReturnsUnsupportedProtocolVersion_Minus32022() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal("9999-99-99", json["error"]!["data"]!["requested"]!.GetValue()); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithMalformedMetadata_IsIgnored() + { + await StartAsync(); + + // No header and no established era means legacy handling, where the reserved namespace is opaque. + // A value we cannot read is not a version claim, so it must not turn a legacy request into an error. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); + } + + [Fact] + public async Task MissingProtocolVersionHeader_WithLegacyMetadata_IsIgnored() + { + await StartAsync(); + + // A legacy reserved value on a headerless request stays opaque, which is the ChatGPT shape from #1783. + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2025-06-18""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.NotNull(json["result"]!["tools"]); } [Fact] @@ -405,11 +482,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn } [Fact] - public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() + public async Task InitializeHandshake_IgnoresFutureMetadata_OnDefaultServer() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":{},""io.modelcontextprotocol/clientInfo"":""invalid"",""io.modelcontextprotocol/clientCapabilities"":""invalid""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -453,6 +532,29 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn Assert.False(result.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 tools/list result."); } + [Theory] + [InlineData(@"""2025-11-25""")] + [InlineData(@"""2026-07-28""")] + [InlineData(@"""9999-99-99""")] + [InlineData("{}")] + public async Task Legacy2025Post_IgnoresFuturePerRequestMetadata(string protocolVersionJson) + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""legacy_meta_probe"",""arguments"":{}," + + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":" + protocolVersionJson + "," + + @"""io.modelcontextprotocol/clientInfo"":{""name"":""chatgpt"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{""sampling"":{}}}}}"; + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.November2025ProtocolVersion); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("future-metadata-ignored", json["result"]!["content"]![0]!["text"]!.GetValue()); + } + [Fact] public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() { @@ -523,6 +625,18 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn { private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1); + [McpServerTool(Name = "legacy_meta_probe")] + public static string LegacyMetaProbe(RequestContext context) + { + var requestContext = context.JsonRpcRequest.Context; + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, requestContext?.ProtocolVersion); + Assert.Null(requestContext?.ClientInfo); + Assert.Null(requestContext?.ClientCapabilities); + Assert.Null(context.Server.ClientInfo); + Assert.Null(context.Server.ClientCapabilities); + return "future-metadata-ignored"; + } + [McpServerTool(Name = "requires_sampling")] public static string RequiresSampling() => throw new MissingRequiredClientCapabilityException( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 092f8f2..a95d989 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; @@ -9,6 +10,7 @@ using System.Diagnostics; using System.Net; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -154,6 +156,43 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem Assert.Equal("Server to client requests are not supported in stateless mode.", Assert.IsType(toolContent).Text); } + [Fact] + public async Task ClientCapabilities_AreAvailableFromInjectedServer() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.RootsHandler = (_, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "getClientCapabilities", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "sampling|roots|elicitation", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + + [Fact] + public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode() + { + await StartAsync(); + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException(); + clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException(); + await using var client = await ConnectMcpClientAsync(clientOptions); + + var toolResponse = await client.CallToolAsync( + "testInterceptedRequests", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + "intercepted-sample|Seattle", + Assert.IsType(Assert.Single(toolResponse.Content)).Text); + } + [Fact] public async Task UnsolicitedNotification_Fails_WithInvalidOperationException() { @@ -575,8 +614,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedSamplingErrorMessage = "Sampling is not supported in stateless mode."; - // Even when the client has sampling support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Sampling); var asSamplingChatClientEx = Assert.Throws(() => server.AsSamplingChatClient()); Assert.Equal(expectedSamplingErrorMessage, asSamplingChatClientEx.Message); @@ -596,8 +635,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedRootsErrorMessage = "Roots are not supported in stateless mode."; - // Even when the client has roots support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Roots); var requestRootsEx = Assert.Throws(() => server.RequestRootsAsync(new())); Assert.Equal(expectedRootsErrorMessage, requestRootsEx.Message); @@ -614,8 +653,8 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem { const string expectedElicitationErrorMessage = "Elicitation is not supported in stateless mode."; - // Even when the client has elicitation support, it should not be advertised in stateless mode. - Assert.Null(server.ClientCapabilities); + // The declaration is visible to application code, but it cannot make a session-dependent request safe. + Assert.NotNull(server.ClientCapabilities?.Elicitation); var requestElicitationEx = await Assert.ThrowsAsync(() => server.ElicitAsync(new() { Message = string.Empty }).AsTask()); Assert.Equal(expectedElicitationErrorMessage, requestElicitationEx.Message); @@ -627,6 +666,39 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem return ex.Message; } + [McpServerTool(Name = "getClientCapabilities")] + public static string GetClientCapabilities(McpServer server) => + string.Join( + '|', + server.ClientCapabilities?.Sampling is null ? "no-sampling" : "sampling", + server.ClientCapabilities?.Roots is null ? "no-roots" : "roots", + server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation"); + + [McpServerTool(Name = "testInterceptedRequests")] + public static async Task TestInterceptedRequests(McpServer server) + { + // Background task execution replaces the server-to-client channel with an interceptor that parks the + // request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not + // depend on session affinity, which is why these requests are allowed through in stateless mode. +#pragma warning disable MCPEXP002 + var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch + { + RequestMethods.SamplingCreateMessage => + """{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""", + RequestMethods.ElicitationCreate => + """{"action":"accept","content":{"city":"Seattle"}}""", + _ => throw new UnreachableException(), + }))); +#pragma warning restore MCPEXP002 + + var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?"); + var elicitResult = await intercepted.ElicitAsync( + "Which city?", + options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options }); + + return $"{samplingResponse.Text}|{elicitResult.Content?.City}"; + } + [McpServerTool(Name = "testScope")] public static string? TestScope(ScopedService scopedService) => scopedService.State; @@ -640,3 +712,12 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem public void Report(T value) => handler(value); } } + +public class CityForm +{ + public string? City { get; set; } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CityForm))] +internal partial class StatelessInterceptorJsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 3afbb52..94558a6 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -137,6 +137,53 @@ public class McpClientMetaTests : ClientServerTestBase Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task LegacyToolCall_WithPerRequestClientMetadata_PreservesInitializedSessionState() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + Assert.Null(context.JsonRpcRequest.Context?.ClientInfo); + Assert.Null(context.JsonRpcRequest.Context?.ClientCapabilities); + + Assert.Equal("initialized-client", context.Server.ClientInfo?.Name); + Assert.NotNull(context.Server.ClientCapabilities?.Elicitation); + Assert.Null(context.Server.ClientCapabilities?.Sampling); + + return "ok"; + }, + new() { Name = "legacy_meta_tool" })); + + var clientOptions = new McpClientOptions + { + ProtocolVersion = LatestStableVersion, + ClientInfo = new Implementation { Name = "initialized-client", Version = "1.0.0" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "legacy_meta_tool", + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode( + new Implementation { Name = "request-client", Version = "2.0.0" }, + McpJsonUtilities.DefaultOptions), + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }, + TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(Assert.Single(result.Content)).Text); + } + [Fact] public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() { @@ -250,9 +297,9 @@ public class McpClientMetaTests : ClientServerTestBase ClientCapabilities? handlerObservedCapabilities = null; Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( - (RequestContext context) => + (McpServer server) => { - handlerObservedCapabilities = context.Server.ClientCapabilities; + handlerObservedCapabilities = server.ClientCapabilities; return "ok"; }, new() { Name = "capability_probe_tool" })); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index e54f40d..0ab19a3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -289,11 +289,20 @@ public class McpServerTests : LoggedTest Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); + Assert.True(Assert.IsType(response)["capabilities"]!.AsObject().ContainsKey("logging")); }); } - [Fact] - public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + [Theory] + [InlineData("2025-11-25", "\"2025-03-26\"", null)] + [InlineData("2025-11-25", "\"2026-07-28\"", null)] + [InlineData("2025-11-25", "{}", null)] + [InlineData("2026-07-28", "\"2025-11-25\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "\"9999-99-99\"", McpErrorCode.HeaderMismatch)] + [InlineData("2026-07-28", "{}", McpErrorCode.InvalidParams)] + [InlineData("9999-99-99", "{}", McpErrorCode.UnsupportedProtocolVersion)] + public async Task TransportProtocolVersion_IsValidatedBeforeBodyMetadata( + string transportVersion, string metadataVersionJson, McpErrorCode? expectedError) { var ct = TestContext.Current.CancellationToken; await using var transport = new TestServerTransport(); @@ -303,71 +312,44 @@ public class McpServerTests : LoggedTest 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); + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); transport.OnMessageSent = message => { - if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1") + if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "1") { - rejectedResponse.TrySetResult(error); - } - else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2") - { - acceptedResponse.TrySetResult(message); + response.TrySetResult(message); } }; await transport.SendClientMessageAsync(new JsonRpcRequest { Id = new RequestId(1), - Method = RequestMethods.ToolsList, + Method = RequestMethods.Ping, Params = new JsonObject { ["_meta"] = new JsonObject { - [MetaKeys.ClientInfo] = new JsonObject - { - ["name"] = "test-client", - ["version"] = "1.0.0", - }, + [MetaKeys.ProtocolVersion] = JsonNode.Parse(metadataVersionJson), }, }, Context = new JsonRpcMessageContext { - ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + ProtocolVersion = transportVersion, 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 + var message = await response.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + if (expectedError is { } errorCode) { - 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); + Assert.Equal((int)errorCode, Assert.IsType(message).Error.Code); + Assert.Null(server.NegotiatedProtocolVersion); + } + else + { + Assert.IsType(message); + Assert.Equal(transportVersion, server.NegotiatedProtocolVersion); + } await transport.DisposeAsync(); await runTask; diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index d8cadeb..b4bf440 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -25,6 +25,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl private readonly Pipe _serverToClient = new(); private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); private readonly ServiceProvider _services; + private readonly McpServer _server; private readonly Task _serverTask; private readonly StreamWriter _writer; private readonly StreamReader _reader; @@ -35,14 +36,15 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); serviceCollection.AddSingleton(XunitLoggerProvider); + serviceCollection.AddSingleton(MockLoggerProvider); serviceCollection .AddMcpServer() .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) .WithTools(); _services = serviceCollection.BuildServiceProvider(validateScopes: true); - var server = _services.GetRequiredService(); - _serverTask = server.RunAsync(_cts.Token); + _server = _services.GetRequiredService(); + _serverTask = _server.RunAsync(_cts.Token); _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; _reader = new StreamReader(_serverToClient.Reader.AsStream()); @@ -69,16 +71,115 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() + public async Task LegacyProtocolVersionMetadata_BeforeInitialize_IsAdvisory() { 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); + Assert.IsType( + await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); - // The rejected initialize-handshake _meta request must not have established session state. + // The advisory legacy value must not block a subsequent modern request from selecting its era. Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task MalformedProtocolVersionMetadata_BeforeInitialize_IsIgnored(string protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson), + }, + }, + }; + + // An unreadable value leaves the request on the legacy path without establishing a version. + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Null(_server.NegotiatedProtocolVersion); + + var warning = Assert.Single( + MockLoggerProvider.LogMessages, + m => m.LogLevel == LogLevel.Warning && m.Message.Contains(MetaKeys.ProtocolVersion, StringComparison.Ordinal)); + Assert.Contains(RequestMethods.ToolsList, warning.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task MalformedProtocolVersionMetadata_OnServerDiscover_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + }, + }, + }; + + // server/discover exists only on per-request-metadata revisions, so it cannot fall back to legacy + // handling. This is what keeps a modern client from silently getting legacy treatment on a transport + // with no protocol version header. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + public async Task ModernSession_RejectsMissingOrMalformedProtocolVersionMetadata(string? protocolVersionJson) + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + var meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + if (protocolVersionJson is not null) + { + meta[MetaKeys.ProtocolVersion] = JsonNode.Parse(protocolVersionJson); + } + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + + // Once the era is established the metadata is required, so an unreadable value is an error rather + // than a reason to drop back to legacy handling. + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ProtocolVersion, error.Error.Message, StringComparison.Ordinal); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, _server.NegotiatedProtocolVersion); } [Fact] @@ -110,6 +211,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); Assert.Contains(MetaKeys.ClientCapabilities, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); } [Fact] @@ -142,7 +244,7 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Fact] - public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() + public async Task Initialize_IgnoresFutureReservedMetadata() { var ct = TestContext.Current.CancellationToken; @@ -157,18 +259,101 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl 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", - }, + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", }, }, McpJsonUtilities.DefaultOptions), }; + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + } + + [Fact] + public async Task LegacySession_IgnoresConflictingKnownLegacyProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.March2025ProtocolVersion, ct)); + + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresModernProtocolVersionMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + Assert.IsType( + await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, _server.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacySession_IgnoresMalformedAuxiliaryMetadata() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = new JsonObject(), + [MetaKeys.ClientInfo] = "not-an-object", + [MetaKeys.ClientCapabilities] = "not-an-object", + [MetaKeys.LogLevel] = "not-a-level", + }, + }, + }; + + Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel >= LogLevel.Warning); + } + + [Theory] + [InlineData(MetaKeys.ClientCapabilities, "\"not-an-object\"")] + [InlineData(MetaKeys.ClientCapabilities, "null")] + [InlineData(MetaKeys.ClientCapabilities, "[]")] + [InlineData(MetaKeys.ClientInfo, "\"not-an-object\"")] + [InlineData(MetaKeys.LogLevel, "\"not-a-level\"")] + public async Task ModernRequest_RejectsMalformedMetadata_WithoutEstablishingVersion(string key, string valueJson) + { + var ct = TestContext.Current.CancellationToken; + var meta = PerRequestMetadata(); + meta[key] = JsonNode.Parse(valueJson); + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = meta, + }, + }; + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); - Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); - Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(key, error.Error.Message, StringComparison.Ordinal); + Assert.Null(_server.NegotiatedProtocolVersion); + + Assert.IsType( + await RoundTripInitializeAsync(id: 2, McpProtocolVersions.November2025ProtocolVersion, ct)); } [Fact] @@ -192,7 +377,6 @@ public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposabl } [Theory] - [InlineData("initialize")] [InlineData("ping")] [InlineData("logging/setLevel")] [InlineData("resources/subscribe")] diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index c068d22..a41adfc 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -166,14 +166,14 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable } [Fact] - public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + public async Task MixedSequence_FailedDiscoverProbe_Then_Initialize_Then_ToolsCall_AllSucceed() { - // 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() + "}}"); + // Dual-path servers must accept an initialize-handshake fallback after a 2026-07-28 probe fails. + // This is the sequence a permissive client emits against an unknown server: the failed probe never + // establishes a version, so initialize is still available. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("9999-99-99") + "}}"); var discover = await ReadAsync(); - Assert.NotNull(discover["result"]); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, discover["error"]!["code"]!.GetValue()); await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); @@ -186,5 +186,29 @@ public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable var call = await ReadAsync(); Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); } + + [Theory] + [InlineData("tools/list")] + [InlineData("server/discover")] + public async Task Initialize_AfterEstablishedModernRequest_IsRejected(string establishingMethod) + { + // A modern request locks the session's protocol version. initialize declares nothing on stdio, so + // without this gate it would force the session back to a handshake version and make the modern + // _meta on in-flight requests opaque. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""" + establishingMethod + @""",""params"":{" + July2026ProtocolMetaFragment() + "}}"); + var established = await ReadAsync(); + Assert.NotNull(established["result"]); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); + var init = await ReadAsync(); + Assert.Equal((int)McpErrorCode.MethodNotFound, init["error"]!["code"]!.GetValue()); + + // The session is still modern: a request carrying full per-request metadata keeps working. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""still-modern""}," + + July2026ProtocolMetaFragment() + "}}"); + var call = await ReadAsync(); + Assert.Equal("echo:still-modern", call["result"]!["content"]![0]!["text"]!.GetValue()); + } } #endif diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 6fa9fa2..af4002e 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -123,23 +123,23 @@ public class TaskProtocolGatingTests : ClientServerTestBase } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_IgnoresPerRequestCapability() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy - // request. The server rejects reserved per-request metadata before it can affect behavior. - var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsTaskAsync( + // A forward-compatible client may carry SEP-2575 capabilities on a legacy request. The + // metadata is tolerated, but it cannot opt the legacy session into modern-only tasks. + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "forged"), Meta = CreateForgedTaskOptInMeta(), - }, ct)); + }, ct); - Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode); - Assert.Contains(ClientCapabilitiesMetaKey, ex.Message); + Assert.False(result.IsTask); + Assert.NotNull(result.Result); } [Fact] From c40ee044fd415c70da5176c749cb5ef02f2b59f6 Mon Sep 17 00:00:00 2001 From: Scott Holodak Date: Fri, 18 Sep 2026 20:07:30 -0400 Subject: [PATCH 58/58] fix(client): skip the SSE fallback when the server/discover probe is rejected with 400/404 (#1855) --- .../AutoDetectingClientSessionTransport.cs | 34 +++- .../HttpClientTransportAutoDetectTests.cs | 151 ++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index b3041ec..7563acd 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -99,8 +99,6 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport // behavior. Capture the underlying error (status + body) before falling back so that, // if SSE also fails, we can surface the real Streamable HTTP diagnostic to the caller // instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526). - LogStreamableHttpFailed(_name, response.StatusCode); - // This reads the response body a second time for the application/json case, where // TryReadJsonRpcErrorAsync above already read it. HttpContent buffers after the first // read, so this returns the same buffered content and is safe (not a second stream @@ -108,6 +106,26 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport // TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read. var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); + if (IsDiscoverProbeRejection(message, response.StatusCode)) + { + // The server/discover probe is protocol negotiation, not transport detection. A server + // predating SEP-2575 rejects the session-less POST with 400 (can't parse the request) or + // 404 (requires Mcp-Session-Id on every non-initialize POST) whether it speaks Streamable + // HTTP or SSE, so neither status is evidence about which transport to use. McpClientImpl + // .ConnectAsync treats exactly these two statuses as "initialize-handshake server" and + // immediately retries with initialize on this same transport — and that attempt still + // falls back to SSE, so an SSE-only server is reached one POST later rather than not at + // all. Attempting SSE here instead spends a GET whose result is discarded on every + // connect to a Streamable-HTTP-only server that predates SEP-2575, and logs a "falling + // back to SSE transport" line that misreports settled protocol negotiation as a failure. + LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode); + + await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); + throw streamableHttpError; + } + + LogStreamableHttpFailed(_name, response.StatusCode); + await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false); } @@ -122,6 +140,15 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport } } + /// + /// Returns when the failed request was the SEP-2575 server/discover probe and the + /// status is one of the two already reads as "this server requires the initialize + /// handshake", meaning the SSE fallback cannot contribute anything the initialize retry won't. + /// + private static bool IsDiscoverProbeRejection(JsonRpcMessage message, HttpStatusCode statusCode) => + statusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound && + message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }; + private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken) { if (_options.KnownSessionId is not null) @@ -184,6 +211,9 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")] private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode); + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} server/discover probe rejected with status code {StatusCode}; skipping the SSE fallback so the initialize handshake is attempted instead.")] + private partial void LogSkippingSseFallbackForDiscoverProbe(string endpointName, HttpStatusCode statusCode); + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")] private partial void LogUsingStreamableHttp(string endpointName); diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index 7100e72..c463833 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -505,4 +505,155 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SSE fallback failed")); } + + // A 400/404 on the SEP-2575 server/discover probe is protocol negotiation, not transport detection: + // a pre-SEP-2575 server rejects the session-less POST whether it speaks Streamable HTTP or SSE, and + // McpClientImpl.ConnectAsync reads exactly those two statuses as "initialize-handshake server" and + // retries with initialize. The SSE GET can therefore only waste a round trip here. + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.BadRequest)] + public async Task AutoDetectMode_SkipsSseFallback_WhenDiscoverProbeIsRejected(HttpStatusCode statusCode) + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var getCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + getCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent("Not Found"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + var ex = await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, getCount); + Assert.Equal(statusCode, ex.Data["ModelContextProtocol.HttpStatusCode"]); + Assert.DoesNotContain( + MockLoggerProvider.LogMessages, + m => m.Message.Contains("falling back to SSE transport")); + } + + // Skipping the SSE attempt on the discover probe must not strand an SSE-only server: the initialize + // retry that McpClientImpl.ConnectAsync issues next goes through this same transport and still falls + // back to SSE, so such a server is reached one POST later rather than not at all. + [Fact] + public async Task AutoDetectMode_StillFallsBackToSse_WhenInitializeFollowsRejectedDiscoverProbe() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe then initialize test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var ssePipe = new Pipe(); + var sseEndpointPostCount = 0; + + await ssePipe.Writer.WriteAsync( + System.Text.Encoding.UTF8.GetBytes("event: endpoint\r\ndata: /sse-endpoint\r\n\r\n"), + TestContext.Current.CancellationToken); + await ssePipe.Writer.FlushAsync(TestContext.Current.CancellationToken); + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + var content = new StreamContent(ssePipe.Reader.AsStream()); + content.Headers.ContentType = new("text/event-stream"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + + if (request.RequestUri?.AbsolutePath == "/sse-endpoint") + { + sseEndpointPostCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("Mcp-Session-Id required"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(2) }, + TestContext.Current.CancellationToken); + + Assert.Equal(1, sseEndpointPostCount); + + await ssePipe.Writer.CompleteAsync(); + } + + // The skip is scoped to the two statuses ConnectAsync acts on. Any other failure on the discover probe + // keeps the original fallback, because it is not evidence that an initialize retry is coming. + [Fact] + public async Task AutoDetectMode_FallsBackToSse_WhenDiscoverProbeFailsWithUnrelatedStatus() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect discover probe 415 test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + var getCount = 0; + + mockHttpHandler.RequestHandler = request => + { + if (request.Method == HttpMethod.Get) + { + getCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.UnsupportedMediaType) + { + Content = new StringContent("Content-Type must be 'application/json'"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(1, getCount); + } }