From b8c4d951ec78ba1d7c531de7fd6f5e029d7efaad Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Thu, 14 May 2026 17:39:37 -0500 Subject: [PATCH 01/50] Implementation for SEP-2243 Http Standardization (#1553) Co-authored-by: Tarek Mahmoud Sayed Co-authored-by: Tarek Mahmoud Sayed Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-build-test.yml | 2 +- .gitignore | 3 + docs/concepts/tools/tools.md | 25 + package-lock.json | 237 ++++++++- package.json | 2 +- src/Common/McpHttpHeaders.cs | 78 +++ .../ModelContextProtocol.AspNetCore.csproj | 1 + .../StreamableHttpHandler.cs | 300 ++++++++++- .../Client/McpClient.Methods.cs | 26 + .../Client/McpClientImpl.cs | 26 +- .../Client/McpHeaderExtractor.cs | 147 ++++++ .../StreamableHttpClientSessionTransport.cs | 81 ++- src/ModelContextProtocol.Core/McpErrorCode.cs | 20 + .../McpSessionHandler.cs | 3 +- .../ModelContextProtocol.Core.csproj | 1 + .../Protocol/McpHeaderEncoder.cs | 243 +++++++++ .../Server/AIFunctionMcpServerTool.cs | 87 ++++ .../Server/McpHeaderAttribute.cs | 81 +++ tests/Common/Utils/NodeHelpers.cs | 52 +- .../ClientConformanceTests.cs | 16 + .../HttpHeaderConformanceTests.cs | 478 ++++++++++++++++++ ...delContextProtocol.AspNetCore.Tests.csproj | 1 + .../ServerConformanceTests.cs | 61 ++- .../StreamableHttpClientConformanceTests.cs | 223 ++++++++ .../StreamableHttpServerConformanceTests.cs | 92 ++++ .../Program.cs | 130 +++++ .../Tools/ConformanceTools.cs | 9 + .../Client/McpClientToolRejectionTests.cs | 67 +++ .../Client/McpHeaderEncoderTests.cs | 163 ++++++ .../Client/McpRequestHeadersTests.cs | 35 ++ .../ModelContextProtocol.Tests.csproj | 1 + .../Server/McpHeaderAttributeTests.cs | 59 +++ .../Server/McpServerToolTests.cs | 132 +++++ 33 files changed, 2854 insertions(+), 28 deletions(-) create mode 100644 src/Common/McpHttpHeaders.cs create mode 100644 src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs create mode 100644 src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientToolRejectionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index dc788d08e..faab32ff8 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -52,7 +52,7 @@ jobs: - name: 🔧 Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: 📦 Install pinned npm dependencies for tests run: npm ci diff --git a/.gitignore b/.gitignore index 8d8db2cb4..a2ea2f790 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Cake tools /[Tt]ools/ +# Language server cache +*.lscache + # Build output [Bb]uildArtifacts/ # Build results diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 503307e66..1cd412b73 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -315,3 +315,28 @@ public static string Search( // Schema will include descriptions and default value for maxResults } ``` + +### Custom HTTP headers from tool parameters + +When using the Streamable HTTP transport, tool parameters can be mirrored as HTTP headers so that network infrastructure (load balancers, proxies, gateways) can make routing decisions without parsing the JSON-RPC request body. Apply the to a parameter to opt it in: + +```csharp +[McpServerTool, Description("Executes a SQL query in a specific region")] +public static string ExecuteSql( + [McpHeader("Region"), Description("Target datacenter region")] string region, + [Description("The SQL query to execute")] string query) +{ + // Clients will send an additional HTTP header: + // Mcp-Param-Region: +} +``` + +When the tool's schema is generated, the annotated parameter includes an `x-mcp-header` extension property. Clients read this annotation and automatically add the corresponding `Mcp-Param-{Name}` header on outgoing `tools/call` requests. The server validates that the header value matches the value in the JSON-RPC body. + +Rules and constraints: + +- Only primitive parameter types (`string`, numeric types, `bool`) are supported. +- The header name must contain only visible ASCII characters (0x21–0x7E) excluding colon (`:`). +- Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. +- Header names must be case-insensitively unique within the tool's input schema. +- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later). diff --git a/package-lock.json b/package-lock.json index 34d53809d..521815617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.1.13", + "@modelcontextprotocol/conformance": "0.1.16", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,28 +23,38 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.13.tgz", - "integrity": "sha512-J9cC7Dsi7hTn7sxorvxDZ+wC8OlHAkOmVxVm1/IBQ8HEpYJu8r0eAUEw68vTmjBPP5WcU8Jld4RZ3cOFp/Ih1g==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.16.tgz", + "integrity": "sha512-GI7qiN0r39/MH2srVUR3AXaEN0YLCro20lIBbnvc1frBhszenxvUifBuTzxeVQVagILfBzCIcnungUOma8OrgA==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "@octokit/rest": "^22.0.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", "undici": "^7.19.0", "yaml": "^2.8.2", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "bin": { "conformance": "dist/index.js" } }, + "node_modules/@modelcontextprotocol/conformance/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -110,6 +120,175 @@ "mcp-server-memory": "dist/index.js" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.9.tgz", + "integrity": "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request/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/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -156,6 +335,12 @@ } } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "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", @@ -486,6 +671,22 @@ "express": ">= 4.11" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -493,9 +694,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -743,6 +944,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -1229,6 +1436,12 @@ "node": ">=20.18.1" } }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index b05f143aa..dd8dedfe3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.1.13", + "@modelcontextprotocol/conformance": "0.1.16", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs new file mode 100644 index 000000000..2e5ad2841 --- /dev/null +++ b/src/Common/McpHttpHeaders.cs @@ -0,0 +1,78 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Constants for MCP-specific HTTP header names used in the Streamable HTTP transport. +/// +/// +/// Per RFC 9110, HTTP header names are case-insensitive. Clients and servers must +/// use case-insensitive comparisons when processing these headers. +/// +internal static class McpHttpHeaders +{ + /// + /// The minimum protocol version that requires standard MCP request headers. + /// + /// + /// Servers enforce missing Mcp-Method and Mcp-Name headers as errors only when + /// the client's MCP-Protocol-Version header indicates this version or later. + /// Clients using older versions are not required to send these headers. + /// + public static readonly string MinVersionForStandardHeaders = "DRAFT-2026-v1"; + + /// The session identifier header. + public const string SessionId = "Mcp-Session-Id"; + + /// The negotiated protocol version header. + public const string ProtocolVersion = "MCP-Protocol-Version"; + + /// The last event ID for SSE stream resumption. + public const string LastEventId = "Last-Event-ID"; + + /// + /// The JSON-RPC method being invoked (e.g., "tools/call", "resources/read"). + /// + /// + /// Required on all Streamable HTTP POST requests. The value must match the method + /// field in the JSON-RPC request body. + /// + public const string Method = "Mcp-Method"; + + /// + /// The name or URI of the target resource for the request. + /// + /// + /// Required for tools/call, resources/read, and prompts/get requests. + /// For tools/call and prompts/get, the value is taken from params.name. + /// For resources/read, the value is taken from params.uri. + /// + public const string Name = "Mcp-Name"; + + /// + /// Prefix for custom parameter headers (Mcp-Param-{Name}). + /// + /// + /// When a tool's inputSchema includes properties annotated with x-mcp-header, + /// clients mirror those parameter values into HTTP headers using this prefix. + /// + public const string ParamPrefix = "Mcp-Param-"; + + /// + /// Key used in to store the tool + /// definition for the current request, enabling the transport to add custom parameter headers. + /// + internal const string ToolContextKey = "Mcp.Tool"; + + /// + /// Protocol versions that require standard MCP request headers (Mcp-Method, Mcp-Name). + /// + private static readonly HashSet s_versionsWithStandardHeaders = new(StringComparer.Ordinal) + { + MinVersionForStandardHeaders, + }; + + /// + /// Returns if the given protocol version requires standard MCP request headers. + /// + public static bool SupportsStandardHeaders(string? protocolVersion) + => !string.IsNullOrEmpty(protocolVersion) && s_versionsWithStandardHeaders.Contains(protocolVersion!); +} diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 980cd1a40..762091667 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -23,6 +23,7 @@ + diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index ec28eff84..49922b8d9 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using System.Buffers; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Hosting; @@ -8,6 +9,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json.Serialization.Metadata; @@ -23,9 +25,9 @@ internal sealed class StreamableHttpHandler( IServiceProvider applicationServices, ILoggerFactory loggerFactory) { - private const string McpSessionIdHeaderName = "Mcp-Session-Id"; - private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version"; - private const string LastEventIdHeaderName = "Last-Event-ID"; + private const string McpSessionIdHeaderName = McpHttpHeaders.SessionId; + private const string McpProtocolVersionHeaderName = McpHttpHeaders.ProtocolVersion; + private const string LastEventIdHeaderName = McpHttpHeaders.LastEventId; /// /// All protocol versions supported by this implementation. @@ -37,6 +39,7 @@ internal sealed class StreamableHttpHandler( "2025-03-26", "2025-06-18", "2025-11-25", + "DRAFT-2026-v1", ]; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); @@ -79,6 +82,12 @@ await WriteJsonRpcErrorAsync(context, return; } + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out errorMessage)) + { + await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); + return; + } + var session = await GetOrCreateSessionAsync(context, message); if (session is null) { @@ -540,6 +549,289 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, out strin return true; } + /// + /// Validates standard MCP request headers (Mcp-Method, Mcp-Name) and custom parameter headers + /// (Mcp-Param-*) against the JSON-RPC request body. + /// Validation is only performed for protocol versions that include the HTTP Standardization feature. + /// + /// The HTTP context containing the request headers. + /// The JSON-RPC message to validate against. + /// The tool collection to look up tool schemas for parameter header validation. + /// Set to the error message if validation fails; null otherwise. + /// True if validation passes; false otherwise. + internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection? toolCollection, [NotNullWhen(false)] out string? errorMessage) + { + // Only validate for protocol versions that support standard headers. + var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion)) + { + errorMessage = null; + return true; + } + + // Only validate for JSON-RPC requests and notifications, not responses. + if (!(message is JsonRpcRequest || message is JsonRpcNotification)) + { + errorMessage = null; + return true; + } + + // For requests that support standard headers, the Mcp-Method header must be present + // and match the method in the JSON-RPC body. + if (!context.Request.Headers.ContainsKey(McpHttpHeaders.Method)) + { + errorMessage = "Missing required Mcp-Method header."; + return false; + } + + var mcpMethodInHeader = context.Request.Headers[McpHttpHeaders.Method].ToString().Trim(); + var mcpMethodInBody = message switch + { + JsonRpcRequest request => request.Method, + JsonRpcNotification notification => notification.Method, + _ => null, // This case is already ruled out by the earlier check, but we need it to satisfy the compiler. + }; + + if (!string.Equals(mcpMethodInHeader, mcpMethodInBody, StringComparison.Ordinal)) + { + errorMessage = $"Header mismatch: Mcp-Method header value '{mcpMethodInHeader}' does not match body value '{mcpMethodInBody}'."; + return false; + } + + // From here on, only validate resources/read, tools/call, and prompts/get requests + if (mcpMethodInBody is not (RequestMethods.ToolsCall or RequestMethods.ResourcesRead or RequestMethods.PromptsGet)) + { + errorMessage = null; + return true; + } + + // For these requests, the Mcp-Name header must be present and match the name or uri in the JSON-RPC body. + if (!context.Request.Headers.ContainsKey(McpHttpHeaders.Name)) + { + errorMessage = "Missing required Mcp-Name header."; + return false; + } + + var mcpNameInHeader = context.Request.Headers[McpHttpHeaders.Name].ToString().Trim(); + + // Extract the params and name value from the body based on the method, if present. + var bodyParams = message switch + { + JsonRpcRequest request => request.Params, + JsonRpcNotification notification => notification.Params, + _ => null, + }; + var mcpNameInBody = mcpMethodInBody switch + { + RequestMethods.ToolsCall => GetJsonNodeStringProperty(bodyParams, "name"), + RequestMethods.ResourcesRead => GetJsonNodeStringProperty(bodyParams, "uri"), + RequestMethods.PromptsGet => GetJsonNodeStringProperty(bodyParams, "name"), + _ => null, + }; + + // Check that the header value matches the body value if the body value is present. + if (!string.Equals(mcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) + { + errorMessage = $"Header mismatch: Mcp-Name header value '{mcpNameInHeader}' does not match body value '{mcpNameInBody}'."; + return false; + } + + // Validate Mcp-Param-* custom headers against tool schema + if (!ValidateCustomParamHeaders(context, message, toolCollection, out errorMessage)) + { + return false; + } + + errorMessage = null; + return true; + } + + /// + /// Validates that all parameters annotated with x-mcp-header in the tool's input schema + /// have corresponding Mcp-Param-* headers present in the request, and that any present + /// Mcp-Param-* headers have valid encoding. + /// + private static bool ValidateCustomParamHeaders( + HttpContext context, + JsonRpcMessage message, + McpServerPrimitiveCollection? toolCollection, + [NotNullWhen(false)] out string? errorMessage) + { + // Custom param headers are only relevant for tools/call requests + if (message is not JsonRpcRequest { Method: RequestMethods.ToolsCall, Params: { } bodyParams }) + { + errorMessage = null; + return true; + } + + // Look up the tool to check for x-mcp-header annotations in the schema + var toolName = GetJsonNodeStringProperty(bodyParams, "name"); + if (toolName is null || toolCollection is null || !toolCollection.TryGetPrimitive(toolName, out var tool)) + { + errorMessage = null; + return true; + } + + var inputSchema = tool.ProtocolTool.InputSchema; + if (inputSchema.ValueKind != System.Text.Json.JsonValueKind.Object || + !inputSchema.TryGetProperty("properties", out var properties) || + properties.ValueKind != System.Text.Json.JsonValueKind.Object) + { + errorMessage = null; + return true; + } + + // Get the arguments from the body for value comparison + System.Text.Json.Nodes.JsonNode? arguments = null; + if (bodyParams is System.Text.Json.Nodes.JsonObject paramsObj) + { + paramsObj.TryGetPropertyValue("arguments", out arguments); + } + + // Check that every x-mcp-header annotated parameter has a corresponding header, + // that the header value is validly encoded, and that it matches the body value. + foreach (var property in properties.EnumerateObject()) + { + if (!property.Value.TryGetProperty("x-mcp-header", out var headerNameElement)) + { + continue; + } + + var headerName = headerNameElement.GetString(); + if (string.IsNullOrEmpty(headerName)) + { + continue; + } + + var fullHeaderName = $"{McpHttpHeaders.ParamPrefix}{headerName}"; + if (!context.Request.Headers.ContainsKey(fullHeaderName)) + { + // Per the SEP: if the parameter value is null or not provided in + // the arguments, the client MUST omit the header and the server + // MUST NOT expect it. Only reject when a non-null value is present + // in the body but the header is missing. + bool hasNonNullBodyValue = arguments is System.Text.Json.Nodes.JsonObject argsForMissing && + argsForMissing.TryGetPropertyValue(property.Name, out var argForMissing) && + argForMissing is not null && + argForMissing.GetValueKind() != System.Text.Json.JsonValueKind.Null; + + if (hasNonNullBodyValue) + { + errorMessage = $"Missing required {fullHeaderName} header for parameter '{property.Name}' annotated with x-mcp-header."; + return false; + } + + continue; + } + + var actualHeaderValue = context.Request.Headers[fullHeaderName].ToString().Trim(); + + // Validate the raw header value for invalid characters per SEP. + // Servers MUST reject headers containing characters outside the valid HTTP header value range. + if (!IsValidHeaderValue(actualHeaderValue)) + { + errorMessage = $"Header mismatch: {fullHeaderName} header contains invalid characters."; + return false; + } + + var decodedActual = McpHeaderEncoder.DecodeValue(actualHeaderValue); + if (decodedActual is null) + { + errorMessage = $"Header mismatch: {fullHeaderName} header contains invalid Base64 encoding."; + return false; + } + + // Verify the header value matches the argument value in the body + if (arguments is System.Text.Json.Nodes.JsonObject argsObj && + argsObj.TryGetPropertyValue(property.Name, out var argNode) && + argNode is not null) + { + var expectedHeaderValue = McpHeaderEncoder.ConvertToHeaderValue(argNode); + if (expectedHeaderValue is not null) + { + var decodedExpected = McpHeaderEncoder.DecodeValue(expectedHeaderValue); + if (!ValuesMatch(decodedActual, decodedExpected, property.Value)) + { + errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'."; + return false; + } + } + } + } + + errorMessage = null; + return true; + } + + private static string? GetJsonNodeStringProperty(System.Text.Json.Nodes.JsonNode? node, string propertyName) + { + if (node is System.Text.Json.Nodes.JsonObject obj && obj.TryGetPropertyValue(propertyName, out var value)) + { + return value?.GetValue(); + } + + return null; + } + + // Valid HTTP header field-value characters per RFC 9110: horizontal tab (0x09), + // space (0x20), and visible ASCII (0x21-0x7E). + private static readonly SearchValues s_validHeaderValueChars = + SearchValues.Create("\t !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + + /// + /// Validates that a header value contains only characters allowed in HTTP header field values + /// per RFC 9110: visible ASCII (0x21-0x7E), space (0x20), and horizontal tab (0x09). + /// + private static bool IsValidHeaderValue(string value) => + value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0; + + /// + /// Compares two decoded header values, using numeric comparison for number-typed + /// parameters to handle cross-SDK representation differences (e.g., "42" vs "42.0"). + /// + private static bool ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema) + { + if (string.Equals(actual, expected, StringComparison.Ordinal)) + { + return true; + } + + // JSON Schema defines two numeric types: "number" (any numeric value including + // decimals like 3.14) and "integer" (whole numbers only like 42). Both produce + // JsonValueKind.Number in the JSON body and are sent as numeric strings in headers. + // We check for both because different SDKs may serialize them differently — + // e.g., a client might send header "42.0" for an "integer" body value of 42, + // or header "42" for a "number" body value of 42.0. Without handling both types, + // valid cross-SDK requests would be incorrectly rejected. + if (propertySchema.TryGetProperty("type", out var typeElement) && + typeElement.ValueKind == System.Text.Json.JsonValueKind.String && + actual is not null && expected is not null) + { + var schemaType = typeElement.GetString(); + + // For "integer" type, prefer exact long comparison to preserve full precision + // for values beyond double's ~15-17 significant digit limit. + if (schemaType == "integer" && + long.TryParse(actual, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var actualLong) && + long.TryParse(expected, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var expectedLong)) + { + return actualLong == expectedLong; + } + + // For "number" type, or "integer" values in decimal format (e.g., cross-SDK "42.0" vs "42"), + // use double comparison with tolerance. + if (schemaType is "number" or "integer" && + double.TryParse(actual, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var actualNum) && + double.TryParse(expected, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var expectedNum) && + Math.Abs(actualNum - expectedNum) < 1e-9) + { + return true; + } + } + + return false; + } + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) => acceptHeaderValue.MatchesMediaType("application/json"); diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 673f66420..f04c32ffd 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -176,6 +176,8 @@ public async ValueTask> ListToolsAsync( RequestOptions? options = null, CancellationToken cancellationToken = default) { + ToolCacheClearing?.Invoke(); + List? tools = null; ListToolsRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() }; do @@ -184,6 +186,15 @@ public async ValueTask> ListToolsAsync( tools ??= new(toolResults.Tools.Count); foreach (var tool in toolResults.Tools) { + // Validate x-mcp-header annotations per SEP-2243. + // Clients MUST exclude tools with invalid annotations and SHOULD log a warning. + if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason)) + { + ToolRejected?.Invoke(tool, rejectionReason!); + continue; + } + + ToolDiscovered?.Invoke(tool); tools.Add(new(this, tool, options?.JsonSerializerOptions)); } @@ -194,6 +205,21 @@ public async ValueTask> ListToolsAsync( return tools; } + /// + /// Invoked when a tool definition is discovered from a tools/list response. + /// + internal Action? ToolDiscovered; + + /// + /// Invoked when a tool definition is rejected due to invalid x-mcp-header annotations. + /// + internal Action? ToolRejected; + + /// + /// Invoked before enumerating tools to clear any previously cached tool definitions. + /// + internal Action? ToolCacheClearing; + /// /// Retrieves a list of available tools from the server. /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 4205c28e1..0d5803559 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; +using System.Collections.Concurrent; using System.Text.Json; namespace ModelContextProtocol.Client; @@ -22,6 +23,7 @@ internal sealed partial class McpClientImpl : McpClient private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; + private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal); private ServerCapabilities? _serverCapabilities; private Implementation? _serverInfo; @@ -67,6 +69,10 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio incomingMessageFilter: null, outgoingMessageFilter: null, _logger); + + ToolDiscovered = tool => _toolCache[tool.Name] = tool; + ToolRejected = (tool, reason) => LogToolRejected(tool.Name, reason); + ToolCacheClearing = () => _toolCache.Clear(); } private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers) @@ -633,7 +639,22 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) /// public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) - => _sessionHandler.SendRequestAsync(request, cancellationToken); + { + // For tools/call requests, attach the cached tool definition to the message context + // so the transport can add custom Mcp-Param-* headers based on x-mcp-header schema annotations. + if (request.Method == RequestMethods.ToolsCall && + request.Params is System.Text.Json.Nodes.JsonObject paramsObj && + paramsObj.TryGetPropertyValue("name", out var nameNode) && + nameNode?.GetValue() is { } toolName && + _toolCache.TryGetValue(toolName, out var tool)) + { + request.Context ??= new(); + request.Context.Items ??= new Dictionary(); + request.Context.Items[McpHttpHeaders.ToolContextKey] = tool; + } + + return _sessionHandler.SendRequestAsync(request, cancellationToken); + } /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) @@ -686,4 +707,7 @@ public override async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")] private partial void LogClientSessionResumed(string endpointName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")] + private partial void LogToolRejected(string toolName, string reason); + } diff --git a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs new file mode 100644 index 000000000..349168f04 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs @@ -0,0 +1,147 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Client; + +/// +/// Extracts parameter values from tool call arguments and adds them as HTTP headers +/// based on x-mcp-header schema extensions. +/// +internal static class McpHeaderExtractor +{ + private const string XMcpHeaderProperty = "x-mcp-header"; + + /// + /// Adds custom parameter headers to an HTTP request based on a tool's schema extensions. + /// + /// The HTTP request headers to add to. + /// The tool definition containing the input schema with x-mcp-header annotations. + /// The arguments being passed to the tool call. + public static void AddParameterHeaders( + HttpRequestHeaders headers, + Tool tool, + JsonElement? arguments) + { + if (!arguments.HasValue || arguments.Value.ValueKind != JsonValueKind.Object) + { + return; + } + + if (tool.InputSchema.ValueKind != JsonValueKind.Object || + !tool.InputSchema.TryGetProperty("properties", out var properties) || + properties.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var property in properties.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object || + !property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement)) + { + continue; + } + + var headerName = headerNameElement.GetString(); + if (string.IsNullOrEmpty(headerName)) + { + continue; + } + + // Look for the corresponding argument value + if (!arguments.Value.TryGetProperty(property.Name, out var argValue)) + { + continue; + } + + // Null values → omit header per SEP + if (argValue.ValueKind == JsonValueKind.Null) + { + continue; + } + + var headerValue = McpHeaderEncoder.ConvertToHeaderValue(argValue); + if (headerValue is not null) + { + headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue); + } + } + } + + /// + /// Validates a tool's inputSchema for valid x-mcp-header annotations. + /// Returns if the tool is valid; with a reason if it should be rejected. + /// + internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason) + { + rejectionReason = null; + + if (tool.InputSchema.ValueKind != JsonValueKind.Object || + !tool.InputSchema.TryGetProperty("properties", out var properties) || + properties.ValueKind != JsonValueKind.Object) + { + return true; + } + + var headerNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var property in properties.EnumerateObject()) + { + // Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas) + if (property.Value.ValueKind != JsonValueKind.Object || + !property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement)) + { + continue; + } + + // x-mcp-header value must be a string + if (headerNameElement.ValueKind != JsonValueKind.String) + { + rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' is not a string."; + return false; + } + + var headerName = headerNameElement.GetString(); + + // MUST NOT be empty + if (string.IsNullOrEmpty(headerName)) + { + rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' is empty."; + return false; + } + + // MUST contain only ASCII characters (0x21-0x7E) excluding space and colon + foreach (char c in headerName!) + { + if (c < 0x21 || c > 0x7E || c == ':') + { + rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2})."; + return false; + } + } + + // MUST be case-insensitively unique + if (!headerNames.Add(headerName)) + { + rejectionReason = $"Tool '{tool.Name}': duplicate x-mcp-header name '{headerName}' (case-insensitive)."; + return false; + } + + // MUST only be applied to primitive types (string, number, boolean) + if (property.Value.TryGetProperty("type", out var typeElement) && + typeElement.ValueKind == JsonValueKind.String) + { + var typeName = typeElement.GetString(); + if (typeName is not ("string" or "number" or "integer" or "boolean")) + { + rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has non-primitive type '{typeName}'."; + return false; + } + } + } + + return true; + } +} diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index f51e236b4..2cebccb3b 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -4,6 +4,7 @@ using System.Net.Http.Headers; using System.Net.ServerSentEvents; using System.Text.Json; +using System.Text.Json.Nodes; using ModelContextProtocol.Protocol; using System.Threading.Channels; using System.Net; @@ -91,6 +92,8 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion); + AddMcpRequestHeaders(httpRequestMessage.Headers, message); + var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false); // We'll let the caller decide whether to throw or fall back given an unsuccessful response. @@ -431,17 +434,17 @@ internal static void CopyAdditionalHeaders( { if (sessionId is not null) { - headers.Add("Mcp-Session-Id", sessionId); + headers.Add(McpHttpHeaders.SessionId, sessionId); } if (protocolVersion is not null) { - headers.Add("MCP-Protocol-Version", protocolVersion); + headers.Add(McpHttpHeaders.ProtocolVersion, protocolVersion); } if (lastEventId is not null) { - headers.Add("Last-Event-ID", lastEventId); + headers.Add(McpHttpHeaders.LastEventId, lastEventId); } if (additionalHeaders is null) @@ -458,6 +461,78 @@ internal static void CopyAdditionalHeaders( } } + /// + /// Adds standard MCP request headers (Mcp-Method, Mcp-Name) and custom parameter headers + /// (Mcp-Param-{Name}) to an HTTP request based on the JSON-RPC message being sent. + /// + internal static void AddMcpRequestHeaders(HttpRequestHeaders headers, JsonRpcMessage message) + { + string? method = message switch + { + JsonRpcRequest request => request.Method, + JsonRpcNotification notification => notification.Method, + _ => null, + }; + + if (method is null) + { + return; + } + + headers.Add(McpHttpHeaders.Method, method); + + // Add Mcp-Name header for methods that target a specific named resource + string? name = message switch + { + JsonRpcRequest { Method: RequestMethods.ToolsCall or RequestMethods.PromptsGet } request + => GetParamsStringProperty(request.Params, "name"), + JsonRpcRequest { Method: RequestMethods.ResourcesRead } request + => GetParamsStringProperty(request.Params, "uri"), + _ => null, + }; + + if (name is not null) + { + headers.Add(McpHttpHeaders.Name, name); + } + + // Add custom Mcp-Param-{Name} headers for tools/call requests with x-mcp-header annotations + if (method == RequestMethods.ToolsCall && + message is JsonRpcRequest toolsCallRequest && + toolsCallRequest.Context?.Items?.TryGetValue(McpHttpHeaders.ToolContextKey, out var toolObj) == true && + toolObj is Tool tool) + { + var arguments = GetParamsArguments(toolsCallRequest.Params); + McpHeaderExtractor.AddParameterHeaders(headers, tool, arguments); + } + } + + /// + /// Extracts a string property from the JSON-RPC params object. + /// + private static string? GetParamsStringProperty(JsonNode? paramsNode, string propertyName) + { + if (paramsNode is JsonObject obj && obj.TryGetPropertyValue(propertyName, out var value)) + { + return value?.GetValue(); + } + + return null; + } + + /// + /// Extracts the arguments property from a tools/call params object as a JsonElement. + /// + private static JsonElement? GetParamsArguments(JsonNode? paramsNode) + { + if (paramsNode is JsonObject obj && obj.TryGetPropertyValue("arguments", out var argsNode) && argsNode is not null) + { + return JsonSerializer.Deserialize(argsNode, McpJsonUtilities.JsonContext.Default.JsonElement); + } + + return null; + } + /// /// Tracks state across SSE stream connections. /// diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 33cd74a82..38c5f1161 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -5,6 +5,26 @@ namespace ModelContextProtocol; /// public enum McpErrorCode { + /// + /// Indicates that HTTP headers do not match the corresponding values in the request body, + /// or that required headers are missing or malformed. + /// + /// + /// + /// This error is returned when a Streamable HTTP request fails header validation. Validation failures include: + /// + /// + /// A required standard header (Mcp-Method, Mcp-Name) is missing. + /// A header value does not match the corresponding request body value. + /// A Base64-encoded header value cannot be decoded. + /// A header value contains invalid characters. + /// + /// + /// This error code is in the JSON-RPC implementation-defined server error range (-32000 to -32099). + /// + /// + HeaderMismatch = -32001, + /// /// Indicates that the requested resource could not be found. /// diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 24543fd3e..77c18b8be 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -41,6 +41,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "2025-03-26", "2025-06-18", LatestProtocolVersion, + "DRAFT-2026-v1", ]; /// @@ -159,7 +160,7 @@ public McpSessionHandler( /// completes its channel with a , the wrapped /// is unwrapped. Otherwise, a default instance is returned. /// - internal Task CompletionTask => + internal Task CompletionTask => field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion); /// diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index f982d41cd..b6423b0c8 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -26,6 +26,7 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs new file mode 100644 index 000000000..c31e93388 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs @@ -0,0 +1,243 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Protocol; + +/// +/// Encodes and decodes parameter values for use in MCP HTTP headers according to the +/// HTTP Standardization SEP. +/// +/// +/// +/// This encoder handles conversion of parameter values to HTTP header-safe strings, +/// including Base64 encoding for values that cannot be safely transmitted as plain text. +/// +/// +/// Encoding rules: +/// +/// Plain ASCII values (0x20-0x7E): sent as-is +/// Values with leading/trailing whitespace: Base64 encoded with =?base64?{value}?= wrapper +/// Non-ASCII characters: Base64 encoded +/// Control characters: Base64 encoded +/// +/// +/// +public static class McpHeaderEncoder +{ + private const string Base64Prefix = "=?base64?"; + private const string Base64Suffix = "?="; + + /// + /// Encodes a string parameter value for use in an HTTP header. + /// + /// The string value to encode. + /// + /// The encoded header value, or if is . + /// + public static string? EncodeValue(string? value) + { + if (value is null) + { + return null; + } + + if (RequiresBase64Encoding(value)) + { + return EncodeAsBase64(value); + } + + return value; + } + + /// + /// Encodes a boolean parameter value for use in an HTTP header. + /// + /// The boolean value to encode. + /// The encoded header value ("true" or "false"). + public static string EncodeValue(bool value) => value ? "true" : "false"; + + /// + /// Encodes a numeric parameter value for use in an HTTP header. + /// + /// The numeric value to encode. + /// The decimal string representation of the value. + public static string EncodeValue(long value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + /// + /// Encodes a numeric parameter value for use in an HTTP header. + /// + /// The numeric value to encode. + /// The decimal string representation of the value. + public static string EncodeValue(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + /// + /// Encodes a parameter value for use in an HTTP header. + /// + /// The value to encode. Supported types are string, numeric types, and boolean. + /// + /// The encoded header value, or if the value is + /// or is not a supported type (string, numeric, or boolean). + /// + public static string? EncodeValue(object? value) + { + if (value is null) + { + return null; + } + + // Route to typed overloads for known types + if (value is string s) + { + return EncodeValue(s); + } + + if (value is bool b) + { + return EncodeValue(b); + } + + var stringValue = ConvertToString(value); + if (stringValue is null) + { + return null; + } + + return stringValue; + } + + /// + /// Decodes a header value that may be Base64-encoded according to SEP rules. + /// + /// The header value to decode. + /// + /// The decoded string value, or if decoding fails. + /// If the value is not Base64-encoded, returns the original value. + /// + public static string? DecodeValue(string? headerValue) + { + if (headerValue is null || headerValue.Length == 0) + { + return headerValue; + } + + // Check for Base64 wrapper. The spec defines the prefix as lowercase "=?base64?" + // but we match case-insensitively for robustness against non-conforming senders. + if (headerValue.StartsWith(Base64Prefix, StringComparison.OrdinalIgnoreCase) && + headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal)) + { + var base64Content = headerValue.Substring( + Base64Prefix.Length, + headerValue.Length - Base64Prefix.Length - Base64Suffix.Length); + + try + { + var bytes = Convert.FromBase64String(base64Content); + return Encoding.UTF8.GetString(bytes); + } + catch (FormatException) + { + return null; + } + } + + return headerValue; + } + + /// + /// Converts a value to an encoded header value string. + /// + /// The JSON element to convert. + /// The encoded header value, or if the element is not a supported primitive type. + public static string? ConvertToHeaderValue(JsonElement element) + { + object? value = element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + + return EncodeValue(value); + } + + /// + /// Converts a value to an encoded header value string. + /// + /// The JSON node to convert. + /// The encoded header value, or if the node is not a or is not a supported primitive type. + public static string? ConvertToHeaderValue(JsonNode node) + { + if (node is not JsonValue jsonValue) + { + return null; + } + + object? value = jsonValue.GetValueKind() switch + { + JsonValueKind.String => jsonValue.GetValue(), + JsonValueKind.Number => jsonValue.ToJsonString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + + return EncodeValue(value); + } + + private static string? ConvertToString(object value) + { + return value switch + { + string s => s, + bool b => b ? "true" : "false", + byte n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + sbyte n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + short n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + ushort n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + int n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + uint n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + long n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + ulong n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + float n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + double n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + decimal n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => null + }; + } + + private static bool RequiresBase64Encoding(string value) + { + if (value.Length == 0) + { + return false; + } + + // Check for leading/trailing whitespace (space or tab) + if (value[0] is ' ' or '\t' || value[^1] is ' ' or '\t') + { + return true; + } + + foreach (char c in value) + { + // Valid HTTP header field value characters per SEP: visible ASCII (0x21-0x7E) and space (0x20). + // All control characters (0x00-0x1F, 0x7F), including tab, must be Base64-encoded. + if (c < 0x20 || c > 0x7E) + { + return true; + } + } + + return false; + } + + private static string EncodeAsBase64(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var base64 = Convert.ToBase64String(bytes); + return $"{Base64Prefix}{base64}{Base64Suffix}"; + } +} diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 700d9d26d..961344c2c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -124,6 +124,12 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Icons = options?.Icons, }; + // Add x-mcp-header extensions to the input schema based on McpHeaderAttribute on parameters + if (function.UnderlyingMethod is { } method) + { + tool.InputSchema = AddMcpHeaderExtensions(tool.InputSchema, method); + } + if (options is not null) { if (options.Title is not null || @@ -600,4 +606,85 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer IsError = allErrorContent && hasAny }; } + + /// + /// Post-processes the input schema to add x-mcp-header extensions based on + /// on method parameters. + /// + private static JsonElement AddMcpHeaderExtensions(JsonElement inputSchema, MethodInfo method) + { + // Collect parameters with McpHeaderAttribute + var headerParams = new List<(string ParameterName, string HeaderName, ParameterInfo Parameter)>(); + var headerNamesSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var param in method.GetParameters()) + { + var attr = param.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + // Validate primitive type only + var paramType = Nullable.GetUnderlyingType(param.ParameterType) ?? param.ParameterType; + if (!IsPrimitiveHeaderType(paramType)) + { + throw new InvalidOperationException( + $"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a primitive type. " + + "Only string, numeric, and boolean types may be annotated with [McpHeader]."); + } + + // Validate case-insensitive uniqueness + if (!headerNamesSet.Add(attr.Name)) + { + throw new InvalidOperationException( + $"Duplicate x-mcp-header name '{attr.Name}' (case-insensitive) found on method '{method.Name}'. " + + "Header names must be case-insensitively unique within a tool's input schema."); + } + + headerParams.Add((param.Name!, attr.Name, param)); + } + + if (headerParams.Count == 0) + { + return inputSchema; + } + + // Parse the schema to a mutable JsonNode, add extensions, and convert back + var schemaNode = JsonNode.Parse(inputSchema.GetRawText()); + if (schemaNode is not JsonObject schemaObj || + !schemaObj.TryGetPropertyValue("properties", out var propertiesNode) || + propertiesNode is not JsonObject propertiesObj) + { + return inputSchema; + } + + foreach (var (parameterName, headerName, _) in headerParams) + { + if (propertiesObj.TryGetPropertyValue(parameterName, out var propNode) && + propNode is JsonObject propObj) + { + propObj["x-mcp-header"] = headerName; + } + } + + return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); + } + + private static bool IsPrimitiveHeaderType(Type type) + { + return type == typeof(string) || + type == typeof(bool) || + type == typeof(byte) || + type == typeof(sbyte) || + type == typeof(short) || + type == typeof(ushort) || + type == typeof(int) || + type == typeof(uint) || + type == typeof(long) || + type == typeof(ulong) || + type == typeof(float) || + type == typeof(double) || + type == typeof(decimal); + } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs b/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs new file mode 100644 index 000000000..516b73580 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs @@ -0,0 +1,81 @@ +namespace ModelContextProtocol.Server; + +/// +/// Indicates that a tool parameter should be mirrored as an HTTP header in client requests. +/// +/// +/// +/// When applied to a parameter, the SDK will include an x-mcp-header extension property +/// in the parameter's JSON schema. Clients will then mirror this parameter's value into an +/// HTTP header named Mcp-Param-{Name}. +/// +/// +/// Only parameters with primitive types (string, number, boolean) may use this attribute. +/// The header name must contain only ASCII characters (0x21-0x7E, excluding space and colon) +/// and must be case-insensitively unique within the tool's input schema. +/// +/// +/// This enables network infrastructure such as load balancers, proxies, and gateways to make +/// routing decisions based on tool parameter values without parsing the JSON-RPC request body. +/// +/// +/// +/// +/// [McpServerTool] +/// public static string ExecuteSql( +/// [McpHeader("Region")] string region, +/// string query) +/// { +/// // The client will add header: Mcp-Param-Region: {region value} +/// } +/// +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class McpHeaderAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The name portion of the header. The full header name will be Mcp-Param-{name}. + /// Must contain only ASCII characters (0x21-0x7E, excluding space and colon). + /// + /// + /// The name is null, empty, or contains invalid characters. + /// + public McpHeaderAttribute(string name) + { + Throw.IfNullOrWhiteSpace(name); + ValidateHeaderName(name); + Name = name; + } + + /// + /// Gets the name portion of the header. + /// + /// + /// The full header name sent by clients will be Mcp-Param-{Name}. + /// + public string Name { get; } + + /// + /// Validates that a header name contains only valid characters. + /// + /// The header name to validate. + /// The name contains invalid characters. + internal static void ValidateHeaderName(string name) + { + foreach (char c in name) + { + // Valid token characters per RFC 9110: visible ASCII (0x21-0x7E) excluding delimiters. + // Space (0x20) and colon (':') are explicitly prohibited. + if (c < 0x21 || c > 0x7E || c == ':') + { + throw new ArgumentException( + $"Header name contains invalid character '{c}' (0x{(int)c:X2}). " + + "Only ASCII characters (0x21-0x7E) excluding colon are allowed.", + nameof(name)); + } + } + } +} diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 94ae206ab..a30dd3fc3 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -49,9 +49,12 @@ public static void EnsureNpmDependenciesInstalled() var repoRoot = FindRepoRoot(); var nodeModulesPath = Path.Combine(repoRoot, "node_modules"); + var lockFilePath = Path.Combine(repoRoot, "package-lock.json"); - // Use 'npm ci' if node_modules doesn't exist, otherwise assume it's up to date. - if (!Directory.Exists(nodeModulesPath)) + // Run 'npm ci' if node_modules doesn't exist or is outdated + // (package-lock.json is newer than node_modules). + if (!Directory.Exists(nodeModulesPath) || + File.GetLastWriteTimeUtc(lockFilePath) > Directory.GetLastWriteTimeUtc(nodeModulesPath)) { var startInfo = NpmStartInfo("ci", repoRoot); using var process = Process.Start(startInfo) @@ -80,6 +83,13 @@ public static ProcessStartInfo ConformanceTestStartInfo(string arguments) { EnsureNpmDependenciesInstalled(); + // If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner. + var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); + if (!string.IsNullOrEmpty(protocolVersion)) + { + arguments += $" --spec-version {protocolVersion}"; + } + var repoRoot = FindRepoRoot(); var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance"); @@ -157,6 +167,44 @@ public static bool IsNodeInstalled() } } + /// + /// Checks whether the SEP-2243 conformance scenarios are available by reading + /// the conformance package version from the repo's package.json. + /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, + /// http-header-validation, and http-custom-header-server-validation scenarios + /// require a conformance package version that includes SEP-2243 support. + /// + public static bool HasSep2243Scenarios() + { + try + { + var repoRoot = FindRepoRoot(); + var packageJsonPath = Path.Combine(repoRoot, "package.json"); + if (!File.Exists(packageJsonPath)) + { + return false; + } + + var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("dependencies", out var deps) && + deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) + { + var versionStr = versionElement.GetString(); + if (versionStr is not null && Version.TryParse(versionStr, out var version)) + { + // SEP-2243 scenarios are expected in conformance package >= 0.2.0 + return version >= new Version(0, 2, 0); + } + } + + return false; + } + catch + { + return false; + } + } + private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 72d075fe7..7b2be118b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -16,6 +16,7 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); + public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); public ClientConformanceTests(ITestOutputHelper output) { @@ -61,6 +62,21 @@ public async Task RunConformanceTest(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // HTTP Standardization (SEP-2243) + [Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))] + [InlineData("http-standard-headers")] + [InlineData("http-custom-headers")] + [InlineData("http-invalid-tool-headers")] + public async Task RunConformanceTest_Sep2243(string scenario) + { + // Run the conformance test suite + var result = await RunClientConformanceScenario(scenario); + + // Report the results + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario) { // Construct an absolute path to the conformance client executable diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs new file mode 100644 index 000000000..c3232b56a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -0,0 +1,478 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Tests for SEP-2243 HTTP header standardization features: +/// - Custom Mcp-Param-* header validation +/// - Tab/control character encoding +/// - Numeric precision in header values +/// - Empty string header validation +/// - Invalid header character rejection +/// +public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = nameof(HttpHeaderConformanceTests), + Version = "1.0", + }; + }).WithTools(Tools).WithHttpTransport(); + + _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")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + // Create a tool with x-mcp-header annotations in the schema. + // We set InputSchema directly because TransformSchemaNode doesn't provide + // property-level path context for lambda-based tool creation. + private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool()]; + + private static readonly JsonSerializerOptions s_reflectionOptions = new() + { + TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver() + }; + + private static McpServerTool CreateHeaderTestTool() + { + var tool = McpServerTool.Create( + [McpServerTool(Name = "header_test")] + static (string region, int priority, bool verbose, string emptyVal) => + $"region={region},priority={priority},verbose={verbose},empty={emptyVal}", + new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions }); + + using var doc = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" }, + "priority": { "type": "integer", "x-mcp-header": "Priority" }, + "verbose": { "type": "boolean", "x-mcp-header": "Verbose" }, + "emptyVal": { "type": "string", "x-mcp-header": "EmptyVal" } + }, + "required": ["region", "priority", "verbose", "emptyVal"] + } + """); + tool.ProtocolTool.InputSchema = doc.RootElement.Clone(); + + return tool; + } + + #region Server-side validation tests + + [Fact] + public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Per SEP-2243: servers MUST accept extra whitespace around header values + // and compare the trimmed value to the request body. + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); + request.Headers.TryAddWithoutValidation("Mcp-Name", " header_test "); + request.Headers.Add("Mcp-Param-Region", "us-west1"); + request.Headers.Add("Mcp-Param-Priority", "42"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Per SEP-2243: servers MUST accept extra whitespace around header values + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.TryAddWithoutValidation("Mcp-Method", " tools/call "); + request.Headers.TryAddWithoutValidation("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "us-west1"); + request.Headers.Add("Mcp-Param-Priority", "42"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Send a tools/call with an empty string param that has an x-mcp-header. + // The header should be present with an empty value, matching the body's empty string. + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "us-west1"); + request.Headers.Add("Mcp-Param-Priority", "42"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Send a tools/call where the body has a non-empty value but the header is empty + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "us-west1"); + request.Headers.Add("Mcp-Param-Priority", "42"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Encode a value with a newline control character using Base64 + var valueWithNewline = "line1\nline2"; + var encodedValue = McpHeaderEncoder.EncodeValue(valueWithNewline); + + var callJson = CallTool("header_test", $$"""{"region":"{{valueWithNewline.Replace("\n", "\\n")}}","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", encodedValue!); + request.Headers.Add("Mcp-Param-Priority", "42"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_AcceptsLargeIntegerWithFullPrecision() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Use a large integer that would lose precision if converted through double + // 2^53 + 1 = 9007199254740993 (cannot be represented exactly as double) + const long largeInt = 9007199254740993L; + var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{largeInt}},"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "test"); + request.Headers.Add("Mcp-Param-Priority", largeInt.ToString()); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("42", 42)] // "42" header vs 42 body → exact integer match + [InlineData("42.0", 42)] // "42.0" header vs 42 body → numeric equivalence + [InlineData("42", 42.0)] // "42" header vs 42.0 body → numeric equivalence + public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, double bodyValue) + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + var callJson = CallTool("header_test", $$$"""{"region":"test","priority":{{{bodyValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "test"); + request.Headers.Add("Mcp-Param-Priority", headerValue); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Header says "99" but body says priority:42 — must reject even with numeric comparison + var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "test"); + request.Headers.Add("Mcp-Param-Priority", "99"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Server_SkipsHeaderValidation_ForNonDraftVersion() + { + await StartAsync(); + await InitializeWithNonDraftVersionAsync(); + + // With non-draft version, Mcp-Param-* headers are NOT validated even if mismatched + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + // Send the WRONG header value — this should still succeed because version is non-draft + request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "WRONG-VALUE"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers + // instead of properly base64-encoding non-ASCII values. + var handler = new SocketsHttpHandler + { + ConnectCallback = SocketsHttpHandler.ConnectCallback, + RequestHeaderEncodingSelector = (headerName, _) => + headerName.StartsWith("Mcp-", StringComparison.OrdinalIgnoreCase) + ? Encoding.UTF8 + : null + }; + + using var utf8Client = new HttpClient(handler); + ConfigureHttpClient(utf8Client); + utf8Client.DefaultRequestHeaders.Accept.Add(new("application/json")); + utf8Client.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + // Send a tools/call with raw UTF-8 non-ASCII in the Mcp-Name header. + // Kestrel reads header bytes as Latin-1, so the UTF-8 bytes for "café☕" + // will be garbled and won't match the body value, causing rejection. + var callJson = CallTool("café☕", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); + // Raw UTF-8 non-ASCII value in Mcp-Name — server must reject this + request.Headers.TryAddWithoutValidation("Mcp-Name", "café☕"); + request.Headers.TryAddWithoutValidation("Mcp-Param-Region", "us-west1"); + request.Headers.TryAddWithoutValidation("Mcp-Param-Priority", "42"); + request.Headers.TryAddWithoutValidation("Mcp-Param-Verbose", "false"); + request.Headers.TryAddWithoutValidation("Mcp-Param-EmptyVal", ""); + + using var response = await utf8Client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + #endregion + + #region Client-side encoding tests (unit tests for McpHeaderEncoder) + + [Theory] + [InlineData("hello\tworld")] + [InlineData("col1\tcol2\tcol3")] + public void Client_TabInValue_IsBase64Encoded(string value) + { + var encoded = McpHeaderEncoder.EncodeValue(value); + Assert.NotNull(encoded); + Assert.StartsWith("=?base64?", encoded); + Assert.EndsWith("?=", encoded); + + // Verify round-trip + var decoded = McpHeaderEncoder.DecodeValue(encoded); + Assert.Equal(value, decoded); + } + + [Theory] + [InlineData("simple-text", false)] + [InlineData("with space", false)] + [InlineData("Hello, 世界", true)] + [InlineData("line1\nline2", true)] + [InlineData("\ttab-start", true)] + [InlineData("mid\ttab", true)] + [InlineData("control\x01char", true)] + public void Client_EncodeValue_Base64OnlyWhenNeeded(string value, bool expectBase64) + { + var encoded = McpHeaderEncoder.EncodeValue(value); + Assert.NotNull(encoded); + + if (expectBase64) + { + Assert.StartsWith("=?base64?", encoded); + } + else + { + Assert.DoesNotContain("=?base64?", encoded); + } + + // All values must round-trip + var decoded = McpHeaderEncoder.DecodeValue(encoded); + Assert.Equal(value, decoded); + } + + [Fact] + public void Client_EncodeValue_LargeInteger_PreservesFullPrecision() + { + // 2^53 + 1 cannot be represented exactly as a double + var encoded = McpHeaderEncoder.EncodeValue(9007199254740993L); + Assert.Equal("9007199254740993", encoded); + } + + [Fact] + public void Client_EncodeValue_Boolean_EncodesCorrectly() + { + Assert.Equal("true", McpHeaderEncoder.EncodeValue(true)); + Assert.Equal("false", McpHeaderEncoder.EncodeValue(false)); + } + + #endregion + + #region Version gating tests + + [Theory] + [InlineData("DRAFT-2026-v1", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-06-18", false)] + [InlineData("2024-11-05", false)] + [InlineData(null, false)] + [InlineData("", false)] + public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) + { + Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + } + + #endregion + + #region Helpers + + private async Task InitializeWithDraftVersionAsync() + { + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent(InitializeRequestDraft); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "initialize"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + } + + private async Task InitializeWithNonDraftVersionAsync() + { + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + + using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + } + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + + private long _lastRequestId = 1; + + private string CallTool(string toolName, string arguments = "{}") + { + var id = Interlocked.Increment(ref _lastRequestId); + return $$$""" + {"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}} + """; + } + + private static string InitializeRequest => """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + """; + + private static string InitializeRequestDraft => """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + """; + + #endregion +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 384e8fcdd..acdcfa456 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index adb8e4ac4..98cc5971a 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -134,6 +135,30 @@ public async Task RunPendingConformanceTest_ServerSsePolling() $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + [Fact] + public async Task RunConformanceTest_HttpHeaderValidation() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available."); + + var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation"); + + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + + [Fact] + public async Task RunConformanceTest_HttpCustomHeaderServerValidation() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available."); + + var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation"); + + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments); @@ -190,10 +215,40 @@ public async Task RunPendingConformanceTest_ServerSsePolling() process.OutputDataReceived -= outputHandler; process.ErrorDataReceived -= errorHandler; + var stdoutText = outputBuilder.ToString(); + var stderrText = errorBuilder.ToString(); + + // The Node.js conformance runner can crash during cleanup on Windows with a libuv + // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit + // code even though every conformance check passed. When that happens, fall back to + // parsing the "Test Results:" summary in stdout to decide success. + bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); + return ( - Success: process.ExitCode == 0, - Output: outputBuilder.ToString(), - Error: errorBuilder.ToString() + Success: success, + Output: stdoutText, + Error: stderrText ); } + + /// + /// Parses the conformance runner output for a "Test Results:" line such as + /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed + /// and none failed. + /// + private static bool ConformanceOutputIndicatesSuccess(string output) + { + // Match lines like "Passed: 3/3, 0 failed, 0 warnings" + var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); + if (!match.Success) + { + return false; + } + + int passed = int.Parse(match.Groups[1].Value); + int total = int.Parse(match.Groups[2].Value); + int failed = int.Parse(match.Groups[3].Value); + + return passed == total && failed == 0 && total > 0; + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index c8e3f8d7b..517d41e02 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -549,6 +549,229 @@ private static string Echo(string message) return message; } + #region SEP-2243 Client Header Tests + + [Fact] + public async Task ListTools_FiltersToolsWithInvalidHeaderAnnotations() + { + // Start a mock server that returns tools with both valid and invalid x-mcp-header annotations + await StartHeaderToolServer(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // The server returns 3 tools: valid_tool, invalid_space_tool, invalid_duplicate_tool + // The client should filter out tools with invalid x-mcp-header annotations + var toolNames = tools.Select(t => t.Name).ToList(); + Assert.Contains("valid_tool", toolNames); + Assert.DoesNotContain("invalid_space_tool", toolNames); + Assert.DoesNotContain("invalid_duplicate_tool", toolNames); + } + + [Fact] + public async Task Client_SendsCorrectHeaders_EndToEnd() + { + // Start a server that captures request headers for verification + var capturedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + await StartHeaderCapturingServer(capturedHeaders); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var tool = Assert.Single(tools); + Assert.Equal("header_tool", tool.Name); + + // Call the tool — client should send Mcp-Param-* headers automatically + capturedHeaders.Clear(); + await tool.CallAsync(new Dictionary { ["region"] = "us-west-2" }, cancellationToken: TestContext.Current.CancellationToken); + + // Verify the client sent the correct headers + Assert.True(capturedHeaders.ContainsKey("Mcp-Method"), "Expected Mcp-Method header"); + Assert.Equal("tools/call", capturedHeaders["Mcp-Method"]); + Assert.True(capturedHeaders.ContainsKey("Mcp-Name"), "Expected Mcp-Name header"); + Assert.Equal("header_tool", capturedHeaders["Mcp-Name"]); + Assert.True(capturedHeaders.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region header"); + Assert.Equal("us-west-2", capturedHeaders["Mcp-Param-Region"]); + } + + private async Task StartHeaderToolServer() + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + _app = Builder.Build(); + + _app.MapPost("/mcp", (JsonRpcMessage message) => + { + if (message is not JsonRpcRequest request) + { + return Results.Accepted(); + } + + if (request.Method == "initialize") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "header-test-server", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions) + }); + } + + if (request.Method == "tools/list") + { + // Return tools with various x-mcp-header annotations — some valid, some invalid + var toolsJson = JsonSerializer.SerializeToNode(new ListToolsResult + { + Tools = + [ + CreateToolWithSchema("valid_tool", """ + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + } + } + """), + CreateToolWithSchema("invalid_space_tool", """ + { + "type": "object", + "properties": { + "value": { "type": "string", "x-mcp-header": "Invalid Name" } + } + } + """), + CreateToolWithSchema("invalid_duplicate_tool", """ + { + "type": "object", + "properties": { + "a": { "type": "string", "x-mcp-header": "Same" }, + "b": { "type": "string", "x-mcp-header": "Same" } + } + } + """), + ] + }, McpJsonUtilities.DefaultOptions); + + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = toolsJson, + }); + } + + return Results.Accepted(); + }); + + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private async Task StartHeaderCapturingServer(Dictionary capturedHeaders) + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + _app = Builder.Build(); + + _app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) => + { + if (message is not JsonRpcRequest request) + { + return Results.Accepted(); + } + + if (request.Method == "initialize") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions) + }); + } + + if (request.Method == "tools/list") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult + { + Tools = [CreateToolWithSchema("header_tool", """ + { + "type": "object", + "properties": { + "region": { "type": "string", "x-mcp-header": "Region" } + }, + "required": ["region"] + } + """)] + }, McpJsonUtilities.DefaultOptions), + }); + } + + if (request.Method == "tools/call") + { + // Capture all MCP headers for verification + foreach (var header in context.Request.Headers) + { + if (header.Key.StartsWith("Mcp-", StringComparison.OrdinalIgnoreCase)) + { + capturedHeaders[header.Key] = header.Value.ToString(); + } + } + + var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo()); + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new CallToolResult + { + Content = [new TextContentBlock { Text = "ok" }], + }, McpJsonUtilities.DefaultOptions), + }); + } + + return Results.Accepted(); + }); + + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private static Tool CreateToolWithSchema(string name, string schemaJson) + { + using var doc = JsonDocument.Parse(schemaJson); + return new Tool + { + Name = name, + InputSchema = doc.RootElement.Clone(), + }; + } + + #endregion + private sealed class ResumeTestServer { private static readonly Tool ResumeTool = new() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index bbe642ab6..38b1ca696 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -730,6 +730,98 @@ public async Task McpServer_UsedOutOfScope_CanSendNotifications() Assert.Equal(NotificationMethods.ResourceUpdatedNotification, notification.Method); } + #region SEP-2243 Header Validation Tests + + [Fact] + public async Task DraftVersion_RejectsMissingMcpMethodHeader() + { + await StartAsync(); + + // Initialize with draft version to enable header validation + await CallInitializeWithDraftVersionAndValidateAsync(); + + // Send a tools/call request without Mcp-Method header — should be rejected + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + // Deliberately omit Mcp-Method header + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() + { + await StartAsync(); + await CallInitializeWithDraftVersionAndValidateAsync(); + + // Send a tools/call request but set Mcp-Method to wrong value + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftVersion_AcceptsCorrectMcpMethodHeader() + { + await StartAsync(); + await CallInitializeWithDraftVersionAndValidateAsync(); + + // Send a tools/call request with correct Mcp-Method and Mcp-Name headers + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task NonDraftVersion_DoesNotRequireMcpMethodHeader() + { + await StartAsync(); + await CallInitializeAndValidateAsync(); + + // With non-draft version, Mcp-Method header is not required + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); + request.Headers.Add("MCP-Protocol-Version", "2025-03-26"); + // No Mcp-Method header — should still work + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private async Task CallInitializeWithDraftVersionAndValidateAsync() + { + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = JsonContent(InitializeRequestDraft); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "initialize"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + var rpcResponse = await AssertSingleSseResponseAsync(response); + AssertServerInfo(rpcResponse); + + var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); + SetSessionId(sessionId); + } + + private static string InitializeRequestDraft => """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + """; + + #endregion + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index b5e048dd0..7ce848907 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -179,6 +179,136 @@ } break; } + case "http-standard-headers": + { + // List and call tools to test Mcp-Method and Mcp-Name headers + var tools = await mcpClient.ListToolsAsync(); + Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}"); + + var tool = tools.FirstOrDefault(t => t.Name == "test_headers"); + if (tool is not null) + { + Console.WriteLine("Calling tool: test_headers"); + var result = await mcpClient.CallToolAsync(toolName: "test_headers", arguments: new Dictionary()); + success &= !(result.IsError == true); + } + + // List and get prompts to test Mcp-Method and Mcp-Name headers + var prompts = await mcpClient.ListPromptsAsync(); + Console.WriteLine($"Available prompts: {string.Join(", ", prompts.Select(p => p.Name))}"); + + foreach (var prompt in prompts) + { + Console.WriteLine($"Getting prompt: {prompt.Name}"); + try + { + await mcpClient.GetPromptAsync(prompt.Name); + } + catch (Exception ex) + { + Console.WriteLine($"Prompt get error (expected for test): {ex.Message}"); + } + } + + // List and read resources to test Mcp-Name with params.uri + var resources = await mcpClient.ListResourcesAsync(); + Console.WriteLine($"Available resources: {string.Join(", ", resources.Select(r => r.Uri))}"); + + foreach (var resource in resources) + { + Console.WriteLine($"Reading resource: {resource.Uri}"); + try + { + await mcpClient.ReadResourceAsync(resource.Uri); + } + catch (Exception ex) + { + Console.WriteLine($"Resource read error (expected for test): {ex.Message}"); + } + } + break; + } + case "http-custom-headers": + { + // List tools to discover x-mcp-header annotations (populates tool cache) + var tools = await mcpClient.ListToolsAsync(); + Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}"); + + // Parse conformance context for tool calls + if (!string.IsNullOrEmpty(conformanceContext)) + { + using var contextDoc = JsonDocument.Parse(conformanceContext); + + // Support both "toolCalls" (array) and legacy "toolCall" (single object) + var toolCallElements = new List(); + if (contextDoc.RootElement.TryGetProperty("toolCalls", out var toolCallsArray) && + toolCallsArray.ValueKind == JsonValueKind.Array) + { + foreach (var item in toolCallsArray.EnumerateArray()) + { + toolCallElements.Add(item); + } + } + else if (contextDoc.RootElement.TryGetProperty("toolCall", out var toolCallEl)) + { + toolCallElements.Add(toolCallEl); + } + + foreach (var toolCallEl in toolCallElements) + { + var toolName = toolCallEl.TryGetProperty("name", out var nameEl) + ? nameEl.GetString() ?? "test_custom_headers" + : "test_custom_headers"; + + Dictionary toolCallArgs = new(); + if (toolCallEl.TryGetProperty("arguments", out var argsEl)) + { + foreach (var prop in argsEl.EnumerateObject()) + { + object? value = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.TryGetInt64(out var l) ? l : prop.Value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => prop.Value.GetRawText(), + }; + toolCallArgs[prop.Name] = value; + } + } + + Console.WriteLine($"Calling tool: {toolName} with {toolCallArgs.Count} arguments"); + var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: toolCallArgs); + success &= !(result.IsError == true); + } + } + break; + } + case "http-invalid-tool-headers": + { + // List tools — the client should filter out tools with invalid x-mcp-header annotations + var tools = await mcpClient.ListToolsAsync(); + Console.WriteLine($"Available tools after filtering: {string.Join(", ", tools.Select(t => t.Name))}"); + + // Only call valid_tool — invalid tools should have been excluded + var validTool = tools.FirstOrDefault(t => t.Name == "valid_tool"); + if (validTool is not null) + { + Console.WriteLine("Calling valid_tool"); + var result = await mcpClient.CallToolAsync(toolName: "valid_tool", arguments: new Dictionary + { + { "region", "us-east1" } + }); + success &= !(result.IsError == true); + } + else + { + Console.WriteLine("ERROR: valid_tool was not found in the filtered tool list"); + success = false; + } + break; + } default: // No extra processing for other scenarios break; diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs index d6db6f626..bef403404 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs @@ -442,4 +442,13 @@ public static string TestReconnection() // and the client must reconnect to get the result. return "Reconnection test completed successfully"; } + + [McpServerTool(Name = "test_header_tool")] + [Description("A tool with x-mcp-header annotations for conformance testing")] + public static string TestHeaderTool( + [McpHeader("Region"), Description("The deployment region")] string region, + [Description("The query to execute")] string query) + { + return $"Executed in region {region}: {query}"; + } } \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolRejectionTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolRejectionTests.cs new file mode 100644 index 000000000..cd3ddde7f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolRejectionTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientToolRejectionTests : ClientServerTestBase +{ + private const string InvalidToolName = "InvalidHeaderTool"; + private const string ValidToolName = "ValidTool"; + + public McpClientToolRejectionTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // Register a valid tool. + mcpServerBuilder.WithTools([McpServerTool.Create( + (string input) => $"echo {input}", + new() { Name = ValidToolName })]); + + // Register a tool whose InputSchema has an invalid x-mcp-header (colon in header name). + var invalidTool = McpServerTool.Create( + (string region) => $"result for {region}", + new() { Name = InvalidToolName }); + + // Manually inject an invalid x-mcp-header annotation into the schema. + // The header name "Invalid:Header" contains a colon, which is prohibited. + var schemaJson = """ + { + "type": "object", + "properties": { + "region": { + "type": "string", + "x-mcp-header": "Invalid:Header" + } + } + } + """; + invalidTool.ProtocolTool.InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(); + + mcpServerBuilder.WithTools([invalidTool]); + } + + [Fact] + public async Task ListToolsAsync_ExcludesToolWithInvalidXMcpHeader_AndLogsWarning() + { + // Act + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert: the valid tool is returned, the invalid one is excluded. + Assert.Contains(tools, t => t.Name == ValidToolName); + Assert.DoesNotContain(tools, t => t.Name == InvalidToolName); + + // Assert: a warning was logged about the rejected tool. + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.LogLevel == LogLevel.Warning && + log.Message.Contains(InvalidToolName) && + log.Message.Contains("excluded")); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs new file mode 100644 index 000000000..51db214ea --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs @@ -0,0 +1,163 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests.Client; + +public class McpHeaderEncoderTests +{ + [Theory] + [InlineData("us-west1", "us-west1")] + [InlineData("hello-world", "hello-world")] + [InlineData("my_tool_name", "my_tool_name")] + [InlineData("us west 1", "us west 1")] + [InlineData("", "")] + public void EncodeValue_PlainAscii_PassesThrough(string input, string expected) + { + var result = McpHeaderEncoder.EncodeValue(input); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(" us-west1", "=?base64?IHVzLXdlc3Qx?=")] + [InlineData("us-west1 ", "=?base64?dXMtd2VzdDEg?=")] + [InlineData(" us-west1 ", "=?base64?IHVzLXdlc3QxIA==?=")] + [InlineData("\tindented", "=?base64?CWluZGVudGVk?=")] + public void EncodeValue_LeadingTrailingWhitespace_Base64Encodes(string input, string expected) + { + var result = McpHeaderEncoder.EncodeValue(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EncodeValue_NonAsciiCharacters_Base64Encodes() + { + var result = McpHeaderEncoder.EncodeValue("日本語"); + Assert.Equal("=?base64?5pel5pys6Kqe?=", result); + } + + [Fact] + public void EncodeValue_NewlineCharacter_Base64Encodes() + { + var result = McpHeaderEncoder.EncodeValue("line1\nline2"); + Assert.Equal("=?base64?bGluZTEKbGluZTI=?=", result); + } + + [Fact] + public void EncodeValue_CarriageReturnNewline_Base64Encodes() + { + var result = McpHeaderEncoder.EncodeValue("line1\r\nline2"); + Assert.Equal("=?base64?bGluZTENCmxpbmUy?=", result); + } + + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + public void EncodeValue_Boolean_ConvertsToLowercase(bool input, string expected) + { + var result = McpHeaderEncoder.EncodeValue(input); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(42, "42")] + [InlineData(3.14, "3.14")] + [InlineData(0, "0")] + [InlineData(-1, "-1")] + public void EncodeValue_Number_ConvertsToString(object input, string expected) + { + var result = McpHeaderEncoder.EncodeValue(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EncodeValue_Null_ReturnsNull() + { + var result = McpHeaderEncoder.EncodeValue(null); + Assert.Null(result); + } + + [Fact] + public void EncodeValue_UnsupportedType_ReturnsNull() + { + var result = McpHeaderEncoder.EncodeValue(new object()); + Assert.Null(result); + } + + [Theory] + [InlineData("us-west1", "us-west1")] + [InlineData("", "")] + public void DecodeValue_PlainAscii_ReturnsAsIs(string input, string expected) + { + var result = McpHeaderEncoder.DecodeValue(input); + Assert.Equal(expected, result); + } + + [Fact] + public void DecodeValue_Null_ReturnsNull() + { + var result = McpHeaderEncoder.DecodeValue(null); + Assert.Null(result); + } + + [Fact] + public void DecodeValue_ValidBase64_Decodes() + { + var result = McpHeaderEncoder.DecodeValue("=?base64?SGVsbG8=?="); + Assert.Equal("Hello", result); + } + + [Fact] + public void DecodeValue_CaseInsensitivePrefix_Decodes() + { + var result = McpHeaderEncoder.DecodeValue("=?BASE64?SGVsbG8=?="); + Assert.Equal("Hello", result); + } + + [Fact] + public void DecodeValue_InvalidBase64_ReturnsNull() + { + var result = McpHeaderEncoder.DecodeValue("=?base64?SGVs!!!bG8=?="); + Assert.Null(result); + } + + [Fact] + public void DecodeValue_MissingPrefix_ReturnsLiteralValue() + { + var result = McpHeaderEncoder.DecodeValue("SGVsbG8="); + Assert.Equal("SGVsbG8=", result); + } + + [Fact] + public void DecodeValue_MissingSuffix_ReturnsLiteralValue() + { + var result = McpHeaderEncoder.DecodeValue("=?base64?SGVsbG8="); + Assert.Equal("=?base64?SGVsbG8=", result); + } + + [Theory] + [InlineData("us-west1")] + [InlineData("Hello, 世界")] + [InlineData(" padded ")] + [InlineData("line1\nline2")] + [InlineData("\tindented")] + [InlineData("a\tb")] + public void RoundTrip_EncodeDecode_PreservesValue(string original) + { + var encoded = McpHeaderEncoder.EncodeValue(original); + Assert.NotNull(encoded); + + var decoded = McpHeaderEncoder.DecodeValue(encoded); + Assert.Equal(original, decoded); + } + + [Fact] + public void EncodeValue_EmbeddedTab_Base64Encodes() + { + var result = McpHeaderEncoder.EncodeValue("col1\tcol2"); + Assert.StartsWith("=?base64?", result); + Assert.EndsWith("?=", result); + + // Verify round-trip + var decoded = McpHeaderEncoder.DecodeValue(result); + Assert.Equal("col1\tcol2", decoded); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs new file mode 100644 index 000000000..83f9e610f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests.Client; + +public class McpRequestHeadersTests +{ + [Fact] + public void McpHttpHeaders_HasCorrectValues() + { + Assert.Equal("Mcp-Session-Id", McpHttpHeaders.SessionId); + Assert.Equal("MCP-Protocol-Version", McpHttpHeaders.ProtocolVersion); + Assert.Equal("Last-Event-ID", McpHttpHeaders.LastEventId); + Assert.Equal("Mcp-Method", McpHttpHeaders.Method); + Assert.Equal("Mcp-Name", McpHttpHeaders.Name); + Assert.Equal("Mcp-Param-", McpHttpHeaders.ParamPrefix); + } + + [Fact] + public void McpErrorCode_HeaderMismatch_HasCorrectValue() + { + Assert.Equal(-32001, (int)McpErrorCode.HeaderMismatch); + } + + [Theory] + [InlineData("DRAFT-2026-v1", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-06-18", false)] + [InlineData("2024-11-05", false)] + [InlineData(null, false)] + [InlineData("", false)] + public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected) + { + Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + } +} diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 0985f4cd7..7f7de2a41 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -39,6 +39,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs new file mode 100644 index 000000000..694869af9 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs @@ -0,0 +1,59 @@ +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpHeaderAttributeTests +{ + [Theory] + [InlineData("Region")] + [InlineData("TenantId")] + [InlineData("Priority")] + [InlineData("X-Custom")] + public void Constructor_ValidHeaderName_Succeeds(string name) + { + var attr = new McpHeaderAttribute(name); + Assert.Equal(name, attr.Name); + } + + [Fact] + public void Constructor_NameWithSpace_Throws() + { + Assert.Throws(() => new McpHeaderAttribute("My Region")); + } + + [Fact] + public void Constructor_NameWithColon_Throws() + { + Assert.Throws(() => new McpHeaderAttribute("Region:Primary")); + } + + [Fact] + public void Constructor_NullName_Throws() + { + Assert.ThrowsAny(() => new McpHeaderAttribute(null!)); + } + + [Fact] + public void Constructor_EmptyName_Throws() + { + Assert.ThrowsAny(() => new McpHeaderAttribute("")); + } + + [Fact] + public void Constructor_WhitespaceName_Throws() + { + Assert.ThrowsAny(() => new McpHeaderAttribute(" ")); + } + + [Fact] + public void Constructor_NameWithControlCharacter_Throws() + { + Assert.Throws(() => new McpHeaderAttribute("Region\t1")); + } + + [Fact] + public void Constructor_NameWithNonAscii_Throws() + { + Assert.Throws(() => new McpHeaderAttribute("Région")); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 808ba7efe..a283bf18c 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1177,4 +1177,136 @@ private static string SyncTool() [JsonSerializable(typeof(DateTimeOffset?))] [JsonSerializable(typeof(Person))] partial class JsonContext2 : JsonSerializerContext; + + // ===== x-mcp-header tests ===== + + [Fact] + public void Create_WithMcpHeaderAttribute_AddsXMcpHeaderExtension() + { + var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithSingleHeader))!); + var schema = tool.ProtocolTool.InputSchema; + var props = schema.GetProperty("properties"); + var regionProp = props.GetProperty("region"); + Assert.True(regionProp.TryGetProperty("x-mcp-header", out var headerValue)); + Assert.Equal("Region", headerValue.GetString()); + } + + [Fact] + public void Create_WithMultipleMcpHeaderAttributes_AddsAllExtensions() + { + var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithMultipleHeaders))!); + var schema = tool.ProtocolTool.InputSchema; + var props = schema.GetProperty("properties"); + + var regionProp = props.GetProperty("region"); + Assert.True(regionProp.TryGetProperty("x-mcp-header", out var regionHeader)); + Assert.Equal("Region", regionHeader.GetString()); + + var tenantProp = props.GetProperty("tenantId"); + Assert.True(tenantProp.TryGetProperty("x-mcp-header", out var tenantHeader)); + Assert.Equal("TenantId", tenantHeader.GetString()); + } + + [Fact] + public void Create_WithDuplicateHeaderNames_ThrowsInvalidOperationException() + { + Assert.Throws(() => + McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithDuplicateHeaders))!)); + } + + [Fact] + public void Create_WithMcpHeaderOnNonPrimitiveType_ThrowsInvalidOperationException() + { + Assert.Throws(() => + McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNonPrimitiveHeader))!)); + } + + [Fact] + public void Create_WithMcpHeaderOnNumericType_AddsExtension() + { + var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNumericHeader))!); + var schema = tool.ProtocolTool.InputSchema; + var props = schema.GetProperty("properties"); + var countProp = props.GetProperty("count"); + Assert.True(countProp.TryGetProperty("x-mcp-header", out var headerValue)); + Assert.Equal("Count", headerValue.GetString()); + } + + [Fact] + public void Create_WithMcpHeaderOnBooleanType_AddsExtension() + { + var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithBooleanHeader))!); + var schema = tool.ProtocolTool.InputSchema; + var props = schema.GetProperty("properties"); + var flagProp = props.GetProperty("flag"); + Assert.True(flagProp.TryGetProperty("x-mcp-header", out var headerValue)); + Assert.Equal("Flag", headerValue.GetString()); + } + + [Fact] + public void Create_WithMcpHeaderOnNullableType_AddsExtension() + { + var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNullableHeader))!); + var schema = tool.ProtocolTool.InputSchema; + var props = schema.GetProperty("properties"); + var countProp = props.GetProperty("count"); + Assert.True(countProp.TryGetProperty("x-mcp-header", out var headerValue)); + Assert.Equal("Count", headerValue.GetString()); + } + + [Fact] + public void Create_WithoutMcpHeaderAttribute_NoXMcpHeaderExtension() + { + var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithoutHeaders))!); + var schema = tool.ProtocolTool.InputSchema; + var props = schema.GetProperty("properties"); + var regionProp = props.GetProperty("region"); + Assert.False(regionProp.TryGetProperty("x-mcp-header", out _)); + } + + private static class McpHeaderToolType + { + [McpServerTool] + public static string ToolWithSingleHeader( + [McpHeader("Region")] string region, + string query) + => "result"; + + [McpServerTool] + public static string ToolWithMultipleHeaders( + [McpHeader("Region")] string region, + [McpHeader("TenantId")] string tenantId, + string query) + => "result"; + + [McpServerTool] + public static string ToolWithDuplicateHeaders( + [McpHeader("Region")] string region1, + [McpHeader("REGION")] string region2) + => "result"; + + [McpServerTool] + public static string ToolWithNonPrimitiveHeader( + [McpHeader("Data")] object data) + => "result"; + + [McpServerTool] + public static string ToolWithNumericHeader( + [McpHeader("Count")] int count) + => "result"; + + [McpServerTool] + public static string ToolWithBooleanHeader( + [McpHeader("Flag")] bool flag) + => "result"; + + [McpServerTool] + public static string ToolWithNullableHeader( + [McpHeader("Count")] int? count) + => "result"; + + [McpServerTool] + public static string ToolWithoutHeaders(string region, string query) + => "result"; + } } From 74788af04dcc69041b95617d8d638fa3ce5dd36d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 14:47:10 -0700 Subject: [PATCH 02/50] Append `offline_access` to authorization scope when AS advertises it (SEP-2207) (#1479) --- .../Authentication/ClientOAuthProvider.cs | 32 ++++++ .../OAuth/AuthTests.cs | 104 ++++++++++++++++++ .../Program.cs | 14 ++- 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index ecef8e15e..c376f932c 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -492,6 +492,7 @@ private Uri BuildAuthorizationUrl( } var scope = GetScopeParameter(protectedResourceMetadata); + scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); if (!string.IsNullOrEmpty(scope)) { queryParamsDictionary["scope"] = scope!; @@ -726,6 +727,37 @@ private async Task PerformDynamicClientRegistrationAsync( return _configuredScopes; } + /// + /// Augments the scope parameter with offline_access if the authorization server advertises it in + /// scopes_supported and it is not already present. This signals to OIDC-flavored authorization servers + /// that the client desires a refresh token, per SEP-2207. + /// + private static string? AugmentScopeWithOfflineAccess(string? scope, AuthorizationServerMetadata authServerMetadata) + { + const string OfflineAccess = "offline_access"; + + if (authServerMetadata.ScopesSupported?.Contains(OfflineAccess) is not true) + { + return scope; + } + + if (scope is null) + { + return OfflineAccess; + } + + // Check if offline_access is already in the scope string (space-separated tokens). + foreach (var token in scope.Split(' ')) + { + if (token == OfflineAccess) + { + return scope; + } + } + + return scope + " " + OfflineAccess; + } + /// /// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC. /// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index c4979fb10..4f6e0ce94 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1261,4 +1261,108 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task AuthorizationFlow_AppendsOfflineAccess_WhenServerAdvertisesIt() + { + TestOAuthServer.IncludeOfflineAccessInMetadata = true; + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + Assert.Contains("offline_access", requestedScope!.Split(' ')); + } + + [Fact] + public async Task AuthorizationFlow_DoesNotAppendOfflineAccess_WhenServerDoesNotAdvertiseIt() + { + // IncludeOfflineAccessInMetadata defaults to false, so the AS will not advertise offline_access. + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + Assert.DoesNotContain("offline_access", requestedScope!.Split(' ')); + } + + [Fact] + public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPresent() + { + TestOAuthServer.IncludeOfflineAccessInMetadata = true; + + // Configure the PRM to already include offline_access in its scopes. + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "offline_access"]; + }); + + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + var scopeTokens = requestedScope!.Split(' '); + Assert.Single(scopeTokens, t => t == "offline_access"); + } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index e882ecbef..a65c5e4ab 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -78,6 +78,16 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor /// public bool ExpectResource { get; set; } = true; + /// + /// Gets or sets a value indicating whether the authorization server advertises support for + /// offline_access in its scopes_supported metadata. This simulates an OIDC-flavored + /// authorization server that issues refresh tokens when the client requests the offline_access scope. + /// + /// + /// The default value is false. + /// + public bool IncludeOfflineAccessInMetadata { get; set; } + public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); @@ -188,7 +198,9 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) ResponseTypesSupported = ["code"], SubjectTypesSupported = ["public"], IdTokenSigningAlgValuesSupported = ["RS256"], - ScopesSupported = ["openid", "profile", "email", "mcp:tools"], + ScopesSupported = IncludeOfflineAccessInMetadata + ? ["openid", "profile", "email", "mcp:tools", "offline_access"] + : ["openid", "profile", "email", "mcp:tools"], TokenEndpointAuthMethodsSupported = ["client_secret_post"], ClaimsSupported = ["sub", "iss", "name", "email", "aud"], CodeChallengeMethodsSupported = ["S256"], From 712a06b83a656fedf854b8a985f4dcd53d334ea4 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Thu, 21 May 2026 15:05:34 -0700 Subject: [PATCH 03/50] Fix broken links to MCP Tasks specification (#1594) Co-authored-by: Tarek Mahmoud Sayed --- docs/concepts/tasks/tasks.md | 4 ++-- docs/list-of-diagnostics.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index c0b571f77..91f86db1a 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -13,7 +13,7 @@ uid: tasks The Model Context Protocol (MCP) supports [task-based execution] for long-running operations. Tasks enable a "call-now, fetch-later" pattern where clients can initiate operations that may take significant time to complete, then poll for status and retrieve results when ready. -[task-based execution]: https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks +[task-based execution]: https://modelcontextprotocol.io/seps/1686-tasks ## Overview @@ -601,4 +601,4 @@ While this file-based approach demonstrates the pattern, production systems shou - - - -- [MCP Tasks Specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +- [MCP Tasks Specification](https://modelcontextprotocol.io/seps/1686-tasks) diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 515472817..0ea6d746f 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -23,7 +23,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | Diagnostic ID | Description | | :------------ | :---------- | -| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | +| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/seps/1686-tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | | `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). | ## Obsolete APIs From b99d887b463ca313d6dcf967e23fe5638214bb91 Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Tue, 26 May 2026 10:59:03 -0400 Subject: [PATCH 04/50] Deprecate `McpErrorCode.ResourceNotFound` (-32002) and use `McpErrorCode.InvalidParams` (-32602) per SEP-2164 (#1558) --- src/Common/McpHttpHeaders.cs | 8 ++++ src/ModelContextProtocol.Core/McpErrorCode.cs | 14 ++++++- .../McpProtocolException.cs | 2 +- .../Server/McpServerImpl.cs | 8 +++- .../Program.cs | 2 +- .../Program.cs | 2 +- ...cpServerBuilderExtensionsResourcesTests.cs | 4 +- .../McpServerResourceRoutingTests.cs | 38 +++++++++++++++++++ .../McpProtocolExceptionDataTests.cs | 12 +++--- .../Server/McpServerTests.cs | 2 +- 10 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 2e5ad2841..5c631a0e7 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -75,4 +75,12 @@ internal static class McpHttpHeaders /// public static bool SupportsStandardHeaders(string? protocolVersion) => !string.IsNullOrEmpty(protocolVersion) && s_versionsWithStandardHeaders.Contains(protocolVersion!); + + /// + /// Returns if the negotiated protocol version reports unresolvable + /// resource URIs with the standard JSON-RPC (-32602) + /// rather than the legacy (-32002). + /// + internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) + => string.Equals(protocolVersion, MinVersionForStandardHeaders, StringComparison.Ordinal); } diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 38c5f1161..54b9eeebf 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -29,8 +29,17 @@ public enum McpErrorCode /// Indicates that the requested resource could not be found. /// /// - /// This error should be used when a resource URI does not match any available resource on the server. - /// It allows clients to distinguish between missing resources and other types of errors. + /// + /// Legacy error code for unresolvable resource URIs. Newer protocol versions report this + /// condition with the standard JSON-RPC (-32602) instead. The SDK + /// selects between the two automatically based on the negotiated protocol version, so older + /// clients still see (-32002) and newer ones see + /// . + /// + /// + /// New user code throwing directly for unknown-resource conditions + /// should prefer ; the SDK will pass the value through unchanged. + /// /// ResourceNotFound = -32002, @@ -85,6 +94,7 @@ public enum McpErrorCode /// /// Tools: Unknown tool name or invalid protocol-level tool arguments. /// Prompts: Unknown prompt name or missing required protocol-level arguments. + /// Resources: Unknown or unresolvable resource URI. /// Pagination: Invalid or expired cursor values. /// Logging: Invalid log level. /// Tasks: Invalid or nonexistent task ID or invalid cursor. diff --git a/src/ModelContextProtocol.Core/McpProtocolException.cs b/src/ModelContextProtocol.Core/McpProtocolException.cs index 3fbef91c0..7bcc4d0a8 100644 --- a/src/ModelContextProtocol.Core/McpProtocolException.cs +++ b/src/ModelContextProtocol.Core/McpProtocolException.cs @@ -76,7 +76,7 @@ public McpProtocolException(string message, Exception? innerException, McpErrorC /// -32700: Parse error - Invalid JSON received /// -32600: Invalid request - The JSON is not a valid Request object /// -32601: Method not found - The method does not exist or is not available - /// -32602: Invalid params - Malformed request or unknown primitive name (tool/prompt/resource) + /// -32602: Invalid params - Malformed request, unknown primitive name (tool/prompt/resource), or unresolvable resource URI /// -32603: Internal error - Internal JSON-RPC error /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 04d11e016..203856814 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -421,7 +421,13 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && listResourcesHandler ??= (static async (_, __) => new ListResourcesResult()); listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); - readResourceHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", McpErrorCode.ResourceNotFound)); + readResourceHandler ??= (static async (request, _) => + { + var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) + ? McpErrorCode.InvalidParams + : McpErrorCode.ResourceNotFound; + throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode); + }); subscribeHandler ??= (static async (_, __) => new EmptyResult()); unsubscribeHandler ??= (static async (_, __) => new EmptyResult()); var listChanged = resourcesCapability?.ListChanged; diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 9cb963a96..aaa6104cc 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -503,7 +503,7 @@ private static void ConfigureResources(McpServerOptions options) } ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) - ?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.ResourceNotFound); + ?? throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); return new ReadResourceResult { diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index a36a0a6e0..e52a8ff1f 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -307,7 +307,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ?? - throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.ResourceNotFound); + throw new McpProtocolException($"Resource not found: '{request.Params.Uri}'", McpErrorCode.InvalidParams); return new ReadResourceResult { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 4b03cadb2..d6bb239d7 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -109,7 +109,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; } - throw new McpProtocolException($"Resource not found: {request.Params.Uri}", McpErrorCode.ResourceNotFound); + throw new McpProtocolException($"Resource not found: {request.Params.Uri}", McpErrorCode.InvalidParams); }) .WithResources(); } @@ -317,7 +317,7 @@ public async Task Throws_Exception_On_Unknown_Resource() cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Resource not found", e.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, e.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, e.ErrorCode); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 19e0f1bbe..1956887ac 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -22,6 +23,23 @@ private async Task CreateClientWithResourcesAsync(params McpServerRes return await CreateMcpClientForServer(); } + /// + /// Starts the server with the specified resources, pins both the server's and the + /// client's protocol version to , and returns a + /// connected client. Both ends must be pinned because strictly + /// compares the server's negotiated version against the client's requested version and + /// refuses to connect on mismatch. + /// + private async Task CreateClientWithResourcesAndServerVersionAsync( + string protocolVersion, + params McpServerResource[] resources) + { + McpServerBuilder.WithResources(resources); + McpServerBuilder.Services.Configure(o => o.ProtocolVersion = protocolVersion); + StartServer(); + return await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = protocolVersion }); + } + /// /// Asserts that the given URI matches the template and produces the expected text result. /// @@ -56,6 +74,26 @@ private async Task AssertNoMatchAsync( Assert.Equal(McpErrorCode.ResourceNotFound, ex.ErrorCode); } + // Unknown-resource-URI responses are version-gated: older clients keep the legacy + // -32002 (McpErrorCode.ResourceNotFound), and clients on the draft protocol version that + // moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams). + [Theory] + [InlineData("2025-11-25", McpErrorCode.ResourceNotFound)] + [InlineData("DRAFT-2026-v1", McpErrorCode.InvalidParams)] + public async Task ResourceNotFound_ErrorCode_IsVersionGated(string serverProtocolVersion, McpErrorCode expectedCode) + { + var resource = McpServerResource.Create( + options: new() { UriTemplate = "test://known/{id}" }, + method: (string id) => $"ok: {id}"); + + var client = await CreateClientWithResourcesAndServerVersionAsync(serverProtocolVersion, resource); + + var ex = await Assert.ThrowsAsync(async () => + await client.ReadResourceAsync("test://unknown", null, TestContext.Current.CancellationToken)); + + Assert.Equal(expectedCode, ex.ErrorCode); + } + /// /// Verify that when multiple templated resources exist, the correct one is matched based on the URI pattern. /// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821. diff --git a/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs b/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs index 7d50a3044..336f58382 100644 --- a/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs +++ b/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs @@ -33,7 +33,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer switch (toolName) { case "throw_with_serializable_data": - throw new McpProtocolException("Resource not found", McpErrorCode.ResourceNotFound) + throw new McpProtocolException("Resource not found", McpErrorCode.InvalidParams) { Data = { @@ -43,7 +43,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; case "throw_with_nonserializable_data": - throw new McpProtocolException("Resource not found", McpErrorCode.ResourceNotFound) + throw new McpProtocolException("Resource not found", McpErrorCode.InvalidParams) { Data = { @@ -55,7 +55,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; case "throw_with_only_nonserializable_data": - throw new McpProtocolException("Resource not found", McpErrorCode.ResourceNotFound) + throw new McpProtocolException("Resource not found", McpErrorCode.InvalidParams) { Data = { @@ -79,7 +79,7 @@ public async Task Exception_With_Serializable_Data_Propagates_To_Client() await client.CallToolAsync("throw_with_serializable_data", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): Resource not found", exception.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, exception.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); // Verify the data was propagated to the exception // The Data collection should contain the expected keys @@ -113,7 +113,7 @@ public async Task Exception_With_NonSerializable_Data_Still_Propagates_Error_To_ await client.CallToolAsync("throw_with_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): Resource not found", exception.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, exception.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); // Verify that only the serializable data was propagated (non-serializable was filtered out) var hasUri = false; @@ -142,7 +142,7 @@ public async Task Exception_With_Only_NonSerializable_Data_Still_Propagates_Erro await client.CallToolAsync("throw_with_only_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("Request failed (remote): Resource not found", exception.Message); - Assert.Equal(McpErrorCode.ResourceNotFound, exception.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); // When all data is non-serializable, the Data collection should be empty // (the server's ConvertExceptionData returns null when no serializable data exists) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index d9febd721..b8bd57b02 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -1033,7 +1033,7 @@ await transport.SendMessageAsync( public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException_And_Data() { const string ErrorMessage = "Resource not found"; - const McpErrorCode ErrorCode = McpErrorCode.ResourceNotFound; + const McpErrorCode ErrorCode = McpErrorCode.InvalidParams; const string ResourceUri = "file:///path/to/resource"; await using var transport = new TestServerTransport(); From 157f855e511f2cd9e6a3f45e89091853c4d32f9a Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Tue, 26 May 2026 13:31:31 -0700 Subject: [PATCH 05/50] Add RegisterTools API to McpClient for pre-populating tool cache (#1590) Co-authored-by: Tarek Mahmoud Sayed --- docs/concepts/tools/tools.md | 47 ++ .../Client/McpClient.cs | 80 ++++ .../Client/McpClientImpl.cs | 101 +++- .../AddKnownToolsHeaderTests.cs | 364 +++++++++++++++ .../Client/McpClientAddKnownToolsTests.cs | 430 ++++++++++++++++++ 5 files changed, 1016 insertions(+), 6 deletions(-) create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 1cd412b73..4936f4e5d 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -340,3 +340,50 @@ Rules and constraints: - Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. - Header names must be case-insensitively unique within the tool's input schema. - Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later). + +### Pre-loading tool definitions on the client + +By default, `Mcp-Param-*` headers are sent only for tools discovered via . If a client already has tool schema information (for example, from a previous session, hardcoded configuration, or an out-of-band source), it can pre-load those definitions so that headers are sent immediately—without a round trip to the server. + +```csharp +// Build the tool definition with x-mcp-header annotations +var tool = new Tool +{ + Name = "execute_sql", + InputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "region": { + "type": "string", + "x-mcp-header": "Region" + }, + "query": { + "type": "string" + } + } + } + """).RootElement.Clone(), +}; + +// Pre-load the tool definition — no ListToolsAsync needed +client.AddKnownTools([tool]); + +// This call now sends an Mcp-Param-Region header automatically +var result = await client.CallToolAsync("execute_sql", + new Dictionary { ["region"] = "us-west-2", ["query"] = "SELECT 1" }); +``` + +Known tools survive cache clears—they remain in the cache even when the server's tool list is refreshed. If the server returns a tool with the same name, the server's definition overwrites the cached one, but the tool keeps its known status. + +To remove known tools, use for specific tools or to remove all: + +```csharp +// Remove specific known tools by name +client.RemoveKnownTools(["execute_sql"]); + +// Or remove all known tools at once +client.ClearKnownTools(); +``` + +All tools passed to are validated for correct `x-mcp-header` annotations. If any tool in the batch fails validation, an is thrown and no tools are added (all-or-nothing). diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index 406969121..96960db04 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -70,4 +70,84 @@ protected McpClient() /// /// public abstract Task Completion { get; } + + /// + /// Registers one or more tool definitions in the client's tool cache, enabling the transport + /// to send Mcp-Param-* headers for those tools without requiring a prior call. + /// + /// The tool definitions to register. + /// + /// + /// This method allows callers who already have tool schema information (e.g., from a previous session, + /// hardcoded configuration, or an out-of-band source) to provide it directly to the client. Once registered, + /// any + /// call for a registered tool will automatically include Mcp-Param-* HTTP headers based on + /// the tool's x-mcp-header schema annotations, exactly as if the tool had been discovered + /// via . + /// + /// + /// Cache interaction behavior: + /// + /// Registered tools are added to the same internal tool cache used by . + /// Calling after preserves + /// manually registered tools — only server-discovered tools are cleared and repopulated. + /// If the server returns a tool with the same name as a manually registered tool, the server's + /// definition overwrites the registered one in the cache, but the tool retains its known status + /// and will survive subsequent cache clears. This registration is sticky for the lifetime of the + /// ; use or to + /// explicitly drop known tools that are no longer needed. + /// Tools can be registered at any time — before or after , + /// and across multiple calls. + /// Re-registering a tool with the same name overwrites the previous definition in the cache (last write wins). + /// + /// + /// + /// Tools with invalid x-mcp-header annotations cause an to be thrown. + /// No tools are added to the cache if any tool in the batch fails validation (all-or-nothing). + /// + /// + /// is . + /// One or more tools have invalid x-mcp-header annotations. + public virtual void AddKnownTools(IEnumerable tools) + { + Throw.IfNull(tools); + throw new NotSupportedException($"{GetType().Name} does not support adding known tools."); + } + + /// + /// Removes one or more previously registered tool definitions from the client's tool cache by name. + /// + /// The names of the tools to remove. + /// + /// + /// This removes the specified tools from both the known-tools set and the internal tool cache. + /// After removal, those tools will no longer survive + /// cache clears, and Mcp-Param-* headers will no longer be sent for them unless the server + /// re-discovers them via . + /// + /// + /// Removing a tool name that was not previously added via is a no-op. + /// + /// + /// is . + public virtual void RemoveKnownTools(IEnumerable toolNames) + { + Throw.IfNull(toolNames); + throw new NotSupportedException($"{GetType().Name} does not support removing known tools."); + } + + /// + /// Removes all previously registered tool definitions from the client's tool cache. + /// + /// + /// + /// This clears all tools that were added via from both the known-tools + /// set and the internal tool cache. Server-discovered tools that are not also known tools are not affected + /// and will remain in the cache until the next call. + /// + /// + public virtual void ClearKnownTools() + { + throw new NotSupportedException($"{GetType().Name} does not support clearing known tools."); + } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 0d5803559..66410b272 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -24,6 +24,7 @@ internal sealed partial class McpClientImpl : McpClient private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _registeredToolNames = new(StringComparer.Ordinal); private ServerCapabilities? _serverCapabilities; private Implementation? _serverInfo; @@ -72,7 +73,23 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio ToolDiscovered = tool => _toolCache[tool.Name] = tool; ToolRejected = (tool, reason) => LogToolRejected(tool.Name, reason); - ToolCacheClearing = () => _toolCache.Clear(); + ToolCacheClearing = () => + { + if (_registeredToolNames.IsEmpty) + { + _toolCache.Clear(); + return; + } + + // Only remove server-discovered tools; preserve manually registered tools. + foreach (var key in _toolCache.Keys) + { + if (!_registeredToolNames.ContainsKey(key)) + { + _toolCache.TryRemove(key, out _); + } + } + }; } private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers) @@ -637,6 +654,69 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) LogClientSessionResumed(_endpointName); } + /// + public override void AddKnownTools(IEnumerable tools) + { + Throw.IfNull(tools); + + var snapshot = tools as IReadOnlyCollection ?? [.. tools]; + + List? rejections = null; + foreach (var tool in snapshot) + { + Throw.IfNull(tool); + + if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason)) + { + ToolRejected?.Invoke(tool, rejectionReason!); + (rejections ??= []).Add($"{tool.Name}: {rejectionReason}"); + } + } + + if (rejections is { Count: > 0 }) + { + throw new ArgumentException( + "One or more tools failed x-mcp-header validation: " + string.Join("; ", rejections), + nameof(tools)); + } + + foreach (var tool in snapshot) + { + _registeredToolNames[tool.Name] = 0; + _toolCache[tool.Name] = tool; + } + } + + /// + public override void RemoveKnownTools(IEnumerable toolNames) + { + Throw.IfNull(toolNames); + + var snapshot = toolNames as IReadOnlyCollection ?? [.. toolNames]; + + foreach (var name in snapshot) + { + Throw.IfNull(name); + } + + foreach (var name in snapshot) + { + _registeredToolNames.TryRemove(name, out _); + _toolCache.TryRemove(name, out _); + } + } + + /// + public override void ClearKnownTools() + { + foreach (var name in _registeredToolNames.Keys) + { + _toolCache.TryRemove(name, out _); + } + + _registeredToolNames.Clear(); + } + /// public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { @@ -645,12 +725,18 @@ public override Task SendRequestAsync(JsonRpcRequest request, C if (request.Method == RequestMethods.ToolsCall && request.Params is System.Text.Json.Nodes.JsonObject paramsObj && paramsObj.TryGetPropertyValue("name", out var nameNode) && - nameNode?.GetValue() is { } toolName && - _toolCache.TryGetValue(toolName, out var tool)) + nameNode?.GetValue() is { } toolName) { - request.Context ??= new(); - request.Context.Items ??= new Dictionary(); - request.Context.Items[McpHttpHeaders.ToolContextKey] = tool; + if (_toolCache.TryGetValue(toolName, out var tool)) + { + request.Context ??= new(); + request.Context.Items ??= new Dictionary(); + request.Context.Items[McpHttpHeaders.ToolContextKey] = tool; + } + else if (_transport is StreamableHttpClientSessionTransport) + { + LogToolCacheMiss(toolName); + } } return _sessionHandler.SendRequestAsync(request, cancellationToken); @@ -707,6 +793,9 @@ public override async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")] private partial void LogClientSessionResumed(string endpointName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' not found in cache during tools/call. Mcp-Param-* headers will not be sent. Call AddKnownTools or ListToolsAsync to populate the cache.")] + private partial void LogToolCacheMiss(string toolName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")] private partial void LogToolRejected(string toolName, string reason); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs new file mode 100644 index 000000000..7ab98f38e --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -0,0 +1,364 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Tests that allows sending Mcp-Param-* headers +/// without a prior call. +/// +public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + /// + /// Captured headers from tools/call requests, keyed by JSON-RPC request id. + /// + private readonly ConcurrentDictionary> _capturedHeaders = new(); + + private async Task StartAsync() + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + _app = Builder.Build(); + + _app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) => + { + if (message is not JsonRpcRequest request) + { + return Results.Accepted(); + } + + if (request.Method == "initialize") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions) + }); + } + + if (request.Method == "tools/call") + { + // Capture all Mcp-Param-* headers from the incoming HTTP request + var paramHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in context.Request.Headers) + { + if (header.Key.StartsWith("Mcp-Param-", StringComparison.OrdinalIgnoreCase)) + { + paramHeaders[header.Key] = header.Value.ToString(); + } + } + + _capturedHeaders[request.Id.ToString()!] = paramHeaders; + + var parameters = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; + + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new CallToolResult + { + Content = [new TextContentBlock { Text = $"ok" }], + }, McpJsonUtilities.DefaultOptions), + }); + } + + if (request.Method == "tools/list") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult + { + Tools = [], + }, McpJsonUtilities.DefaultOptions), + }); + } + + return Results.Accepted(); + }); + + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private static Tool CreateToolWithHeaders() + { + var schemaJson = """ + { + "type": "object", + "properties": { + "region": { + "type": "string", + "x-mcp-header": "Region" + }, + "priority": { + "type": "integer", + "x-mcp-header": "Priority" + } + }, + "required": ["region", "priority"] + } + """; + + return new Tool + { + Name = "my_tool", + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Fact] + public async Task AddKnownTools_ThenCallTool_SendsMcpParamHeaders_WithoutListToolsAsync() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register the tool WITHOUT calling ListToolsAsync first — this is the core scenario from issue #1577 + client.AddKnownTools([CreateToolWithHeaders()]); + + // Call the tool + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "us-west-2", ["priority"] = 42 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify that Mcp-Param-* headers were captured by the server + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.True(headers.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region header to be sent"); + Assert.Equal("us-west-2", headers["Mcp-Param-Region"]); + Assert.True(headers.ContainsKey("Mcp-Param-Priority"), "Expected Mcp-Param-Priority header to be sent"); + Assert.Equal("42", headers["Mcp-Param-Priority"]); + } + + [Fact] + public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Call the tool without AddKnownTools or ListToolsAsync — no Mcp-Param-* headers should be sent + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "us-west-2", ["priority"] = 42 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify that NO Mcp-Param-* headers were sent + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.Empty(headers); + + // Verify that a cache miss warning IS logged for HTTP transport + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.LogLevel == Microsoft.Extensions.Logging.LogLevel.Warning && + log.Message.Contains("not found in cache during tools/call")); + } + + [Fact] + public async Task AddKnownTools_SurvivesListToolsAsync_HeadersStillSent() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register the tool first + client.AddKnownTools([CreateToolWithHeaders()]); + + // Call ListToolsAsync — server returns empty list, but registered tool should survive + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Call the registered tool — Mcp-Param-* headers should still be sent + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "eu-central-1", ["priority"] = 99 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify headers were sent + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.True(headers.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region header after ListToolsAsync"); + Assert.Equal("eu-central-1", headers["Mcp-Param-Region"]); + Assert.True(headers.ContainsKey("Mcp-Param-Priority"), "Expected Mcp-Param-Priority header after ListToolsAsync"); + Assert.Equal("99", headers["Mcp-Param-Priority"]); + } + + [Fact] + public async Task RemoveKnownTools_ThenCallTool_NoMcpParamHeaders() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register then remove — headers should no longer be sent + client.AddKnownTools([CreateToolWithHeaders()]); + client.RemoveKnownTools(["my_tool"]); + + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "us-east-1", ["priority"] = 1 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify no Mcp-Param-* headers were sent after removal + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.Empty(headers); + } + + private static Tool CreateToolWithSingleHeader(string toolName, string headerName) + { + var schemaJson = $$""" + { + "type": "object", + "properties": { + "value": { + "type": "string", + "x-mcp-header": "{{headerName}}" + } + }, + "required": ["value"] + } + """; + + return new Tool + { + Name = toolName, + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Fact] + public async Task AddKnownTools_ServerReturnsEmptyList_RegisteredToolStillUsedForHeaders() + { + // Staleness test: register foo → server returns [] → ListToolsAsync → call foo → headers still sent + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register tool, then ListToolsAsync returns empty list from server + client.AddKnownTools([CreateToolWithHeaders()]); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Call the registered tool — headers should still be sent (sticky registration) + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "ap-southeast-1", ["priority"] = 5 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.True(headers.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region after server returned empty list"); + Assert.Equal("ap-southeast-1", headers["Mcp-Param-Region"]); + Assert.True(headers.ContainsKey("Mcp-Param-Priority"), "Expected Mcp-Param-Priority after server returned empty list"); + Assert.Equal("5", headers["Mcp-Param-Priority"]); + } + + [Fact] + public async Task AddKnownTools_ReRegisterOverwrite_LastWriteWinsHeaders() + { + // Last-write-wins: register foo with schema A → register foo with schema B → call → headers reflect schema B + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register with header "SchemaA", then overwrite with "SchemaB" + client.AddKnownTools([CreateToolWithSingleHeader("my_tool", "SchemaA")]); + client.AddKnownTools([CreateToolWithSingleHeader("my_tool", "SchemaB")]); + + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["value"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + // SchemaA header should NOT be present + Assert.False(headers.ContainsKey("Mcp-Param-SchemaA"), "SchemaA header should have been overwritten"); + // SchemaB header SHOULD be present (last write wins) + Assert.True(headers.ContainsKey("Mcp-Param-SchemaB"), "Expected Mcp-Param-SchemaB from overwritten registration"); + Assert.Equal("test", headers["Mcp-Param-SchemaB"]); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs new file mode 100644 index 000000000..16d9c3397 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs @@ -0,0 +1,430 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientAddKnownToolsTests : ClientServerTestBase +{ + private const string ServerToolName = "ServerTool"; + private const string ServerToolName2 = "ServerTool2"; + + public McpClientAddKnownToolsTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([ + McpServerTool.Create( + (string input) => $"echo {input}", + new() { Name = ServerToolName }), + McpServerTool.Create( + (string input) => $"echo2 {input}", + new() { Name = ServerToolName2 }), + ]); + } + + private static Tool CreateTool(string name, string? headerAnnotation = null) + { + string schemaJson = headerAnnotation is not null + ? $$""" + { + "type": "object", + "properties": { + "param1": { + "type": "string", + "x-mcp-header": "{{headerAnnotation}}" + } + } + } + """ + : """ + { + "type": "object", + "properties": { + "param1": { + "type": "string" + } + } + } + """; + + return new Tool + { + Name = name, + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + private static Tool CreateInvalidTool(string name) + { + // Colon in header name is invalid + var schemaJson = """ + { + "type": "object", + "properties": { + "param1": { + "type": "string", + "x-mcp-header": "Invalid:Header" + } + } + } + """; + + return new Tool + { + Name = name, + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Fact] + public async Task AddKnownTools_ThenListToolsAsync_ServerToolsStillReturned() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var registeredTool = CreateTool("MyRegisteredTool", "X-Custom"); + + // Act — register without calling ListToolsAsync first, then list + client.AddKnownTools([registeredTool]); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — ListToolsAsync returns server tools (registered tools stay in cache for header generation + // but are not returned by ListToolsAsync which only returns server-reported tools) + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + Assert.Equal(2, tools.Count); + } + + [Fact] + public async Task AddKnownTools_ThenMultipleListToolsAsync_ServerToolsAlwaysRepopulated() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("MyRegisteredTool", "X-Custom")]); + + // Act — ListToolsAsync clears non-registered tools and repopulates from server + var tools1 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools2 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server tools repopulated correctly after each clear + Assert.Contains(tools1, t => t.Name == ServerToolName); + Assert.Contains(tools1, t => t.Name == ServerToolName2); + Assert.Contains(tools2, t => t.Name == ServerToolName); + Assert.Contains(tools2, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task ListToolsAsync_ThenRegisterTool_ServerToolsStillRepopulated() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + + // Act — list first, then register, then list again + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, tools.Count); + + client.AddKnownTools([CreateTool("MyRegisteredTool", "X-Custom")]); + + var tools2 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server tools still repopulated after registering + Assert.Contains(tools2, t => t.Name == ServerToolName); + Assert.Contains(tools2, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task RegisterTool_ListToolsAsync_RegisterTool_ServerToolsIntact() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + + // Act — register, list, register again + client.AddKnownTools([CreateTool("FirstRegistered", "X-First")]); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + client.AddKnownTools([CreateTool("SecondRegistered", "X-Second")]); + + // Another ListToolsAsync — server tools should still be repopulated + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_WithSameNameAsServerTool_ServerDefinitionReturned() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var registeredTool = CreateTool(ServerToolName, "X-Override"); + + // Act — register a tool with the same name as a server tool, then list + client.AddKnownTools([registeredTool]); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server's definition is returned by ListToolsAsync + Assert.Contains(tools, t => t.Name == ServerToolName); + + // After another ListToolsAsync, the tool is still present (pinned as registered + server tool) + var tools2 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools2, t => t.Name == ServerToolName); + } + + [Fact] + public async Task AddKnownTools_WithInvalidSchema_ThrowsArgumentException() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var invalidTool = CreateInvalidTool("BadTool"); + var validTool = CreateTool("GoodTool", "X-Good"); + + // Act & Assert — all-or-nothing: neither tool should be added + var ex = Assert.Throws(() => client.AddKnownTools([invalidTool, validTool])); + Assert.Contains("BadTool", ex.Message); + + // Server tools still work normally + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_DuplicateRegistration_DoesNotBreakCache() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var tool1 = CreateTool("MyTool", "X-First"); + var tool2 = CreateTool("MyTool", "X-Second"); + + // Act — register same name twice; second should overwrite + client.AddKnownTools([tool1]); + client.AddKnownTools([tool2]); + + // Assert — cache clearing still works; server tools repopulated + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_NullArgument_ThrowsArgumentNullException() + { + await using var client = await CreateMcpClientForServer(); + Assert.Throws(() => client.AddKnownTools(null!)); + } + + [Fact] + public async Task MultipleListToolsAsync_WithRegisteredTools_ServerToolsAlwaysRepopulated() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("PinnedTool", "X-Pinned")]); + + // Act — call ListToolsAsync multiple times + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server tools repopulated each time despite registered tool in cache + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_WithNoHeaderAnnotation_StillAccepted() + { + // Arrange — a tool without x-mcp-header is still valid and should be cached + await using var client = await CreateMcpClientForServer(); + var tool = CreateTool("PlainTool"); + + // Act — register a tool with no x-mcp-header; should not throw + client.AddKnownTools([tool]); + + // Assert — server tools still repopulated after cache clears + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_ThenCallTool_RegisteredToolUsedForCacheLookup() + { + // Arrange — register a tool with the same name as a server tool so CallToolAsync succeeds server-side + await using var client = await CreateMcpClientForServer(); + var tool = CreateTool(ServerToolName, "X-Custom"); + + // Act — register without ListToolsAsync, then call the tool directly + client.AddKnownTools([tool]); + + // The tool is in the cache, so SendRequestAsync will find it for header attachment. + // The server has a tool with this name, so the call succeeds. + var result = await client.CallToolAsync( + ServerToolName, + new Dictionary { ["input"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert — call succeeded (tool was found in cache, request was processed by server) + Assert.NotNull(result); + Assert.Contains(result.Content, c => c is TextContentBlock text && text.Text == "echo test"); + } + + [Fact] + public async Task RemoveKnownTools_RemovedToolNoLongerSurvivesListToolsAsync() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("MyTool", "X-Custom")]); + + // Act — remove the known tool + client.RemoveKnownTools(["MyTool"]); + + // Assert — server tools still repopulated, removed tool doesn't interfere + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task RemoveKnownTools_NonExistentName_IsNoOp() + { + await using var client = await CreateMcpClientForServer(); + + // Should not throw + client.RemoveKnownTools(["NonExistentTool"]); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + } + + [Fact] + public async Task RemoveKnownTools_NullArgument_ThrowsArgumentNullException() + { + await using var client = await CreateMcpClientForServer(); + Assert.Throws(() => client.RemoveKnownTools(null!)); + } + + [Fact] + public async Task RemoveKnownTools_PartialRemove_OtherToolsSurvive() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([ + CreateTool("ToolA", "X-A"), + CreateTool("ToolB", "X-B"), + ]); + + // Act — remove only ToolA + client.RemoveKnownTools(["ToolA"]); + + // Assert — ToolB still survives cache clears, server tools repopulated + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task ClearKnownTools_RemovesAllKnownToolsFromCache() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([ + CreateTool("ToolA", "X-A"), + CreateTool("ToolB", "X-B"), + ]); + + // Act + client.ClearKnownTools(); + + // Assert — server tools still work after clearing known tools + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task ClearKnownTools_WhenEmpty_IsNoOp() + { + await using var client = await CreateMcpClientForServer(); + + // Should not throw when nothing is registered + client.ClearKnownTools(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + } + + [Fact] + public async Task ClearKnownTools_ThenAddKnownTools_WorksCorrectly() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("ToolA", "X-A")]); + + // Act — clear then add new tools + client.ClearKnownTools(); + client.AddKnownTools([CreateTool("ToolC", "X-C")]); + + // Assert — server tools repopulated, new tool doesn't interfere + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + } + + [Fact] + public async Task AddKnownTools_PartialFailure_NothingRegistered() + { + // Arrange — [valid, invalid, valid] should register nothing (all-or-nothing) + await using var client = await CreateMcpClientForServer(); + var valid1 = CreateTool("Valid1", "X-One"); + var invalid = CreateInvalidTool("BadTool"); + var valid2 = CreateTool("Valid2", "X-Two"); + + // Act & Assert — throws, no tools registered + Assert.Throws(() => client.AddKnownTools([valid1, invalid, valid2])); + + // Server tools still work; none of the valid tools were cached + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Equal(2, tools.Count); + } + + [Fact] + public async Task AddKnownTools_NullElementInMiddle_NothingRegistered() + { + // Arrange — null element at index 1; elements before it should not be cached + await using var client = await CreateMcpClientForServer(); + var valid = CreateTool("Valid", "X-Valid"); + + // Act & Assert — throws ArgumentNullException on null element, nothing cached + Assert.Throws(() => client.AddKnownTools([valid, null!, CreateTool("Other", "X-Other")])); + + // Server tools still work; valid tool was NOT cached due to atomicity + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, tools.Count); + } + + [Fact] + public async Task CallToolWithoutCache_PipeTransport_DoesNotLogWarning() + { + // Arrange — pipe transport should NOT log a cache miss warning + await using var client = await CreateMcpClientForServer(); + + // Act — call a server tool without populating cache via ListToolsAsync + var result = await client.CallToolAsync( + ServerToolName, + new Dictionary { ["input"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert — call succeeds and no cache miss warning is logged (pipe transport, not HTTP) + Assert.NotNull(result); + Assert.DoesNotContain(MockLoggerProvider.LogMessages, log => + log.LogLevel == Microsoft.Extensions.Logging.LogLevel.Warning && + log.Message.Contains("not found in cache during tools/call")); + } +} From fd1ac08925e3b8215a0d82782fa526d8e5669ecb Mon Sep 17 00:00:00 2001 From: Manuel Naujoks Date: Wed, 27 May 2026 00:23:52 +0200 Subject: [PATCH 06/50] Add ScopeSelectorDelegate to enhance OAuth options for scope filtering (#1596) --- .../Authentication/ClientOAuthOptions.cs | 29 ++- .../Authentication/ClientOAuthProvider.cs | 21 +- .../Authentication/ScopeSelectorDelegate.cs | 37 ++++ .../OAuth/AuthTests.cs | 192 ++++++++++++++++++ .../Program.cs | 5 + 5 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 483e3643e..0bfb19a59 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -35,19 +35,40 @@ public sealed class ClientOAuthOptions public Uri? ClientMetadataDocumentUri { get; set; } /// - /// Gets or sets the OAuth scopes to request. + /// Gets or sets the OAuth scopes to request as a fallback. /// /// /// - /// When specified, these scopes will be used instead of the scopes advertised by the protected resource. - /// If not specified, the provider will use the scopes from the protected resource metadata. + /// These scopes are used only when the server does not provide scope information via the + /// WWW-Authenticate header or Protected Resource Metadata (scopes_supported). This + /// matches the MCP scope selection strategy: WWW-Authenticate scope → PRM scopes_supported → + /// client-configured scopes → omit scope parameter. /// /// - /// Common OAuth scopes include "openid", "profile", and "email". + /// To filter or customize scopes when the server does provide scope information, + /// use instead. /// /// public IEnumerable? Scopes { get; set; } + /// + /// Gets or sets a delegate that selects or filters the OAuth scopes to request. + /// + /// + /// + /// When set, this delegate is called after the MCP scope selection strategy has determined the + /// candidate scopes (WWW-Authenticate → PRM scopes_supported fallback) + /// and after offline_access has been automatically appended when advertised by the + /// authorization server. The return value replaces the candidate scopes in the authorization request. + /// + /// + /// Use this to request only a subset of the scopes offered by the server, or to append a custom + /// scope that is not advertised in the server metadata. Return or an empty + /// enumerable to omit the scope parameter entirely. + /// + /// + public ScopeSelectorDelegate? ScopeSelector { get; set; } + /// /// Gets or sets the authorization redirect delegate for handling the OAuth authorization flow. /// diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index c376f932c..662e436eb 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -28,6 +28,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly Uri _serverUrl; private readonly Uri _redirectUri; private readonly string? _configuredScopes; + private readonly ScopeSelectorDelegate? _scopeSelector; private readonly IDictionary _additionalAuthorizationParameters; private readonly Func, Uri?> _authServerSelector; private readonly AuthorizationRedirectDelegate _authorizationRedirectDelegate; @@ -76,6 +77,7 @@ public ClientOAuthProvider( _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); + _scopeSelector = options.ScopeSelector; _additionalAuthorizationParameters = options.AdditionalAuthorizationParameters; _clientMetadataDocumentUri = options.ClientMetadataDocumentUri; @@ -491,8 +493,7 @@ private Uri BuildAuthorizationUrl( queryParamsDictionary["resource"] = resourceUri; } - var scope = GetScopeParameter(protectedResourceMetadata); - scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); + var scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata); if (!string.IsNullOrEmpty(scope)) { queryParamsDictionary["scope"] = scope!; @@ -654,7 +655,7 @@ private async Task PerformDynamicClientRegistrationAsync( TokenEndpointAuthMethod = "client_secret_post", ClientName = _dcrClientName, ClientUri = _dcrClientUri?.ToString(), - Scope = GetScopeParameter(protectedResourceMetadata), + Scope = ComputeEffectiveScope(protectedResourceMetadata, authServerMetadata), }; var requestBytes = JsonSerializer.SerializeToUtf8Bytes(registrationRequest, McpJsonUtilities.JsonContext.Default.DynamicClientRegistrationRequest); @@ -713,6 +714,20 @@ private async Task PerformDynamicClientRegistrationAsync( private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) => protectedResourceMetadata.Resource; + private string? ComputeEffectiveScope( + ProtectedResourceMetadata protectedResourceMetadata, + AuthorizationServerMetadata authServerMetadata) + { + var scope = GetScopeParameter(protectedResourceMetadata); + scope = AugmentScopeWithOfflineAccess(scope, authServerMetadata); + if (_scopeSelector is not null) + { + var selected = _scopeSelector(scope?.Split(' ')); + scope = selected is not null ? string.Join(" ", selected) : null; + } + return scope; + } + private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata) { if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope)) diff --git a/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs new file mode 100644 index 000000000..5fe688ede --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/ScopeSelectorDelegate.cs @@ -0,0 +1,37 @@ + +namespace ModelContextProtocol.Authentication; + +/// +/// Represents a method that selects or filters the OAuth scopes to request during authorization. +/// +/// +/// The scopes determined by the MCP scope selection strategy (WWW-Authenticate header scope → +/// scopes_supported from Protected Resource Metadata → +/// fallback), with offline_access appended when advertised by the authorization server. May be +/// if the server provided no scope information and no fallback scopes are configured. +/// +/// +/// The scopes to include in the authorization and Dynamic Client Registration requests. Return +/// or an empty enumerable to omit the scope parameter entirely. +/// +/// +/// +/// Use this delegate to filter or customize the proposed scopes before the authorization request is made. +/// Common scenarios include: +/// +/// +/// Requesting only a subset of the scopes offered by the server. +/// Appending a custom scope not advertised in the server metadata. +/// +/// +/// The MCP specification defines the following scope selection priority (highest to lowest): +/// WWW-Authenticate header scope → PRM scopes_supported → omit scope parameter. The +/// parameter already reflects this priority. The delegate runs after +/// offline_access has been auto-appended, so it can also remove that scope if desired. +/// +/// +/// The resolved scope is applied consistently to both the authorization URL and the Dynamic Client +/// Registration (DCR) request, so the registered client scope matches what is actually requested. +/// +/// +public delegate IEnumerable? ScopeSelectorDelegate(IReadOnlyCollection? scope); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 4f6e0ce94..1ec6fddc6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1365,4 +1365,196 @@ public async Task AuthorizationFlow_DoesNotDuplicateOfflineAccess_WhenAlreadyPre var scopeTokens = requestedScope!.Split(' '); Assert.Single(scopeTokens, t => t == "offline_access"); } + + [Fact] + public async Task AuthorizationFlow_ScopeSelector_CanFilterServerProposedScopes() + { + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"]; + }); + + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("mcp:tools", requestedScope); + } + + [Fact] + public async Task AuthorizationFlow_ScopeSelector_CanAddCustomScope() + { + await using var app = await StartMcpServerAsync(); + + string? requestedScope = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScope = query["scope"].ToString(); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + ScopeSelector = scopes => scopes?.Append("custom:scope") ?? ["custom:scope"], + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(requestedScope); + Assert.Contains("custom:scope", requestedScope!.Split(' ')); + } + + [Fact] + public async Task AuthorizationFlow_ScopeSelector_ReceivesNull_WhenServerProvidesNoScopes() + { + // No ScopesSupported on PRM, no Scopes fallback on client, no offline_access on AS (default). + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata!.ScopesSupported = []; + }); + + await using var app = await StartMcpServerAsync(); + + IEnumerable? capturedInput = ["sentinel"]; + + 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"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + ScopeSelector = scopes => + { + capturedInput = scopes; + return scopes; + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Null(capturedInput); + } + + [Fact] + public async Task AuthorizationFlow_ScopeSelector_ReturningNull_OmitsScopeParameter() + { + await using var app = await StartMcpServerAsync(); + + bool? scopePresent = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + ScopeSelector = _ => null, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.False(scopePresent); + } + + [Fact] + public async Task AuthorizationFlow_ScopeSelector_ReturningEmpty_OmitsScopeParameter() + { + await using var app = await StartMcpServerAsync(); + + bool? scopePresent = null; + + 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"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + scopePresent = QueryHelpers.ParseQuery(uri.Query).ContainsKey("scope"); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + ScopeSelector = _ => [], + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.False(scopePresent); + } + + [Fact] + public async Task DynamicClientRegistration_ScopeSelector_AppliesToDcrScope() + { + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata!.ScopesSupported = ["mcp:tools", "files:read"]; + }); + + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new ClientOAuthOptions() + { + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + DynamicClientRegistration = new() { ClientName = "Test MCP Client" }, + ScopeSelector = scopes => scopes?.Where(s => s == "mcp:tools"), + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("mcp:tools", TestOAuthServer.LastRegistrationScope); + } } diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index a65c5e4ab..68600f81d 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -91,6 +91,9 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor public HashSet DisabledMetadataPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection MetadataRequests => _metadataRequests.ToArray(); + /// Gets the scope field from the most recent Dynamic Client Registration request. + public string? LastRegistrationScope { get; private set; } + /// /// Entry point for the application. /// @@ -513,6 +516,8 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) }); } + LastRegistrationScope = registrationRequest.Scope; + // Validate redirect URIs are provided if (registrationRequest.RedirectUris.Count == 0) { From a87518cf44ec0f77327d4020bdcefeff23c134b7 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 12:50:25 -0700 Subject: [PATCH 07/50] Validate user on Streamable HTTP session DELETE (#1604) --- .github/workflows/ci-code-coverage.yml | 2 +- .../StreamableHttpHandler.cs | 15 +++- .../MapMcpStreamableHttpTests.cs | 73 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml index e12d00f68..485280167 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@5.5.5 + uses: danielpalme/ReportGenerator-GitHub-Action@7ae927204961589fcb0b0be245c51fbbc87cbca2 # 5.5.5 with: reports: "**/*.cobertura.xml" targetdir: "${{ github.workspace }}/report" diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 49922b8d9..9763a6cf5 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -218,7 +218,20 @@ public async Task HandleDeleteRequestAsync(HttpContext context) } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - if (sessionManager.TryRemove(sessionId, out var session)) + if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session)) + { + return; + } + + if (!session.HasSameUserId(context.User)) + { + await WriteJsonRpcErrorAsync(context, + "Forbidden: The currently authenticated user does not match the user who initiated the session.", + StatusCodes.Status403Forbidden); + return; + } + + if (sessionManager.TryRemove(sessionId, out session)) { await session.DisposeAsync(); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 4f2d5aaeb..40b9e8217 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -8,6 +8,7 @@ using ModelContextProtocol.Tests.Utils; using System.Collections.Concurrent; using System.Net; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -868,4 +869,76 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() }); } } + + [Fact] + public async Task DeleteRequest_FromDifferentUser_IsRejected_AndSessionSurvives() + { + Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode."); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); + Builder.Services.AddHttpContextAccessor(); + + await using var app = Builder.Build(); + + // Pick the user from a test header so different HttpClient requests can act as different users. + app.Use(next => async context => + { + var name = context.Request.Headers["X-Test-User"].ToString(); + if (!string.IsNullOrEmpty(name)) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)], + "TestAuthType", "name", "role")); + } + await next(context); + }); + + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + const string initializeRequest = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}} + """; + + using var initRequest = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(initializeRequest, System.Text.Encoding.UTF8, "application/json"), + }; + initRequest.Headers.Add("X-Test-User", "Alice"); + initRequest.Headers.Accept.ParseAdd("application/json"); + initRequest.Headers.Accept.ParseAdd("text/event-stream"); + + using var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken); + Assert.True(initResponse.IsSuccessStatusCode); + var sessionId = Assert.Single(initResponse.Headers.GetValues("Mcp-Session-Id")); + + // A DELETE from a different authenticated user must not be able to tear down Alice's session. + using var bobDelete = new HttpRequestMessage(HttpMethod.Delete, "http://localhost:5000/"); + bobDelete.Headers.Add("X-Test-User", "Bob"); + bobDelete.Headers.Add("Mcp-Session-Id", sessionId); + using var bobDeleteResponse = await HttpClient.SendAsync(bobDelete, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, bobDeleteResponse.StatusCode); + + // Alice should still be able to use the session. + const string toolCallRequest = """ + {"jsonrpc":"2.0","id":2,"method":"tools/list"} + """; + using var aliceCall = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(toolCallRequest, System.Text.Encoding.UTF8, "application/json"), + }; + aliceCall.Headers.Add("X-Test-User", "Alice"); + aliceCall.Headers.Add("Mcp-Session-Id", sessionId); + aliceCall.Headers.Accept.ParseAdd("application/json"); + aliceCall.Headers.Accept.ParseAdd("text/event-stream"); + using var aliceCallResponse = await HttpClient.SendAsync(aliceCall, TestContext.Current.CancellationToken); + Assert.True(aliceCallResponse.IsSuccessStatusCode); + + // Alice can still terminate her own session. + using var aliceDelete = new HttpRequestMessage(HttpMethod.Delete, "http://localhost:5000/"); + aliceDelete.Headers.Add("X-Test-User", "Alice"); + aliceDelete.Headers.Add("Mcp-Session-Id", sessionId); + using var aliceDeleteResponse = await HttpClient.SendAsync(aliceDelete, TestContext.Current.CancellationToken); + Assert.True(aliceDeleteResponse.IsSuccessStatusCode); + } } From 168c37179ddf9e2db05749db37db1c6bed8855ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my?= <149010438+JBallan@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:49:23 +0200 Subject: [PATCH 08/50] Fix: Relax resource URI validation to accept base URL (#1517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérémy Ballan Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- .../Authentication/ClientOAuthProvider.cs | 29 +++-- .../OAuth/AuthTests.cs | 109 ++++++++++++++++-- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 662e436eb..8dbd3c394 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -774,15 +774,19 @@ private async Task PerformDynamicClientRegistrationAsync( } /// - /// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC. - /// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server. + /// Verifies that the resource URI in the metadata matches the original request URL. + /// Accepts either an exact match with the full request URL, or a match with the base URL + /// (authority only, path discarded) as allowed by the MCP spec, which derives the authorization + /// base URL by discarding the path component from the MCP server URL. /// /// The metadata to verify. /// /// The original URL the client used to make the request to the resource server or the root Uri for the resource server /// if the metadata was automatically requested from the root well-known location. /// - /// True if the resource URI exactly matches the original request URL, otherwise false. + /// + /// True if the resource URI exactly matches the original request URL or its authority-level base URL, otherwise false. + /// private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation) { if (protectedResourceMetadata.Resource is null) @@ -790,14 +794,22 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou return false; } - // Per RFC: The resource value must be identical to the URL that the client used - // to make the request to the resource server. Compare entire URIs, not just the host. - // Normalize the URIs to ensure consistent comparison string normalizedMetadataResource = NormalizeUri(protectedResourceMetadata.Resource); string normalizedResourceLocation = NormalizeUri(resourceLocation); - return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase); + // Accept exact match with the full MCP endpoint URI + if (string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Per the MCP spec's "Canonical Server URI" section, both the path-specific URI (e.g. https://mcp.example.com/mcp) + // and the authority-only URI (e.g. https://mcp.example.com) are valid canonical URIs for identifying an MCP server. + // Accept a match with the base URL (authority only, path discarded) to support servers that use the less specific form. + + string normalizedBaseUrl = NormalizeUri(new Uri(resourceLocation.GetLeftPart(UriPartial.Authority))); + return string.Equals(normalizedMetadataResource, normalizedBaseUrl, StringComparison.OrdinalIgnoreCase); } /// @@ -916,7 +928,8 @@ private async Task ExtractProtectedResourceMetadata(H // https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements metadata.WwwAuthenticateScope = wwwAuthenticateScope; - // Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server + // Validate that the resource URI in metadata corresponds to the server we're connecting to. + // VerifyResourceMatch accepts both an exact URI match and an authority-level (base URL) match per the MCP spec. LogValidatingResourceMetadata(resourceUri); if (!isLegacyFallback && !VerifyResourceMatch(metadata, resourceUri)) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 1ec6fddc6..84c25e38c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -768,7 +768,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent // // https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3 // - // CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case. + // CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates that a root-level resource is accepted in this case. // CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing. Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => @@ -807,8 +807,14 @@ await McpClient.CreateAsync( Assert.Contains("does not match", ex.Message); } + /// + /// Verifies that OAuth authentication succeeds when the protected resource metadata URI + /// matches the root server URL, even when the actual MCP endpoint is at a subpath. + /// This tests the flexible URI matching behavior where the resource URI can be less specific + /// than the actual endpoint being accessed. + /// [Fact] - public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() + public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() { const string requestedResourcePath = "/mcp/tools"; @@ -839,12 +845,99 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa }, }, HttpClient, LoggerFactory); - var ex = await Assert.ThrowsAsync(async () => + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + /// + /// Verifies that OAuth authentication fails when the protected resource metadata URI + /// does not match the requested MCP server endpoint. This ensures that clients cannot + /// use OAuth tokens intended for one server to access a different server. + /// + [Fact] + public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch() + { + const string requestedResourcePath = "/mcp/tools"; + const string differentResourceUri = "http://different-server.example.com"; + + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => { - await McpClient.CreateAsync( - transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + options.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = differentResourceUri, + AuthorizationServers = { OAuthServerUrl }, + }; }); + await using var app = Builder.Build(); + + app.MapMcp(requestedResourcePath).RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + // This should fail because the resource URI doesn't match + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("does not match", ex.Message); + } + + /// + /// Verifies that OAuth authentication fails when the protected resource metadata URI is an + /// unrelated path on the same host as the requested endpoint (e.g. resource=.../service-a vs + /// endpoint .../service-b). This ensures the authority-level fallback only accepts an exact match + /// or an authority-only resource, and not arbitrary sibling paths on the same host. + /// + [Fact] + public async Task CannotAuthenticate_WhenResourceMetadataResourceIsDifferentPathOnSameAuthority() + { + const string requestedResourcePath = "/service-b"; + const string differentResourcePath = "/service-a"; + + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = $"{McpServerUrl}{differentResourcePath}", + AuthorizationServers = { OAuthServerUrl }, + }; + }); + + await using var app = Builder.Build(); + + app.MapMcp(requestedResourcePath).RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + // This should fail because the resource URI is a different path on the same host, + // which is neither an exact match nor the authority-only base URL. + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("does not match", ex.Message); } @@ -853,7 +946,7 @@ public async Task ResourceMetadata_DoesNotAddTrailingSlash() { // This test verifies that automatically derived resource URIs don't have trailing slashes // and that the client doesn't add them during authentication - + // Don't explicitly set Resource - let it be derived from the request await using var app = await StartMcpServerAsync(); @@ -993,10 +1086,10 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash() { // This test verifies that explicitly configured trailing slashes are preserved const string resourceWithTrailingSlash = "http://localhost:5000/"; - + // Configure ValidResources to accept the trailing slash version for this test TestOAuthServer.ValidResources = [resourceWithTrailingSlash, "http://localhost:5000/mcp"]; - + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => { options.ResourceMetadata = new ProtectedResourceMetadata From 711e5bb3b727803bb11a439de6eedb57ec6c8953 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 2 Jun 2026 12:49:31 -0700 Subject: [PATCH 09/50] Rename `MinVersionForStandardHeaders` to `DraftProtocolVersion` (#1603) --- src/Common/McpHttpHeaders.cs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 5c631a0e7..0768cb442 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -10,14 +10,27 @@ namespace ModelContextProtocol.Protocol; internal static class McpHttpHeaders { /// - /// The minimum protocol version that requires standard MCP request headers. + /// The draft MCP protocol version string used to gate behaviors that are only enabled + /// for clients negotiating the in-progress draft specification. /// /// - /// Servers enforce missing Mcp-Method and Mcp-Name headers as errors only when - /// the client's MCP-Protocol-Version header indicates this version or later. - /// Clients using older versions are not required to send these headers. + /// Behaviors currently gated on this version include: + /// + /// + /// Requiring the standard MCP request headers (Mcp-Method and Mcp-Name) + /// on Streamable HTTP POST requests; servers treat missing headers as errors only when + /// the client's MCP-Protocol-Version header matches this value. + /// + /// + /// Reporting unresolvable resource URIs from resources/read with the standard + /// JSON-RPC (-32602) code rather than the + /// legacy (-32002) code. + /// + /// + /// The associated helpers perform exact ordinal matches against this single value rather + /// than any ordered comparison. /// - public static readonly string MinVersionForStandardHeaders = "DRAFT-2026-v1"; + public const string DraftProtocolVersion = "DRAFT-2026-v1"; /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -67,7 +80,7 @@ internal static class McpHttpHeaders /// private static readonly HashSet s_versionsWithStandardHeaders = new(StringComparer.Ordinal) { - MinVersionForStandardHeaders, + DraftProtocolVersion, }; /// @@ -82,5 +95,5 @@ public static bool SupportsStandardHeaders(string? protocolVersion) /// rather than the legacy (-32002). /// internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) - => string.Equals(protocolVersion, MinVersionForStandardHeaders, StringComparison.Ordinal); + => string.Equals(protocolVersion, DraftProtocolVersion, StringComparison.Ordinal); } From bc372f18c622bfd900edf1f743c8d5cd99e59a6f Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 3 Jun 2026 01:35:15 -0700 Subject: [PATCH 10/50] Update release processes to support release servicing branches (#1620) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../release.md => release-process.md} | 11 +- .github/skills/bump-version/SKILL.md | 28 +++-- .../references/semver-assessment.md | 31 ++++- .github/skills/prepare-release/SKILL.md | 114 +++++++++++------- .github/skills/publish-release/SKILL.md | 21 ++-- .../shared-resources/release-branches.md | 40 ++++++ .github/workflows/release.yml | 35 ++++-- ModelContextProtocol.slnx | 5 +- 8 files changed, 199 insertions(+), 86 deletions(-) rename .github/{workflows/release.md => release-process.md} (51%) create mode 100644 .github/skills/shared-resources/release-branches.md diff --git a/.github/workflows/release.md b/.github/release-process.md similarity index 51% rename from .github/workflows/release.md rename to .github/release-process.md index 87005a64a..41f772759 100644 --- a/.github/workflows/release.md +++ b/.github/release-process.md @@ -10,7 +10,7 @@ The following process is used when publishing new releases to NuGet.org. ## 2. Prepare the release -From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. +From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. Review the PR, request changes if needed, and merge when ready. @@ -20,6 +20,15 @@ After the prepare-release PR is merged, invoke the `publish-release` skill. The Review the draft release on GitHub, check 'Set as a pre-release' if appropriate, and click 'Publish release'. +## Branching + +The `main` branch is the next-MAJOR preview and development line; currently, it produces the `2.0.0-preview.*` series. Nightly `cron` CI on `main` publishes CI-suffixed packages to GitHub Packages. +Long-lived `release/{MAJOR}.x` branches are created on demand when a shipped MAJOR needs servicing releases. Every push to a `release/*` branch publishes a CI-suffixed package to GitHub Packages, so servicing CI packages are commit-driven rather than clock-driven. +Short-lived `release-{version}` branches are local prepare-release work branches that become pull requests, such as `release-2.0.0-preview.1` or `release-1.3.1`. +Official NuGet.org publishes occur only when a GitHub Release is created from a branch's tag. +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 - After publishing, a workflow will produce build artifacts and publish the NuGet packages to NuGet.org diff --git a/.github/skills/bump-version/SKILL.md b/.github/skills/bump-version/SKILL.md index 29932b4db..e78915636 100644 --- a/.github/skills/bump-version/SKILL.md +++ b/.github/skills/bump-version/SKILL.md @@ -8,18 +8,21 @@ compatibility: Requires gh CLI with repo access for creating branches and pull r Assess and bump the SDK version in `src/Directory.Build.props` to prepare for the next release. This skill owns the [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) assessment logic — the [SemVer assessment guide](references/semver-assessment.md) is the single source of truth for version assessment criteria used across the release workflow by both the **prepare-release** and **publish-release** skills. +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles, previous-release lookup rules, and release work-branch naming. + > **Note**: For comprehensive release preparation — including ApiCompat/ApiDiff, documentation review, and release notes — use the **prepare-release** skill, which incorporates version assessment as part of its broader workflow. ## Process ### Step 1: Read Current Version and Previous Release -Read `src/Directory.Build.props` on the default branch and extract: +Read `src/Directory.Build.props` on the current branch and extract: - `` — the `MAJOR.MINOR.PATCH` version +- `` — the prerelease suffix, when present -Display the current version to the user. +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. +Determine the previous release tag from `gh release list` (most recent **published** release). Draft releases must be ignored — they represent a pending release that has not yet shipped. Use `--exclude-drafts` or filter to only published releases when querying. The lookup is branch-aware: from a `release/{MAJOR}.x` branch, restrict candidates to tags matching `v{MAJOR}.*`; from `main`, use the most recent published release globally. See [release-branches.md](../shared-resources/release-branches.md) for details. ### Step 2: Assess and Determine Next Version @@ -40,26 +43,31 @@ When context about queued changes is available or can be gathered, assess the ve #### Default Suggestion (Fallback) -When a quick bump is needed without full change analysis, suggest the next **minor** version: +When a quick bump is needed without full change analysis, suggest based on the candidate version: -- Current `1.0.0` → suggest `1.1.0` -- Current `1.2.3` → suggest `1.3.0` +- **Stable candidate** — suggest the next **minor** version: + - Current `1.0.0` → suggest `1.1.0` + - Current `1.2.3` → suggest `1.3.0` +- **Prerelease candidate** — if the suffix starts with an identifier such as `preview.` or `rc.` followed by an integer, suggest incrementing the trailing integer: + - Current `2.0.0-preview.1` → suggest `2.0.0-preview.2` + - Current `2.0.0-rc.1` → suggest `2.0.0-rc.2` Present the suggestion and let the user confirm or provide an alternative. -Parse the confirmed version into its `VersionPrefix` component. +Parse the confirmed version into its `VersionPrefix` and `VersionSuffix` components. Stable versions have no suffix. ### Step 3: Create Pull Request -1. Create a new branch named `bump-version-to-{version}` (e.g. `bump-version-to-1.1.0`) from the default branch +1. Create a new branch named `bump-version-to-{version}` (e.g. `bump-version-to-1.1.0`) from the current branch 2. Update `src/Directory.Build.props`: - - Set `` to the new version + - Set `` to the confirmed stable component + - Set `` for prerelease versions, or clear it for stable versions; add the element if it is missing - Update `` if the MAJOR version has changed 3. Commit with message: `Bump version to {version}` 4. Push the branch and create a pull request: - **Title**: `Bump version to {version}` - **Label**: `infrastructure` - - **Base**: default branch + - **Base**: the current branch (which, for a servicing branch like `release/1.x`, is that servicing branch — not `main`) ### Step 4: Confirm diff --git a/.github/skills/bump-version/references/semver-assessment.md b/.github/skills/bump-version/references/semver-assessment.md index 2d39ec166..f1f5f6d68 100644 --- a/.github/skills/bump-version/references/semver-assessment.md +++ b/.github/skills/bump-version/references/semver-assessment.md @@ -56,13 +56,31 @@ Recommend a PATCH version increment if no MAJOR or MINOR criteria are met. - MINOR: `MAJOR.(MINOR+1).0` - PATCH: `MAJOR.MINOR.(PATCH+1)` -**Examples** from previous release `v1.2.0`: +### Prereleases -| Level | Recommended | -|-------|-------------| -| PATCH | `v1.2.1` | -| MINOR | `v1.3.0` | -| MAJOR | `v2.0.0` | +While the candidate version uses a prerelease suffix (e.g., `X.Y.Z-preview.N`, `X.Y.Z-rc.N`), the recommended next version increments the trailing integer of the suffix: `preview.3` → `preview.4`, `rc.1` → `rc.2`. + +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. + +### 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 MAJOR/MINOR/PATCH classification criteria above are unchanged regardless of branch. + +See [release-branches.md](../../shared-resources/release-branches.md) for branch-role definitions and previous-release lookup rules. + +**Examples**: + +| Previous release | Branch | Level | Recommended | +|--------------------|---------------|-----------------------|------------------------| +| `v1.2.0` | `main` | PATCH | `v1.2.1` | +| `v1.2.0` | `main` | MINOR | `v1.3.0` | +| `v1.2.0` | `main` | MAJOR | `v2.0.0` | +| `v2.0.0-preview.1` | `main` | (prerelease bump) | `v2.0.0-preview.2` | +| `v1.3.0` | `release/1.x` | PATCH | `v1.3.1` | ## Comparing Against the Candidate Version @@ -83,6 +101,7 @@ Present the assessment as a summary table followed by a rationale: | Aspect | Finding | |--------|---------| +| Branch context | release/1.x | | Previous release | v1.0.0 | | Breaking changes | None confirmed | | New API surface | Yes — 3 PRs add new public APIs | diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index 03976ac91..f881332a1 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -12,22 +12,39 @@ Prepare a new release for the `modelcontextprotocol/csharp-sdk` repository. This > **User confirmation required: This skill NEVER pushes a branch or creates a pull request without explicit user confirmation.** The user must review and approve all details before any remote operations occur. +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles, previous-release lookup rules, and release work-branch naming. + ## Process 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 1: Determine Target and Gather PRs +### Step 1: Select Source Branch + +List candidate source/base branches via: +`gh api repos/{owner}/{repo}/branches --paginate --jq '[.[] | select(.name == "main" or (.name | startswith("release/"))) | .name]'` + +Present the list to the user and ask them to choose the source/base branch. Default selection: `main`. + +The selected branch drives every subsequent step: +1. The branch on which the candidate version is read from `src/Directory.Build.props`. +2. The "previous release" lookup (constrained to `v{MAJOR}.*` on `release/{MAJOR}.x`). +3. The commit range from which PRs are collected. +4. The PR base (`--base`) for `gh pr create` at the end of the skill. + +See [release-branches.md](../shared-resources/release-branches.md) for the structured branch rules. + +### Step 2: Determine Target and Gather PRs The user may provide: -- **A git ref** (commit SHA, branch, or tag) — use as the target commit -- **No context** — show the last 5 commits on `main` (noting HEAD) and offer the option to enter a branch or tag name instead +- **A git ref** (commit SHA, branch, or tag) — use as the target commit relative to the selected source/base branch +- **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`). -2. Get the full list of PRs merged between the previous release tag and the target commit. -3. Read `src/Directory.Build.props` **at the target commit**. Extract `` as the **candidate version**. +1. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). Use the selected source/base branch context: on `release/{MAJOR}.x`, restrict candidates to tags matching `v{MAJOR}.*`; on `main`, use the most recent published release globally. +2. Get the full list of PRs merged between the previous release tag and the target commit on the selected branch. +3. Read `src/Directory.Build.props` **at the target commit**. Extract `` and ``; the **candidate version** is `{VersionPrefix}` plus `-{VersionSuffix}` when the suffix is present (for example, `2.0.0-preview.1`). -### Step 2: Categorize and Attribute +### Step 3: Categorize and Attribute Sort every PR into one of four categories. See [references/categorization.md](references/categorization.md) for detailed guidance. @@ -50,53 +67,55 @@ Sort every PR into one of four categories. See [references/categorization.md](re - Omit the co-author parenthetical when there are none - Sort entries within each section by merge date (chronological) -### Step 3: Breaking Change Audit +### Step 4: Breaking Change Audit Invoke the **breaking-changes** skill with the commit range from the previous release tag to the target commit. Examine every PR, assess impact, reconcile labels (offering to add/remove labels and comment on PRs), and get user confirmation. Use the results (confirmed breaking changes with impact ordering and detail bullets) in the remaining steps. -### Step 4: Assess Release Version +### Step 5: Assess Release Version -Using the categorized PRs from Step 2 and confirmed breaking changes from Step 3, assess the appropriate [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) release level. Follow the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) for the full assessment criteria. +Using the categorized PRs from Step 3 and confirmed breaking changes from Step 4, assess the appropriate [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) release level. Follow the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) for the full assessment criteria. 1. **Classify the release level**: - **MAJOR** — if any confirmed breaking changes are present (API or behavioral), excluding changes to `[Experimental]` APIs - **MINOR** — if no breaking changes but new public APIs, features, or obsoletion warnings are introduced - **PATCH** — otherwise -2. **Compute the recommended version** from the previous release tag: +2. **Compute the recommended version** from the previous release tag and branch context: - Increment the appropriate component (MAJOR resets MINOR.PATCH to 0; MINOR resets PATCH to 0) + - For prerelease candidates such as `preview.N` or `rc.N`, the recommendation may simply increment the trailing integer per the assessment guide 3. **Compare against the candidate version** from `src/Directory.Build.props`. Flag any discrepancy: - **Under-versioned**: The candidate is lower than the recommended level. This is a concern that should be resolved. - **Over-versioned**: The candidate is higher than strictly required. This is acceptable under SemVer but worth noting. 4. **Present the assessment** with a summary table showing the previous release, change classification, recommended level, recommended version, and any discrepancy with the candidate. Include a brief rationale citing the most significant PRs. 5. **Get user confirmation** of the release version before proceeding. -### Step 5: Create Release Branch and Bump Version +### Step 6: Create Release Branch and Bump Version After the version is confirmed: -1. Create a local branch named `release-{version}` from the target commit (e.g., `release-1.1.0`). +1. Create a local branch named `release-{version}` from the target commit (e.g., `release-2.0.0-preview.1`, `release-1.3.1`). 2. Update `src/Directory.Build.props`: - - Set `` to the confirmed version - - Update `` if the MAJOR version has changed (set to the previous release version) + - 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. 3. Build the solution to verify the version change compiles: `dotnet build` This step creates local changes only — nothing is committed or pushed yet. -### Step 6: Run API Compatibility Check +### Step 7: Run API Compatibility Check Run API compatibility validation against the baseline version. Follow [references/apicompat-apidiff.md](references/apicompat-apidiff.md) for the full procedure. 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: - - Cross-reference with the breaking change audit from Step 3 + - 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 -### Step 7: Generate API Diff Report +### Step 8: Generate API Diff Report Generate a human-readable diff of the public API surface between the previous release and the new version. Follow [references/apicompat-apidiff.md](references/apicompat-apidiff.md) for the full procedure, including how to install the `Microsoft.DotNet.ApiDiff.Tool` from the .NET transport feed. @@ -107,7 +126,7 @@ Generate a human-readable diff of the public API surface between the previous re > **If the ApiDiff tool cannot be installed or fails to produce output, STOP and inform the user.** Present the error and ask how to proceed. Do not fall back to a manual summary — the user must decide whether to troubleshoot, skip the API diff, or abort. -### Step 8: Review and Update Documentation +### Step 9: Review and Update Documentation Review repository documentation for changes needed to compensate for or adapt to this release: @@ -118,12 +137,12 @@ Review repository documentation for changes needed to compensate for or adapt to Stage all documentation changes for inclusion in the release commit. -### Step 9: Draft Release Notes +### Step 10: Draft Release Notes Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created. 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 3 results). Include the versioning docs link. +2. **Breaking Changes** — sorted most → least impactful (from Step 4 results). Include the versioning docs link. 3. **What's Changed** — chronological; includes breaking change PRs 4. **Documentation Updates** — chronological 5. **Test Improvements** — chronological @@ -132,11 +151,11 @@ Compose the release notes that will appear in the PR description and serve as th - New contributors (first contribution in this release) - Issue reporters (cite resolving PRs) - PR reviewers (single bullet, sorted by review count, no count shown) -8. **Full Changelog** link +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. +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 10: Commit Changes +### Step 11: Commit Changes Commit all changes to the `release-{version}` branch: @@ -144,39 +163,40 @@ Commit all changes to the `release-{version}` branch: 2. Commit with message: `Prepare release v{version}` 3. Do **not** push yet -### Step 11: Present Release Summary +### Step 12: Present Release Summary -Present **all** of the following details to the user for review. The user must confirm every aspect before proceeding to Step 12. +Present **all** of the following details to the user for review. The user must confirm every aspect before proceeding to Step 13. 1. **Version number** with brief rationale for why this SemVer level was selected -2. **Branch name** (e.g., `release-1.1.0`) -3. **Remote** the branch would be pushed to (show the configured remote, typically `origin`) -4. **Files changed** — list every file modified in the commit with a one-line summary of what changed in each: +2. **Source/base branch** selected in Step 1 +3. **Branch name** (e.g., `release-2.0.0-preview.1`, `release-1.3.1`) +4. **Remote** the branch would be pushed to (show the configured remote, typically `origin`) +5. **Files changed** — list every file modified in the commit with a one-line summary of what changed in each: ``` - src/Directory.Build.props — Version bumped from 1.0.0 to 1.1.0 + src/Directory.Build.props — Version bumped from 2.0.0-preview.1 to 2.0.0-preview.2 src/ModelContextProtocol.Core/CompatibilitySuppressions.xml — Added 2 new suppressions README.md — Updated code sample for new API docs/experimental.md — Added new experimental API reference ``` -5. **Draft release notes** — the complete release notes from Step 9 -6. **API Compatibility results** — the ApiCompat output from Step 6 -7. **API Diff report** — the API diff from Step 7 -8. **Proposed PR title** (e.g., `Release v1.1.0`) -9. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff +6. **Draft release notes** — the complete release notes from Step 10 +7. **API Compatibility results** — the ApiCompat output from Step 7 +8. **API Diff report** — the API diff from Step 8 +9. **Proposed PR title** (e.g., `Release v2.0.0-preview.1`, `Release v1.3.1`) +10. **Proposed PR description** — the assembled content combining release notes, ApiCompat, and ApiDiff After presenting all details, explicitly ask the user: > Would you like to push the branch and create the pull request? **Do not proceed without explicit "yes" confirmation.** -### Step 12: Push Branch and Create Pull Request +### Step 13: Push Branch and Create Pull Request -Only after explicit user confirmation in Step 11: +Only after explicit user confirmation in Step 12: 1. Push the `release-{version}` branch to the remote -2. Create a pull request: +2. Create a pull request with `gh pr create --base {step-1-branch}`: - **Title**: `Release v{version}` - - **Base**: default branch (typically `main`) + - **Base**: the source/base branch selected in Step 1 - **Head**: `release-{version}` - **Description**: The assembled PR description (see PR Description Template below) - **Labels**: Apply appropriate labels (e.g., `release`) @@ -192,18 +212,20 @@ Only after explicit user confirmation in Step 11: - **Single breaking change**: use the same numbered format as multiple - **No user-facing changes**: if all PRs are documentation, tests, or infrastructure, flag that a release may not be warranted and ask the user whether to proceed - **Version discrepancy**: if the candidate version from `Directory.Build.props` doesn't match the SemVer assessment, present the discrepancy and let the user decide the final version +- **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 - **ApiCompat tooling unavailable**: fall back to `dotnet pack` output; note in the PR description that full ApiCompat was run via package validation only - **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**: when bumping MAJOR version, update the baseline to the previous release version; when bumping MINOR or PATCH, keep the existing baseline +- **PackageValidationBaselineVersion update**: for the `2.0.0-preview` series, use `1.3.0`; for subsequent stable releases, use the previous shipped version of the same MAJOR or the latest stable from the previous MAJOR - **CompatibilitySuppressions.xml**: when intentional breaks are found, add suppression entries and include the file in the commit; existing suppressions should be preserved -- **User declines PR creation**: if the user declines at Step 11, leave the local branch intact so they can review, modify, or push manually +- **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 -The PR description combines release notes, ApiCompat, and ApiDiff into a single document. Omit empty sections. +The PR description combines release notes, ApiCompat, and ApiDiff into a single document. Omit empty sections. The `{version}` placeholder is the full version and may include a prerelease suffix (for example, `Release v2.0.0-preview.1`). ```markdown # Release v{version} @@ -241,7 +263,8 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers * @user made their first contribution in #PR * @user1 @user2 @user3 reviewed pull requests -**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/previous-tag...release-{version} +**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/{previous-tag}...v{version} + --- @@ -266,7 +289,7 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers ## Release Notes Template -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. +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. @@ -303,5 +326,6 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers * @user submitted issue #1234 (resolved by #5678) * @user1 @user2 @user3 reviewed pull requests -**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/previous-tag...new-tag +**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/{previous-tag}...v{version} + ``` diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index fce598c4d..0706cd361 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -8,6 +8,8 @@ compatibility: Requires gh CLI with repo access and GitHub API access for PR det Create a GitHub release for the `modelcontextprotocol/csharp-sdk` repository after a **prepare-release** PR has been merged. This skill refreshes the release notes to include any PRs merged between the preparation branch point and the merge, warns about changes that affect the version or breaking change assessment, and creates a **draft** GitHub release. +Use the shared [release branch reference](../shared-resources/release-branches.md) for branch roles, previous-release lookup rules, and release work-branch naming. + > **Safety: This skill only creates and updates draft releases. It must never publish a release.** If the user asks to publish, decline and instruct them to publish manually through the GitHub UI. ## Process @@ -18,7 +20,7 @@ Work through each step sequentially. Present findings at each step and get user The user may provide: - **A PR number or URL** — use directly -- **A version number** (e.g., `1.1.0`) — search for a merged PR titled `Release v{version}` +- **A version number** (e.g., `1.1.0`, `2.0.0-preview.1`) — search for a merged PR titled `Release v{version}`. Prerelease versions are used verbatim, for example `Release v2.0.0-preview.1` - **No context** — list recently merged PRs with `Release v` in the title and ask the user to select Verify the PR is merged. Extract: @@ -28,13 +30,13 @@ 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 ``. The tag is `v{VersionPrefix}`. -2. Determine the previous release tag from `gh release list` (most recent **published** release — exclude drafts with `--exclude-drafts`). +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). 3. Identify the full commit range: previous release tag → merge commit. ### Step 3: Check for Additional PRs -Compare the PRs included in the original prepare-release PR description with the full set of PRs now merged in the commit range. Use the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) to evaluate the impact of any new PRs against the version that was committed during preparation. +Compare the PRs included in the original prepare-release PR description with the full set of PRs now merged in the commit range. Use the [SemVer assessment guide](../bump-version/references/semver-assessment.md) (owned by the **bump-version** skill) to evaluate the impact of any new PRs against the version that was committed during preparation, including its prerelease and branch-context computation rules. This is not a policy change; only the version computation and previous-release lookup change. 1. Extract the PR list from the prepare-release PR description (all `#NNN` references in release notes sections). 2. Get the full set of PRs merged between the previous release tag and the merge commit. @@ -107,11 +109,11 @@ Follow [references/formatting.md](references/formatting.md) when composing and u ### Step 9: Create Draft Release Display release metadata for user review: -- **Title / Tag**: the confirmed version (e.g. `v1.1.0`) -- **Target**: merge commit SHA, its message, and the prepare-release PR link +- **Title / Tag**: the confirmed tag, including any prerelease suffix (e.g. `v1.3.1`, `v2.0.0-preview.1`) +- **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` (always `--draft`) +- Create with `gh release create --draft {tag} --target {merge-commit-branch}` (always `--draft`), using the prerelease tag verbatim when present - **Never publish.** If the user asks to publish, decline and instruct them to publish manually. 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). @@ -131,7 +133,7 @@ When the user requests revisions after the initial creation, always rewrite the ## Release Notes Template -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. Tags may include prerelease suffixes, such as `v2.0.0-preview.1`, and Full Changelog compare links should use the exact tag. ```markdown [Preamble — REQUIRED. Summarize the release theme.] @@ -166,5 +168,6 @@ Refer to the [C# SDK Versioning](https://csharp.sdk.modelcontextprotocol.io/vers * @user submitted issue #1234 (resolved by #5678) * @user1 @user2 @user3 reviewed pull requests -**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/previous-tag...new-tag +**Full Changelog**: https://github.com/modelcontextprotocol/csharp-sdk/compare/{previous-tag}...v{version} + ``` diff --git a/.github/skills/shared-resources/release-branches.md b/.github/skills/shared-resources/release-branches.md new file mode 100644 index 000000000..477567793 --- /dev/null +++ b/.github/skills/shared-resources/release-branches.md @@ -0,0 +1,40 @@ +# Release Branches + +Shared reference for release skills. Describes the branch roles used by the release workflow and the rules each skill follows for selecting a branch and looking up the previous release. + +## Branch roles + +| Branch | Purpose | CI behavior | +| ------------------- | ----------------------------------------------- | -------------------------------------- | +| `main` | Next-MAJOR preview/development line | Nightly `cron` build → GitHub Packages | +| `release/{MAJOR}.x` | Long-lived servicing branch for a shipped MAJOR | Every push → GitHub Packages | +| `release-{version}` | Short-lived release preparation branch | Built by PR CI; no package publishing | + +Official NuGet.org publishes happen only when a GitHub Release is created from a branch's tag. + +## Selecting a source/base branch (`prepare-release` Step 1) + +1. List candidate branches via: + `gh api repos/{owner}/{repo}/branches --paginate --jq '[.[] | select(.name == "main" or (.name | startswith("release/"))) | .name]'` +2. Present the list to the user. Default selection: `main`. +3. The selected branch drives: + - Previous-release lookup (see below). + - The branch on which the candidate version is read from `src/Directory.Build.props`. + - The commit range from which PRs are collected. + - The `--base` of the PR created at the end of the skill. + +## 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. + +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 + +Prepare-release work branches are named `release-{version}` (flat, hyphen-separated): +- `release-2.0.0-preview.1` +- `release-1.3.1` +- `release-2.0.0` + +Hyphens in prerelease versions are valid in git branch names. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c4842ba0..eca7a315a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,24 @@ # Publish new package versions of ModelContextProtocol # -# Daily and Manual Runs -# - Triggered automatically at 07:00 UTC daily -# - Triggered manually using GitHub Actions workflow_dispatch event -# - Version prefix applied from /src/Directory.Build.props -# - Version suffix set to `ci.{github.run_number}` -# - Package published to GitHub package registry +# Triggers +# - Scheduled (07:00 UTC daily, main branch): produces a CI-suffixed package +# and publishes it to the GitHub package registry. # -# Official Releases -# - Triggered after a GitHub Release is created -# - Version prefix applied from /src/Directory.Build.props -# - Version suffix applied from /src/Directory.Build.props -# - Package published to GitHub package registry -# - Package published to NuGet.org -# - Version prefix and/or suffix should be updated after each release +# - Push to `release/**`: every commit to a release branch produces a CI-suffixed +# package and publishes it to the GitHub package registry. +# +# - Manual `workflow_dispatch` (any branch): same outputs as a scheduled/push build; +# accepts a `version_suffix_override` input. +# +# - GitHub Release published (any branch's tag): publishes the version from +# `src/Directory.Build.props` to the GitHub package registry AND NuGet.org. +# +# Version +# - Version prefix and suffix come from `src/Directory.Build.props`. +# - For non-release triggers, the suffix is replaced with `ci.{run_number}` +# (or `workflow_dispatch.inputs.version_suffix_override` when provided). +# - The prefix and suffix in `Directory.Build.props` should be updated after each +# release using the `bump-version` or `prepare-release` skills. name: Release Publishing @@ -21,6 +26,10 @@ on: schedule: - cron: '0 7 * * *' + push: + branches: + - 'release/**' + workflow_dispatch: inputs: version_suffix_override: diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 1090c5377..6d096658d 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -1,11 +1,12 @@ - + + + - From 8202bcc932bc8f9f9802c6347190ea74c3c97901 Mon Sep 17 00:00:00 2001 From: Aniket Anil Kumar Date: Wed, 3 Jun 2026 20:08:08 +0530 Subject: [PATCH 11/50] feat: Add Enterprise Managed Authorization (SEP-990) support (#1305) --- README.md | 5 + docs/concepts/transports/transports.md | 39 ++ .../ExchangeJwtBearerGrantOptions.cs | 32 ++ .../Authentication/IdentityAssertionGrant.cs | 296 +++++++++++++ .../IdentityAssertionGrantContext.cs | 20 + .../IdentityAssertionGrantException.cs | 51 +++ .../IdentityAssertionGrantIdTokenCallback.cs | 17 + .../IdentityAssertionGrantProvider.cs | 207 ++++++++++ .../IdentityAssertionGrantProviderOptions.cs | 68 +++ .../JagTokenExchangeResponse.cs | 40 ++ .../JwtBearerAccessTokenResponse.cs | 37 ++ .../Authentication/OAuthErrorResponse.cs | 26 ++ .../RequestJwtAuthGrantOptions.cs | 42 ++ .../McpJsonUtilities.cs | 6 + .../IdentityAssertionGrantIntegrationTests.cs | 167 ++++++++ .../JagTokenExchangeResponse.cs | 38 ++ .../OAuthJsonContext.cs | 1 + .../Program.cs | 216 +++++++++- .../IdentityAssertionGrantTests.cs | 389 ++++++++++++++++++ 19 files changed, 1695 insertions(+), 2 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs create mode 100644 src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs create mode 100644 tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs create mode 100644 tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs diff --git a/README.md b/README.md index 7f5a9e14e..f0ab4a0ac 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ For more information about MCP: - [Protocol Specification](https://modelcontextprotocol.io/specification/) - [GitHub Organization](https://github.com/modelcontextprotocol) +## Cross-Application Access (Identity Assertion Authorization Grant flow) + +The SDK provides support for the [Identity Assertion Authorization Grant flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) +via `IdentityAssertionGrantProvider`. See the [Cross-Application Access](docs/concepts/transports/transports.md#cross-application-access) section in the transport docs for full usage details. + ## License This project is licensed under the [Apache License 2.0](LICENSE). diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 00df2981c..a3dda4ddf 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -378,3 +378,42 @@ Console.WriteLine(await echo.InvokeAsync(new() { ["arg"] = "Hello World" })); ``` Like [stdio](#stdio-transport), the in-memory transport is inherently single-session — there is no `Mcp-Session-Id` header, and server-to-client requests (sampling, elicitation, roots) work naturally over the bidirectional pipe. This makes it ideal for testing servers that depend on these features. See [Sessions](xref:stateless) for how session behavior varies across transports. + +## Cross-Application Access + +The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. + +The flow consists of two steps: +1. **RFC 8693 Token Exchange** at the enterprise IdP: OIDC ID token → JWT Authorization Grant (JAG) +2. **RFC 7523 JWT Bearer Grant** at the MCP authorization server: JAG → access token + +### Usage + +```csharp +using ModelContextProtocol.Authentication; + +// The caller owns the HttpClient lifetime. +var httpClient = new HttpClient(); + +var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://company.okta.com/oauth2/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (context, cancellationToken) => + // Fetch a fresh ID token from your SSO session. + mySsoClient.GetIdTokenAsync(cancellationToken) + }, + httpClient); + +var tokens = await provider.GetAccessTokenAsync( + resourceUrl: new Uri("https://mcp-server.example.com"), + authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"), + cancellationToken: ct); + +// Use tokens.AccessToken to authenticate against the MCP server. +// Call provider.InvalidateCache() to force a fresh token exchange on the next call. +``` + +The provider caches the resulting access token and reuses it until it expires. To force re-authentication (e.g. after a 401 response), call `provider.InvalidateCache()` before retrying. diff --git a/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs new file mode 100644 index 000000000..9dd440bb8 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/ExchangeJwtBearerGrantOptions.cs @@ -0,0 +1,32 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Options for exchanging a JWT Authorization Grant for an access token via RFC 7523. +/// +internal sealed class ExchangeJwtBearerGrantOptions +{ + /// + /// Gets or sets the MCP Server's authorization server token endpoint URL. + /// + public required string TokenEndpoint { get; set; } + + /// + /// Gets or sets the JWT Authorization Grant (JAG) assertion obtained from token exchange. + /// + public required string Assertion { get; set; } + + /// + /// Gets or sets the client ID for authentication with the MCP authorization server. + /// + public required string ClientId { get; set; } + + /// + /// Gets or sets the client secret for authentication with the MCP authorization server. Optional. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request (space-separated). Optional. + /// + public string? Scope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs new file mode 100644 index 000000000..c94903741 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrant.cs @@ -0,0 +1,296 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace ModelContextProtocol.Authentication; + +/// +/// Provides internal utilities for the Cross-Application Access authorization flow. +/// +/// +/// Implements the Enterprise Managed Authorization flow as specified at +/// . +/// +internal static class IdentityAssertionGrant +{ + #region Constants + + /// Grant type URN for RFC 8693 token exchange. + public const string GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"; + + /// Grant type URN for RFC 7523 JWT Bearer authorization grant. + public const string GrantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + /// Token type URN for OpenID Connect ID Tokens (RFC 8693). + public const string TokenTypeIdToken = "urn:ietf:params:oauth:token-type:id_token"; + + /// Token type URN for SAML 2.0 assertions (RFC 8693). + public const string TokenTypeSaml2 = "urn:ietf:params:oauth:token-type:saml2"; + + /// + /// Token type URN for Identity Assertion JWT Authorization Grants. + /// As specified at + /// . + /// + public const string TokenTypeIdJag = "urn:ietf:params:oauth:token-type:id-jag"; + + /// + /// The expected value for token_type in a JAG token exchange response per RFC 8693 §2.2.1. + /// The issued token is not an OAuth access token, so its type is "N_A". + /// + public const string TokenTypeNotApplicable = "N_A"; + + #endregion + + #region Token Exchange (RFC 8693) + + /// + /// Requests a JWT Authorization Grant (JAG) from an Identity Provider via RFC 8693 Token Exchange. + /// Returns the JAG string to be used as a JWT Bearer assertion (RFC 7523) against the MCP authorization server. + /// + public static async Task RequestJwtAuthorizationGrantAsync( + RequestJwtAuthGrantOptions options, + HttpClient httpClient, + CancellationToken cancellationToken = default) + { + Throw.IfNull(options); + Throw.IfNullOrWhiteSpace(options.TokenEndpoint); + Throw.IfNullOrWhiteSpace(options.Audience); + Throw.IfNullOrWhiteSpace(options.Resource); + Throw.IfNullOrWhiteSpace(options.IdToken); + Throw.IfNullOrWhiteSpace(options.ClientId); + + var formData = new Dictionary + { + ["grant_type"] = GrantTypeTokenExchange, + ["requested_token_type"] = TokenTypeIdJag, + ["subject_token"] = options.IdToken, + ["subject_token_type"] = TokenTypeIdToken, + ["audience"] = options.Audience, + ["resource"] = options.Resource, + ["client_id"] = options.ClientId, + }; + + if (!string.IsNullOrEmpty(options.ClientSecret)) + { + formData["client_secret"] = options.ClientSecret!; + } + + if (!string.IsNullOrEmpty(options.Scope)) + { + formData["scope"] = options.Scope!; + } + + using var requestContent = new FormUrlEncodedContent(formData); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) + { + Content = requestContent + }; + + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!httpResponse.IsSuccessStatusCode) + { + OAuthErrorResponse? errorResponse = null; + try + { + errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse); + } + catch + { + // Could not parse error response + } + + throw new IdentityAssertionGrantException( + $"Token exchange failed with status {(int)httpResponse.StatusCode}.", + errorResponse?.Error, + errorResponse?.ErrorDescription, + errorResponse?.ErrorUri); + } + + var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JagTokenExchangeResponse); + + if (response is null) + { + var ex = new IdentityAssertionGrantException("Failed to parse token exchange response."); + ex.Data["ResponseBody"] = responseBody; + throw ex; + } + + if (string.IsNullOrEmpty(response.AccessToken)) + { + throw new IdentityAssertionGrantException("Token exchange response missing required field: access_token"); + } + + if (!string.Equals(response.IssuedTokenType, TokenTypeIdJag, StringComparison.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"Token exchange response issued_token_type must be '{TokenTypeIdJag}', got '{response.IssuedTokenType}'."); + } + + if (!string.Equals(response.TokenType, TokenTypeNotApplicable, StringComparison.Ordinal)) + { + throw new IdentityAssertionGrantException( + $"Token exchange response token_type must be '{TokenTypeNotApplicable}' per RFC 8693 §2.2.1, got '{response.TokenType}'."); + } + + return response.AccessToken; + } + + #endregion + + #region JWT Bearer Grant (RFC 7523) + + /// + /// Exchanges a JWT Authorization Grant (JAG) for an access token at an MCP Server's authorization server + /// using the JWT Bearer grant (RFC 7523). + /// + public static async Task ExchangeJwtBearerGrantAsync( + ExchangeJwtBearerGrantOptions options, + HttpClient httpClient, + CancellationToken cancellationToken = default) + { + Throw.IfNull(options); + Throw.IfNullOrWhiteSpace(options.TokenEndpoint); + Throw.IfNullOrWhiteSpace(options.Assertion); + Throw.IfNullOrWhiteSpace(options.ClientId); + + var formData = new Dictionary + { + ["grant_type"] = GrantTypeJwtBearer, + ["assertion"] = options.Assertion, + ["client_id"] = options.ClientId, + }; + + if (!string.IsNullOrEmpty(options.ClientSecret)) + { + formData["client_secret"] = options.ClientSecret!; + } + + if (!string.IsNullOrEmpty(options.Scope)) + { + formData["scope"] = options.Scope!; + } + + using var requestContent = new FormUrlEncodedContent(formData); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, options.TokenEndpoint) + { + Content = requestContent + }; + + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var httpResponse = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var responseBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!httpResponse.IsSuccessStatusCode) + { + OAuthErrorResponse? errorResponse = null; + try + { + errorResponse = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.OAuthErrorResponse); + } + catch + { + // Could not parse error response + } + + throw new IdentityAssertionGrantException( + $"JWT bearer grant failed with status {(int)httpResponse.StatusCode}.", + errorResponse?.Error, + errorResponse?.ErrorDescription, + errorResponse?.ErrorUri); + } + + var response = JsonSerializer.Deserialize(responseBody, McpJsonUtilities.JsonContext.Default.JwtBearerAccessTokenResponse); + + if (response is null) + { + var ex = new IdentityAssertionGrantException("Failed to parse JWT bearer grant response."); + ex.Data["ResponseBody"] = responseBody; + throw ex; + } + + if (string.IsNullOrEmpty(response.AccessToken)) + { + throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: access_token"); + } + + if (string.IsNullOrEmpty(response.TokenType)) + { + throw new IdentityAssertionGrantException("JWT bearer grant response missing required field: token_type"); + } + + if (!string.Equals(response.TokenType, "bearer", StringComparison.OrdinalIgnoreCase)) + { + throw new IdentityAssertionGrantException( + $"JWT bearer grant response token_type must be 'bearer' per RFC 7523, got '{response.TokenType}'."); + } + + return new TokenContainer + { + AccessToken = response.AccessToken, + TokenType = response.TokenType, + RefreshToken = response.RefreshToken, + ExpiresIn = response.ExpiresIn, + Scope = response.Scope, + ObtainedAt = DateTimeOffset.UtcNow, + }; + } + + #endregion + + #region Helper: Auth Server Metadata Discovery + + private static readonly string[] s_wellKnownPaths = [".well-known/openid-configuration", ".well-known/oauth-authorization-server"]; + + /// + /// Discovers authorization server metadata from the well-known endpoints. + /// + internal static async Task DiscoverAuthServerMetadataAsync( + Uri issuerUrl, + HttpClient httpClient, + CancellationToken cancellationToken) + { + var baseUrl = issuerUrl.ToString(); + if (!baseUrl.EndsWith("/", StringComparison.Ordinal)) + { + issuerUrl = new Uri($"{baseUrl}/"); + } + + foreach (var path in s_wellKnownPaths) + { + try + { + var wellKnownEndpoint = new Uri(issuerUrl, path); + var response = await httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + continue; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var metadata = await JsonSerializer.DeserializeAsync( + stream, + McpJsonUtilities.JsonContext.Default.AuthorizationServerMetadata, + cancellationToken).ConfigureAwait(false); + + if (metadata is not null) + { + return metadata; + } + } + catch + { + continue; + } + } + + throw new IdentityAssertionGrantException($"Failed to discover authorization server metadata for: {issuerUrl}"); + } + + #endregion +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs new file mode 100644 index 000000000..2b956b9b9 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantContext.cs @@ -0,0 +1,20 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Context provided to the for a Cross-Application Access +/// authorization flow. Contains the URLs discovered during the OAuth flow needed for the token exchange step. +/// +public sealed class IdentityAssertionGrantContext +{ + /// + /// Gets the MCP resource server URL (i.e., the resource parameter for token exchange). + /// This is the URL of the MCP server being accessed. + /// + public required Uri ResourceUrl { get; init; } + + /// + /// Gets the MCP authorization server URL (i.e., the audience parameter for token exchange). + /// This is the URL of the authorization server protecting the MCP resource. + /// + public required Uri AuthorizationServerUrl { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs new file mode 100644 index 000000000..3dcec8082 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantException.cs @@ -0,0 +1,51 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents an error that occurred during a Cross-Application Access authorization operation +/// (token exchange per RFC 8693, and JWT bearer grant per RFC 7523). +/// +public sealed class IdentityAssertionGrantException : Exception +{ + /// + /// Gets the OAuth error code, if available (e.g., "invalid_request", "invalid_grant"). + /// + public string? ErrorCode { get; } + + /// + /// Gets the human-readable error description from the OAuth error response. + /// + public string? ErrorDescription { get; } + + /// + /// Gets the URI identifying a human-readable web page with error information. + /// + public string? ErrorUri { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The OAuth error code. + /// The human-readable error description. + /// The error URI. + public IdentityAssertionGrantException(string message, string? errorCode = null, string? errorDescription = null, string? errorUri = null) + : base(FormatMessage(message, errorCode, errorDescription)) + { + ErrorCode = errorCode; + ErrorDescription = errorDescription; + ErrorUri = errorUri; + } + + private static string FormatMessage(string message, string? errorCode, string? errorDescription) + { + if (!string.IsNullOrEmpty(errorCode)) + { + message = $"{message} Error: {errorCode}"; + if (!string.IsNullOrEmpty(errorDescription)) + { + message = $"{message} ({errorDescription})"; + } + } + return message; + } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs new file mode 100644 index 000000000..2951d1e8b --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantIdTokenCallback.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents a method that returns an OIDC ID token for use in a Cross-Application Access authorization flow. +/// +/// +/// Context containing the MCP resource and authorization server URLs discovered during the OAuth flow. +/// +/// The to monitor for cancellation requests. +/// +/// A task that represents the asynchronous operation. The task result contains the OIDC ID token string +/// obtained from the enterprise Identity Provider (e.g., via SSO login). The provider will then use this +/// ID token to perform the RFC 8693 token exchange to obtain a JWT Authorization Grant. +/// +public delegate Task IdentityAssertionGrantIdTokenCallback( + IdentityAssertionGrantContext context, + CancellationToken cancellationToken); diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs new file mode 100644 index 000000000..359c66fce --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProvider.cs @@ -0,0 +1,207 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ModelContextProtocol.Authentication; + +/// +/// Provides Cross-Application Access authorization as a standalone, non-interactive provider +/// that can be used alongside the MCP client's OAuth infrastructure. +/// +/// +/// +/// This provider implements the full Identity Assertion Authorization Grant flow as specified at +/// : +/// +/// +/// +/// The is called to obtain an OIDC ID token. +/// It receives a with the discovered resource and authorization +/// server URLs. +/// +/// +/// The provider performs the RFC 8693 token exchange at the enterprise Identity Provider +/// (using the configured IdpTokenEndpoint or discovered from IdpUrl), +/// exchanging the ID token for a JWT Authorization Grant (JAG). +/// +/// +/// The JAG is then exchanged for an access token at the MCP Server's authorization server +/// via the RFC 7523 JWT Bearer grant. +/// +/// +/// +/// +/// +/// var provider = new IdentityAssertionGrantProvider( +/// new IdentityAssertionGrantProviderOptions +/// { +/// ClientId = "mcp-client-id", +/// IdpTokenEndpoint = "https://company.okta.com/oauth2/token", +/// IdpClientId = "idp-client-id", +/// IdTokenCallback = (context, ct) => +/// mySsoClient.GetIdTokenAsync(ct) +/// }, +/// httpClient: myHttpClient); +/// +/// var tokens = await provider.GetAccessTokenAsync( +/// resourceUrl: new Uri("https://mcp-server.example.com"), +/// authorizationServerUrl: new Uri("https://auth.example.com"), +/// cancellationToken: ct); +/// +/// +public sealed class IdentityAssertionGrantProvider +{ + private readonly IdentityAssertionGrantProviderOptions _options; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private TokenContainer? _cachedTokens; + + /// + /// Initializes a new instance of the class. + /// + /// Configuration for the Cross-Application Access provider. + /// + /// The HTTP client to use for token exchange requests. The caller is responsible for the lifetime of this instance. + /// + /// Optional logger factory. + /// or is null. + /// Required option values are missing. + public IdentityAssertionGrantProvider( + IdentityAssertionGrantProviderOptions options, + HttpClient httpClient, + ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(options); + Throw.IfNull(httpClient); + + Throw.IfNullOrWhiteSpace(options.ClientId); + Throw.IfNullOrWhiteSpace(options.IdpClientId); + + if (string.IsNullOrEmpty(options.IdpUrl) && string.IsNullOrEmpty(options.IdpTokenEndpoint)) + { + throw new ArgumentException("Either IdpUrl or IdpTokenEndpoint is required.", $"{nameof(options)}.{nameof(options.IdpUrl)}"); + } + + if (options.IdTokenCallback is null) + { + throw new ArgumentNullException($"{nameof(options)}.{nameof(options.IdTokenCallback)}"); + } + + _options = options; + _httpClient = httpClient; + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; + } + + /// + /// Performs the full Cross-Application Access flow to obtain an access token for the given MCP resource. + /// + /// The MCP resource server URL. + /// The MCP authorization server URL. + /// The to monitor for cancellation requests. + /// A containing the access token. + /// Thrown when any step of the flow fails. + public async Task GetAccessTokenAsync( + Uri resourceUrl, + Uri authorizationServerUrl, + CancellationToken cancellationToken = default) + { + // Return cached token if still valid + if (_cachedTokens is not null && !_cachedTokens.IsExpired) + { + return _cachedTokens; + } + + _logger.LogDebug("Starting Cross-Application Access flow for resource {ResourceUrl}", resourceUrl); + + // Step 1: Discover MCP authorization server metadata to find the token endpoint + var mcpAuthMetadata = await IdentityAssertionGrant.DiscoverAuthServerMetadataAsync( + authorizationServerUrl, _httpClient, cancellationToken).ConfigureAwait(false); + + var mcpTokenEndpoint = mcpAuthMetadata.TokenEndpoint?.ToString() + ?? throw new IdentityAssertionGrantException( + $"MCP authorization server metadata at {authorizationServerUrl} missing token_endpoint."); + + // Step 2: Call the ID token callback to get the caller's OIDC ID token + var context = new IdentityAssertionGrantContext + { + ResourceUrl = resourceUrl, + AuthorizationServerUrl = authorizationServerUrl, + }; + + _logger.LogDebug("Requesting ID token via callback"); + var idToken = await _options.IdTokenCallback(context, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(idToken)) + { + throw new IdentityAssertionGrantException("ID token callback returned a null or empty token."); + } + + // Step 3: RFC 8693 token exchange — ID token → JWT Authorization Grant (JAG) at the enterprise IdP + _logger.LogDebug("Performing RFC 8693 token exchange at IdP"); + var idpTokenEndpoint = await ResolveIdpTokenEndpointAsync(cancellationToken).ConfigureAwait(false); + + var jag = await IdentityAssertionGrant.RequestJwtAuthorizationGrantAsync( + new RequestJwtAuthGrantOptions + { + TokenEndpoint = idpTokenEndpoint, + Audience = authorizationServerUrl.ToString(), + Resource = resourceUrl.ToString(), + IdToken = idToken, + ClientId = _options.IdpClientId, + ClientSecret = _options.IdpClientSecret, + Scope = _options.IdpScope, + }, _httpClient, cancellationToken).ConfigureAwait(false); + + // Step 4: RFC 7523 JWT bearer grant — JAG → access token at the MCP authorization server + _logger.LogDebug("Exchanging JAG for access token at {McpTokenEndpoint}", mcpTokenEndpoint); + var tokens = await IdentityAssertionGrant.ExchangeJwtBearerGrantAsync( + new ExchangeJwtBearerGrantOptions + { + TokenEndpoint = mcpTokenEndpoint, + Assertion = jag, + ClientId = _options.ClientId, + ClientSecret = _options.ClientSecret, + Scope = _options.Scope, + }, _httpClient, cancellationToken).ConfigureAwait(false); + + _cachedTokens = tokens; + _logger.LogDebug("Cross-Application Access flow completed successfully"); + + return tokens; + } + + /// + /// Clears any cached tokens, forcing a fresh token exchange on the next call to . + /// + public void InvalidateCache() + { + _cachedTokens = null; + } + + private string? _resolvedIdpTokenEndpoint; + + private async Task ResolveIdpTokenEndpointAsync(CancellationToken cancellationToken) + { + if (_resolvedIdpTokenEndpoint is not null) + { + return _resolvedIdpTokenEndpoint; + } + + if (!string.IsNullOrEmpty(_options.IdpTokenEndpoint)) + { + _resolvedIdpTokenEndpoint = _options.IdpTokenEndpoint!; + return _resolvedIdpTokenEndpoint; + } + + // Discover from IdpUrl + var idpMetadata = await IdentityAssertionGrant.DiscoverAuthServerMetadataAsync( + new Uri(_options.IdpUrl!), _httpClient, cancellationToken).ConfigureAwait(false); + + var resolved = idpMetadata.TokenEndpoint?.ToString() + ?? throw new IdentityAssertionGrantException( + $"IdP metadata discovery for {_options.IdpUrl} did not return a token_endpoint."); + + _resolvedIdpTokenEndpoint = resolved; + return resolved; + } +} diff --git a/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs new file mode 100644 index 000000000..c6cc7f8b6 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/IdentityAssertionGrantProviderOptions.cs @@ -0,0 +1,68 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Configuration options for the . +/// +public sealed class IdentityAssertionGrantProviderOptions +{ + /// + /// Gets or sets the MCP client ID used for the JWT Bearer grant (RFC 7523) at the MCP authorization server. + /// + public required string ClientId { get; set; } + + /// + /// Gets or sets the MCP client secret used for the JWT Bearer grant at the MCP authorization server. + /// Optional; only required if the MCP authorization server requires client authentication. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request from the MCP authorization server (space-separated). Optional. + /// + public string? Scope { get; set; } + + /// + /// Gets or sets the enterprise Identity Provider base URL for OAuth/OIDC metadata discovery. + /// Used to discover IdpTokenEndpoint automatically when is not set. + /// Either this or must be provided. + /// + public string? IdpUrl { get; set; } + + /// + /// Gets or sets the enterprise Identity Provider token endpoint URL for RFC 8693 token exchange. + /// When provided, skips IdP metadata discovery. Either this or must be provided. + /// + public string? IdpTokenEndpoint { get; set; } + + /// + /// Gets or sets the client ID for authentication with the enterprise Identity Provider (RFC 8693 token exchange). + /// + public required string IdpClientId { get; set; } + + /// + /// Gets or sets the client secret for authentication with the enterprise Identity Provider. Optional. + /// + public string? IdpClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request from the enterprise Identity Provider (space-separated). Optional. + /// + public string? IdpScope { get; set; } + + /// + /// Gets or sets the callback that supplies the OIDC ID token for the Cross-Application Access flow. + /// + /// + /// + /// This callback is invoked after the MCP resource and authorization server URLs have been discovered. + /// It receives a with these URLs and should return the + /// OIDC ID token string obtained from the enterprise Identity Provider (e.g., from an SSO login session). + /// + /// + /// The provider will use the returned ID token to internally perform the RFC 8693 token exchange at the + /// configured IdP, obtaining a JWT Authorization Grant, which is then exchanged for an access token at + /// the MCP authorization server via RFC 7523. + /// + /// + public required IdentityAssertionGrantIdTokenCallback IdTokenCallback { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs b/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs new file mode 100644 index 000000000..35a08f646 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/JagTokenExchangeResponse.cs @@ -0,0 +1,40 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the response from an RFC 8693 Token Exchange for the JAG flow. +/// Contains the JWT Authorization Grant in the field. +/// +internal sealed class JagTokenExchangeResponse +{ + /// + /// Gets or sets the issued JAG. Despite the name "access_token" (required by RFC 8693), + /// this contains a JAG JWT, not an OAuth access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + /// + /// Gets or sets the type of the security token issued. + /// This MUST be . + /// + [System.Text.Json.Serialization.JsonPropertyName("issued_token_type")] + public string IssuedTokenType { get; set; } = null!; + + /// + /// Gets or sets the token type. This MUST be "N_A" per RFC 8693 §2.2.1. + /// + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; set; } = null!; + + /// + /// Gets or sets the scope of the issued token, if different from the request. + /// + [System.Text.Json.Serialization.JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// Gets or sets the lifetime in seconds of the issued token. + /// + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs b/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs new file mode 100644 index 000000000..9a0a4004e --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/JwtBearerAccessTokenResponse.cs @@ -0,0 +1,37 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents the response from a JWT Bearer grant (RFC 7523) access token request. +/// +internal sealed class JwtBearerAccessTokenResponse +{ + /// + /// Gets or sets the OAuth access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + /// + /// Gets or sets the token type. This should be "Bearer". + /// + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; set; } = null!; + + /// + /// Gets or sets the lifetime in seconds of the access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + + /// + /// Gets or sets the refresh token. + /// + [System.Text.Json.Serialization.JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + /// + /// Gets or sets the scope of the access token. + /// + [System.Text.Json.Serialization.JsonPropertyName("scope")] + public string? Scope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs b/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs new file mode 100644 index 000000000..a8822fa32 --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/OAuthErrorResponse.cs @@ -0,0 +1,26 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Represents an OAuth error response per RFC 6749 Section 5.2. +/// Used for both token exchange and JWT bearer grant error responses. +/// +internal sealed class OAuthErrorResponse +{ + /// + /// Gets or sets the error code. + /// + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; set; } + + /// + /// Gets or sets the human-readable error description. + /// + [System.Text.Json.Serialization.JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + + /// + /// Gets or sets the URI identifying a human-readable web page with error information. + /// + [System.Text.Json.Serialization.JsonPropertyName("error_uri")] + public string? ErrorUri { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs new file mode 100644 index 000000000..7e83b198d --- /dev/null +++ b/src/ModelContextProtocol.Core/Authentication/RequestJwtAuthGrantOptions.cs @@ -0,0 +1,42 @@ +namespace ModelContextProtocol.Authentication; + +/// +/// Options for requesting a JWT Authorization Grant from an Identity Provider via RFC 8693 Token Exchange. +/// +internal sealed class RequestJwtAuthGrantOptions +{ + /// + /// Gets or sets the IDP's token endpoint URL. + /// + public required string TokenEndpoint { get; set; } + + /// + /// Gets or sets the MCP authorization server URL (used as the audience parameter). + /// + public required string Audience { get; set; } + + /// + /// Gets or sets the MCP resource server URL (used as the resource parameter). + /// + public required string Resource { get; set; } + + /// + /// Gets or sets the OIDC ID token to exchange. + /// + public required string IdToken { get; set; } + + /// + /// Gets or sets the client ID for authentication with the IDP. + /// + public required string ClientId { get; set; } + + /// + /// Gets or sets the client secret for authentication with the IDP. Optional. + /// + public string? ClientSecret { get; set; } + + /// + /// Gets or sets the scopes to request (space-separated). Optional. + /// + public string? Scope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index abb6d29df..70eb30d0d 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -187,6 +187,12 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(DynamicClientRegistrationRequest))] [JsonSerializable(typeof(DynamicClientRegistrationResponse))] + // For Enterprise Managed Authorization flow as specified at + // https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx + [JsonSerializable(typeof(JagTokenExchangeResponse))] + [JsonSerializable(typeof(JwtBearerAccessTokenResponse))] + [JsonSerializable(typeof(OAuthErrorResponse))] + // Primitive types for use in consuming AIFunctions [JsonSerializable(typeof(string))] [JsonSerializable(typeof(byte))] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs new file mode 100644 index 000000000..6972fa4b4 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/IdentityAssertionGrantIntegrationTests.cs @@ -0,0 +1,167 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using System.Net.Http.Headers; + +namespace ModelContextProtocol.AspNetCore.Tests.OAuth; + +/// +/// Integration tests for Cross-Application Access authorization using the in-memory +/// test OAuth server as a stand-in for both the enterprise Identity Provider (IdP) and +/// the MCP Authorization Server (AS). +/// +/// Flow exercised: +/// 1. discovers the MCP AS +/// metadata and calls the ID token callback. +/// 2. The provider performs RFC 8693 token exchange at /idp/token on the test OAuth server +/// (ID token → JAG). +/// 3. The provider exchanges the JAG for an access token at /token +/// (RFC 7523 JWT-bearer grant: JAG → access token). +/// 4. The access token is passed to the MCP client transport and used to authenticate +/// against the protected MCP server. +/// +public class IdentityAssertionGrantIntegrationTests : OAuthTestBase +{ + public IdentityAssertionGrantIntegrationTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + [Fact] + public async Task CanAuthenticate_WithIdentityAssertionGrantProvider() + { + // Enable Enterprise Managed Authorization endpoints on the test OAuth server. + TestOAuthServer.EnterpriseSupportEnabled = true; + + await using var app = await StartMcpServerAsync(); + + // Simulate the enterprise ID token that would normally come from the SSO login step. + const string simulatedIdToken = "test-enterprise-sso-id-token"; + + // Create the provider with IdP config folded into options. + // The ID token callback just returns the SSO ID token; the provider performs + // RFC 8693 (ID token → JAG) and RFC 7523 (JAG → access token) internally. + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token", + IdpClientId = "enterprise-idp-client", + IdpClientSecret = "enterprise-idp-secret", + IdTokenCallback = (_, ct) => Task.FromResult(simulatedIdToken), + }, + httpClient: HttpClient); + + // Run the full Cross-Application Access flow: discover AS → get JAG → exchange for access token. + var tokens = await provider.GetAccessTokenAsync( + resourceUrl: new Uri(McpServerUrl), + authorizationServerUrl: new Uri(OAuthServerUrl), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(tokens.AccessToken); + Assert.False(string.IsNullOrEmpty(tokens.AccessToken)); + Assert.Equal("bearer", tokens.TokenType, ignoreCase: true); + + // Wire the obtained access token into an HTTP client that shares the same + // in-memory Kestrel transport as the rest of the test fixture. + var mcpHttpClient = new HttpClient(SocketsHttpHandler, disposeHandler: false); + ConfigureHttpClient(mcpHttpClient); + mcpHttpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + + // Connect the MCP client using the enterprise access token — no interactive OAuth flow. + await using var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new Uri(McpServerUrl) }, + mcpHttpClient, + LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // If we get here the MCP server accepted the enterprise access token. + Assert.NotNull(client); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_ReturnsCachedToken_OnSecondCall() + { + TestOAuthServer.EnterpriseSupportEnabled = true; + + await using var _ = await StartMcpServerAsync(); + + var idTokenCallCount = 0; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token", + IdpClientId = "enterprise-idp-client", + IdpClientSecret = "enterprise-idp-secret", + IdTokenCallback = (_, ct) => + { + idTokenCallCount++; + return Task.FromResult("test-sso-token"); + }, + }, + httpClient: HttpClient); + + var tokens1 = await provider.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + var tokens2 = await provider.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + // The ID token callback (and therefore the IdP round-trip) should only fire once. + Assert.Equal(1, idTokenCallCount); + Assert.Equal(tokens1.AccessToken, tokens2.AccessToken); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_FetchesFreshToken_AfterInvalidateCache() + { + TestOAuthServer.EnterpriseSupportEnabled = true; + + await using var _ = await StartMcpServerAsync(); + + var idTokenCallCount2 = 0; + + var provider2 = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + IdpTokenEndpoint = $"{OAuthServerUrl}/idp/token", + IdpClientId = "enterprise-idp-client", + IdpClientSecret = "enterprise-idp-secret", + IdTokenCallback = (_, ct) => + { + idTokenCallCount2++; + return Task.FromResult("test-sso-token"); + }, + }, + httpClient: HttpClient); + + var tokens1 = await provider2.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + // Invalidate the cache to force a full re-exchange. + provider2.InvalidateCache(); + + var tokens2 = await provider2.GetAccessTokenAsync( + new Uri(McpServerUrl), new Uri(OAuthServerUrl), + TestContext.Current.CancellationToken); + + // The IdP should have been called twice — once for each GetAccessTokenAsync after invalidation. + Assert.Equal(2, idTokenCallCount2); + // The tokens may or may not be identical depending on timing, but the flow ran again. + Assert.NotNull(tokens2.AccessToken); + } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs b/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs new file mode 100644 index 000000000..cae8a943d --- /dev/null +++ b/tests/ModelContextProtocol.TestOAuthServer/JagTokenExchangeResponse.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.TestOAuthServer; + +/// +/// Represents the token exchange response for the Identity Assertion JWT Authorization Grant (ID-JAG) +/// per RFC 8693 / SEP-990. +/// +internal sealed class JagTokenExchangeResponse +{ + /// + /// Gets or sets the issued JWT Authorization Grant (JAG). + /// Despite the field name "access_token" (required by RFC 8693), this contains a JAG JWT, + /// not an OAuth access token. + /// + [JsonPropertyName("access_token")] + public required string AccessToken { get; init; } + + /// + /// Gets or sets the type of security token issued. + /// For SEP-990, this MUST be "urn:ietf:params:oauth:token-type:id-jag". + /// + [JsonPropertyName("issued_token_type")] + public required string IssuedTokenType { get; init; } + + /// + /// Gets or sets the token type. + /// For SEP-990, this MUST be "N_A" per RFC 8693 §2.2.1 because the JAG is not an access token. + /// + [JsonPropertyName("token_type")] + public required string TokenType { get; init; } + + /// + /// Gets or sets the lifetime in seconds of the issued JAG. + /// + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } +} diff --git a/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs b/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs index 6caaaea01..e8c98275a 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/OAuthJsonContext.cs @@ -5,6 +5,7 @@ namespace ModelContextProtocol.TestOAuthServer; [JsonSerializable(typeof(OAuthServerMetadata))] [JsonSerializable(typeof(AuthorizationServerMetadata))] [JsonSerializable(typeof(TokenResponse))] +[JsonSerializable(typeof(JagTokenExchangeResponse))] [JsonSerializable(typeof(JsonWebKeySet))] [JsonSerializable(typeof(JsonWebKey))] [JsonSerializable(typeof(TokenIntrospectionResponse))] diff --git a/tests/ModelContextProtocol.TestOAuthServer/Program.cs b/tests/ModelContextProtocol.TestOAuthServer/Program.cs index 68600f81d..69eb60683 100644 --- a/tests/ModelContextProtocol.TestOAuthServer/Program.cs +++ b/tests/ModelContextProtocol.TestOAuthServer/Program.cs @@ -57,6 +57,18 @@ public Program(ILoggerProvider? loggerProvider = null, IConnectionListenerFactor // Track if we've already issued an already-expired token for the CanAuthenticate_WithTokenRefresh test which uses the test-refresh-client registration. public bool HasRefreshedToken { get; set; } + /// + /// Gets or sets a value indicating whether the server supports the Enterprise Managed + /// Authorization (SEP-990) flow, including the IdP token-exchange endpoint and the + /// JWT-bearer grant type at the token endpoint. + /// + /// + /// When true, the server registers enterprise test clients and activates the + /// /idp/token endpoint (RFC 8693 token exchange) and the + /// urn:ietf:params:oauth:grant-type:jwt-bearer grant type (RFC 7523). + /// + public bool EnterpriseSupportEnabled { get; set; } + /// /// Gets or sets a value indicating whether the authorization server /// advertises support for client ID metadata documents in its discovery @@ -171,6 +183,25 @@ public async Task RunServerAsync(string[]? args = null, CancellationToken cancel RedirectUris = ["http://localhost:1179/callback"], }; + // Enterprise Auth (SEP-990) clients. + // The IdP client is used to authenticate calls to /idp/token (token exchange). + // The MCP client is used to authenticate calls to /token (jwt-bearer grant). + // Neither needs redirect URIs because neither uses the authorization code flow. + _clients["enterprise-idp-client"] = new ClientInfo + { + ClientId = "enterprise-idp-client", + ClientSecret = "enterprise-idp-secret", + RequiresClientSecret = true, + RedirectUris = [], + }; + _clients["enterprise-mcp-client"] = new ClientInfo + { + ClientId = "enterprise-mcp-client", + ClientSecret = "enterprise-mcp-secret", + RequiresClientSecret = true, + RedirectUris = [], + }; + // The MCP spec tells the client to use /.well-known/oauth-authorization-server but AddJwtBearer looks for // /.well-known/openid-configuration by default. // @@ -363,10 +394,18 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) type: "https://tools.ietf.org/html/rfc6749#section-5.2"); } + // Read grant type early so we can skip resource validation for grant types that + // don't use the resource parameter (e.g. jwt-bearer where the resource is embedded + // inside the JWT assertion itself). + var grant_type = form["grant_type"].ToString(); + // Validate resource in accordance with RFC 8707. // When ExpectResource is false, the resource parameter must be absent (legacy mode). + // RFC 7523 JWT-bearer assertions carry the target resource inside the JWT itself, + // so we skip the form-level resource check for that grant type. var resource = form["resource"].ToString(); - if (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource)) + if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer" && + (ExpectResource ? (string.IsNullOrEmpty(resource) || !ValidResources.Contains(resource)) : !string.IsNullOrEmpty(resource))) { return Results.BadRequest(new OAuthErrorResponse { @@ -375,7 +414,6 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) }); } - var grant_type = form["grant_type"].ToString(); if (grant_type == "authorization_code") { var code = form["code"].ToString(); @@ -452,6 +490,45 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) HasRefreshedToken = true; return Results.Ok(response); } + else if (grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer") + { + if (!EnterpriseSupportEnabled) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "unsupported_grant_type", + ErrorDescription = "JWT bearer grant is not enabled on this server." + }); + } + + var assertion = form["assertion"].ToString(); + if (string.IsNullOrEmpty(assertion)) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "invalid_request", + ErrorDescription = "assertion is required for jwt-bearer grant" + }); + } + + // Extract the target resource from the JAG payload (set during /idp/token). + // Fall back to ValidResources[0] so the token is still usable in tests even + // if the resource claim is absent. + var jagResource = ExtractJwtClaim(assertion, "resource"); + if (string.IsNullOrEmpty(jagResource) || !ValidResources.Contains(jagResource)) + { + jagResource = ValidResources.Length > 0 ? ValidResources[0] : null; + } + + var resourceUri = jagResource is not null ? new Uri(jagResource) : null; + var scope = form["scope"].ToString(); + var scopes = string.IsNullOrEmpty(scope) + ? ["mcp:tools"] + : scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + + var response = GenerateJwtTokenResponse(client.ClientId, scopes, resourceUri); + return Results.Ok(response); + } else { return Results.BadRequest(new OAuthErrorResponse @@ -462,6 +539,77 @@ IResult HandleMetadataRequest(HttpContext context, string? issuerPath = null) } }); + // IdP token-exchange endpoint (RFC 8693) for Enterprise Managed Authorization (SEP-990). + // Exchanges an enterprise ID token (from SSO) for a JWT Authorization Grant (JAG) + // that can subsequently be used at the /token endpoint via the jwt-bearer grant. + app.MapPost("/idp/token", async (HttpContext context) => + { + if (!EnterpriseSupportEnabled) + { + return Results.NotFound(); + } + + var form = await context.Request.ReadFormAsync(); + + // Authenticate the IdP client. + var client = AuthenticateClient(context, form); + if (client == null) + { + context.Response.StatusCode = 401; + return Results.Problem( + statusCode: 401, + title: "Unauthorized", + detail: "Invalid client credentials", + type: "https://tools.ietf.org/html/rfc6749#section-5.2"); + } + + var grantType = form["grant_type"].ToString(); + if (grantType != "urn:ietf:params:oauth:grant-type:token-exchange") + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "unsupported_grant_type", + ErrorDescription = "Only urn:ietf:params:oauth:grant-type:token-exchange is supported on this endpoint." + }); + } + + var subjectToken = form["subject_token"].ToString(); + if (string.IsNullOrEmpty(subjectToken)) + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "invalid_request", + ErrorDescription = "subject_token is required." + }); + } + + var requestedTokenType = form["requested_token_type"].ToString(); + if (requestedTokenType != "urn:ietf:params:oauth:token-type:id-jag") + { + return Results.BadRequest(new OAuthErrorResponse + { + Error = "invalid_request", + ErrorDescription = "requested_token_type must be urn:ietf:params:oauth:token-type:id-jag." + }); + } + + var audience = form["audience"].ToString(); + var resourceParam = form["resource"].ToString(); + + // Generate a JAG JWT signed with the server's RSA key. + // The JAG encodes the intended audience (MCP AS) and resource (MCP server) so + // the /token endpoint can later issue a correctly-scoped access token. + var jag = GenerateJagJwt(audience, resourceParam); + + return Results.Ok(new JagTokenExchangeResponse + { + AccessToken = jag, + IssuedTokenType = "urn:ietf:params:oauth:token-type:id-jag", + TokenType = "N_A", + ExpiresIn = 300, + }); + }); + // Introspection endpoint app.MapPost("/introspect", async (HttpContext context) => { @@ -692,6 +840,70 @@ private TokenResponse GenerateJwtTokenResponse(string clientId, List sco }; } + /// + /// Generates a JWT Authorization Grant (JAG) signed with the server's RSA key. + /// The JAG encodes the target audience (MCP AS URL) and the resource (MCP server URL). + /// + private string GenerateJagJwt(string audience, string resource) + { + var expiresIn = TimeSpan.FromMinutes(5); + var issuedAt = DateTimeOffset.UtcNow; + var expiresAt = issuedAt.Add(expiresIn); + + var header = new Dictionary + { + { "alg", "RS256" }, + { "typ", "JWT" }, + { "kid", _keyId }, + }; + + var payload = new Dictionary + { + { "iss", _url }, + { "sub", "enterprise-user" }, + { "aud", audience }, + { "resource", resource }, // carried through so /token can issue the right audience + { "jti", Guid.NewGuid().ToString() }, + { "iat", issuedAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) }, + { "exp", expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) }, + }; + + var headerJson = System.Text.Json.JsonSerializer.Serialize(header, OAuthJsonContext.Default.DictionaryStringString); + var payloadJson = System.Text.Json.JsonSerializer.Serialize(payload, OAuthJsonContext.Default.DictionaryStringString); + + var headerBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + var payloadBase64 = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + var dataToSign = $"{headerBase64}.{payloadBase64}"; + var signature = _rsa.SignData(Encoding.UTF8.GetBytes(dataToSign), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return $"{headerBase64}.{payloadBase64}.{WebEncoders.Base64UrlEncode(signature)}"; + } + + /// + /// Decodes a JWT payload (without signature verification) and returns the value of + /// , or null if the claim is absent or the JWT is malformed. + /// + private static string? ExtractJwtClaim(string jwt, string claimName) + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return null; + } + + try + { + var payloadJson = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(parts[1])); + var payload = System.Text.Json.JsonSerializer.Deserialize(payloadJson, OAuthJsonContext.Default.DictionaryStringString); + return payload?.TryGetValue(claimName, out var value) == true ? value : null; + } + catch + { + return null; + } + } + /// /// Generates a random token for authorization code or refresh token. /// diff --git a/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs new file mode 100644 index 000000000..44afcceb6 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/IdentityAssertionGrantTests.cs @@ -0,0 +1,389 @@ +using System.Net; +using System.Text.Json.Nodes; +using ModelContextProtocol.Authentication; + +namespace ModelContextProtocol.Tests; + +public sealed class IdentityAssertionGrantTests : IDisposable +{ + private readonly MockHttpMessageHandler _mockHandler; + private readonly HttpClient _httpClient; + + public IdentityAssertionGrantTests() + { + _mockHandler = new MockHttpMessageHandler(); + _httpClient = new HttpClient(_mockHandler); + } + + public void Dispose() + { + _httpClient.Dispose(); + _mockHandler.Dispose(); + } + + #region IdentityAssertionGrantProvider Tests + + [Fact] + public async Task IdentityAssertionGrantProvider_FullFlow_ReturnsAccessToken() + { + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + + if (url.Contains(".well-known/openid-configuration")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["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", + }); + } + + if (url.Contains("idp.example.com/token")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag-assertion", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + if (url.Contains("auth.mcp-server.example.com/token")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "final-access-token", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "mcp-client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (context, ct) => + { + Assert.Equal(new Uri("https://mcp-server.example.com"), context.ResourceUrl); + Assert.Equal(new Uri("https://auth.mcp-server.example.com"), context.AuthorizationServerUrl); + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var tokens = await provider.GetAccessTokenAsync( + resourceUrl: new Uri("https://mcp-server.example.com"), + authorizationServerUrl: new Uri("https://auth.mcp-server.example.com"), + TestContext.Current.CancellationToken); + + Assert.Equal("final-access-token", tokens.AccessToken); + Assert.Equal("Bearer", tokens.TokenType); + Assert.Equal(3600, tokens.ExpiresIn); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_CachesTokens() + { + var mcpTokenCallCount = 0; + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + mcpTokenCallCount++; + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "cached-token", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + }; + + var idTokenCallCount = 0; + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => + { + idTokenCallCount++; + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var ct = TestContext.Current.CancellationToken; + + var firstTokens = await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + var secondTokens = await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + Assert.Same(firstTokens, secondTokens); + Assert.Equal(1, idTokenCallCount); + Assert.Equal(1, mcpTokenCallCount); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_InvalidateCache_ForcesRefresh() + { + var idTokenCallCount = 0; + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + if (url.Contains("idp.example.com")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = "mock-jag", + ["issued_token_type"] = "urn:ietf:params:oauth:token-type:id-jag", + ["token_type"] = "N_A", + }); + } + + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["access_token"] = $"token-{idTokenCallCount}", + ["token_type"] = "Bearer", + ["expires_in"] = 3600, + }); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => + { + idTokenCallCount++; + return Task.FromResult("mock-id-token"); + }, + }, + _httpClient); + + var ct = TestContext.Current.CancellationToken; + + await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + provider.InvalidateCache(); + + await provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), ct); + + Assert.Equal(2, idTokenCallCount); + } + + [Fact] + public async Task IdentityAssertionGrantProvider_IdTokenCallbackReturnsEmpty_ThrowsException() + { + _mockHandler.Handler = request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains(".well-known")) + { + return JsonResponse(HttpStatusCode.OK, new JsonObject + { + ["authorization_endpoint"] = "https://auth.example.com/authorize", + ["token_endpoint"] = "https://auth.example.com/token", + }); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }; + + var provider = new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult(string.Empty), + }, + _httpClient); + + await Assert.ThrowsAsync( + () => provider.GetAccessTokenAsync( + new Uri("https://resource.example.com"), + new Uri("https://auth.example.com"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public void IdentityAssertionGrantProvider_NullOptions_ThrowsArgumentNullException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider(null!, _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_NullHttpClient_ThrowsArgumentNullException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("token"), + }, + null!)); + } + + [Fact] + public void IdentityAssertionGrantProvider_MissingClientId_ThrowsArgumentException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = (_, _) => Task.FromResult("test"), + }, + _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_MissingIdTokenCallback_ThrowsArgumentNullException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpTokenEndpoint = "https://idp.example.com/token", + IdpClientId = "idp-client-id", + IdTokenCallback = null!, + }, + _httpClient)); + } + + [Fact] + public void IdentityAssertionGrantProvider_MissingIdpConfig_ThrowsArgumentException() + { + Assert.Throws(() => new IdentityAssertionGrantProvider( + new IdentityAssertionGrantProviderOptions + { + ClientId = "client-id", + IdpClientId = "idp-client-id", + // Neither IdpUrl nor IdpTokenEndpoint provided + IdTokenCallback = (_, _) => Task.FromResult("test"), + }, + _httpClient)); + } + + #endregion + + #region IdentityAssertionGrantException Tests + + [Fact] + public void IdentityAssertionGrantException_WithErrorCodeAndDescription_FormatsMessage() + { + var ex = new IdentityAssertionGrantException("Base message", "invalid_grant", "Token expired"); + + Assert.Contains("Base message", ex.Message); + Assert.Contains("invalid_grant", ex.Message); + Assert.Contains("Token expired", ex.Message); + Assert.Equal("invalid_grant", ex.ErrorCode); + Assert.Equal("Token expired", ex.ErrorDescription); + } + + [Fact] + public void IdentityAssertionGrantException_WithErrorUri_StoresIt() + { + var ex = new IdentityAssertionGrantException("msg", "error", "desc", "https://docs.example.com/error"); + + Assert.Equal("https://docs.example.com/error", ex.ErrorUri); + } + + [Fact] + public void IdentityAssertionGrantException_WithoutErrorDetails_PlainMessage() + { + var ex = new IdentityAssertionGrantException("Simple error"); + + Assert.Equal("Simple error", ex.Message); + Assert.Null(ex.ErrorCode); + Assert.Null(ex.ErrorDescription); + Assert.Null(ex.ErrorUri); + } + + #endregion + + #region Helpers + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, JsonObject payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(payload.ToJsonString(), System.Text.Encoding.UTF8, "application/json") + }; + } + + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + public Func? Handler { get; set; } + public Func>? AsyncHandler { get; set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (AsyncHandler is not null) + { + return await AsyncHandler(request); + } + + if (Handler is not null) + { + return Handler(request); + } + + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("No mock response configured") + }; + } + } + + #endregion +} From f6dbe4372f9018f49954e9af584798749e293378 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 3 Jun 2026 16:19:31 -0700 Subject: [PATCH 12/50] Multi Round-Trip Requests (MRTR) (#1458) --- docs/concepts/elicitation/elicitation.md | 55 ++ docs/concepts/index.md | 1 + docs/concepts/mrtr/mrtr.md | 291 +++++++ docs/concepts/roots/roots.md | 40 + docs/concepts/sampling/sampling.md | 52 ++ docs/concepts/toc.yml | 2 + src/Common/Experimentals.cs | 19 + .../ModelContextProtocol.AspNetCore.csproj | 1 + .../StreamableHttpHandler.cs | 23 +- .../Client/McpClientImpl.cs | 209 ++++- .../McpJsonUtilities.cs | 9 +- src/ModelContextProtocol.Core/McpSession.cs | 2 +- .../McpSessionHandler.cs | 16 +- .../Protocol/InputRequest.cs | 197 +++++ .../Protocol/InputRequiredException.cs | 109 +++ .../Protocol/InputRequiredResult.cs | 65 ++ .../Protocol/InputResponse.cs | 128 +++ .../Protocol/JsonRpcMessageContext.cs | 11 + .../Protocol/RequestParams.cs | 47 ++ .../Protocol/Result.cs | 14 + .../Server/DestinationBoundMcpServer.cs | 42 +- .../Server/McpServer.cs | 19 + .../Server/McpServerImpl.cs | 506 +++++++++++- .../Server/MrtrContext.cs | 78 ++ .../Server/MrtrContinuation.cs | 50 ++ .../Server/MrtrExchange.cs | 41 + tests/Common/Utils/NodeHelpers.cs | 38 + tests/Common/Utils/ServerMessageTracker.cs | 95 +++ .../MapMcpStatelessTests.cs | 40 +- .../MapMcpStreamableHttpTests.cs | 47 +- .../MapMcpTests.Mrtr.cs | 779 ++++++++++++++++++ .../MapMcpTests.cs | 60 +- .../MrtrProtocolTests.cs | 469 +++++++++++ .../ServerConformanceTests.cs | 28 + .../Program.cs | 2 + .../Prompts/IncompleteResultPrompts.cs | 68 ++ .../Tools/IncompleteResultTools.cs | 279 +++++++ .../Client/McpClientTests.cs | 8 + .../Client/MrtrIntegrationTests.cs | 570 +++++++++++++ ...rverBuilderExtensionsMessageFilterTests.cs | 32 +- .../Protocol/MrtrSerializationTests.cs | 298 +++++++ .../Server/DraftProtocolBackcompatTests.cs | 151 ++++ .../Server/MrtrHandlerLifecycleTests.cs | 438 ++++++++++ .../Server/MrtrInputRequiredExceptionTests.cs | 61 ++ .../Server/MrtrMessageFilterTests.cs | 149 ++++ .../Server/MrtrServerBackcompatTests.cs | 113 +++ .../Server/MrtrSessionLimitTests.cs | 183 ++++ 47 files changed, 5832 insertions(+), 103 deletions(-) create mode 100644 docs/concepts/mrtr/mrtr.md create mode 100644 src/ModelContextProtocol.Core/Protocol/InputRequest.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/InputResponse.cs create mode 100644 src/ModelContextProtocol.Core/Server/MrtrContext.cs create mode 100644 src/ModelContextProtocol.Core/Server/MrtrContinuation.cs create mode 100644 src/ModelContextProtocol.Core/Server/MrtrExchange.cs create mode 100644 tests/Common/Utils/ServerMessageTracker.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs create mode 100644 tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs create mode 100644 tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 94597fa5f..78782bfbb 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -170,6 +170,61 @@ Here's an example implementation of how a console application might handle elici [!code-csharp[](samples/client/Program.cs?name=snippet_ElicitationHandler)] +### Multi Round-Trip Requests (MRTR) + +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. + +> [!IMPORTANT] +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. + +For example: + +```csharp +[McpServerTool, Description("Tool that elicits via MRTR")] +public static string ElicitWithMrtr( + McpServer server, + RequestContext context) +{ + // On retry, process the client's elicitation response + if (context.Params!.InputResponses?.TryGetValue("user_input", out var response) is true) + { + var elicitResult = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + return elicitResult?.Action == "accept" + ? $"User accepted: {elicitResult.Content?.FirstOrDefault().Value}" + : "User declined."; + } + + if (!server.IsMrtrSupported) + { + return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + } + + // First call — request user input + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm the action", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["confirm"] = new ElicitRequestParams.BooleanSchema + { + Description = "Confirm the action" + } + } + } + }) + }, + requestState: "awaiting-confirmation"); +} +``` + +> [!TIP] +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including multiple round trips, concurrent input requests, and the compatibility matrix. + ### URL Elicitation Required Error When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:stateless) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 6393d9997..9e5a90f25 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -18,6 +18,7 @@ Install the SDK and build your first MCP client and server. | [Progress tracking](progress/progress.md) | Learn how to track progress for long-running operations through notification messages. | | [Cancellation](cancellation/cancellation.md) | Learn how to cancel in-flight MCP requests using cancellation tokens and notifications. | | [Tasks](tasks/tasks.md) | Learn how to use task-based execution for long-running operations that can be polled for status and results. | +| [Multi Round-Trip Requests (MRTR)](mrtr/mrtr.md) | Learn how servers request client input during tool execution using input-required results and retries. | ### Client Features diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md new file mode 100644 index 000000000..1d1ebce32 --- /dev/null +++ b/docs/concepts/mrtr/mrtr.md @@ -0,0 +1,291 @@ +--- +title: Multi Round-Trip Requests (MRTR) +author: halter73 +description: How servers request client input during tool execution using Multi Round-Trip Requests. +uid: mrtr +--- + +# Multi Round-Trip Requests (MRTR) + + +> [!WARNING] +> MRTR is part of the **`DRAFT-2026-v1`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. + +Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. + +## Overview + +MRTR is useful when: + +- A tool needs user confirmation before proceeding (elicitation). +- A tool needs LLM reasoning from the client (sampling). +- A tool needs an updated list of client roots. +- A tool needs to perform multiple rounds of interaction in a single logical operation. +- A stateless server needs to orchestrate multi-step flows without keeping handler state in memory between rounds. + +## How MRTR works + +1. The client calls a tool on the server via `tools/call`. +2. The server tool determines it needs client input and returns an `InputRequiredResult` containing `inputRequests` and/or `requestState`. +3. The client resolves each input request (for example by prompting the user for elicitation, calling an LLM for sampling, or listing its roots). +4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. +5. The server processes the responses and either returns a final result or another `InputRequiredResult` for additional rounds. + +## Opting in + +MRTR activates when both peers negotiate protocol revision **`DRAFT-2026-v1`** during `initialize`. The C# SDK opts in by listing `DRAFT-2026-v1` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. + +```csharp +// Client +var clientOptions = new McpClientOptions +{ + ProtocolVersion = "DRAFT-2026-v1", + Handlers = new McpClientHandlers + { + ElicitationHandler = HandleElicitationAsync, + SamplingHandler = HandleSamplingAsync, + } +}; +``` + +Under `DRAFT-2026-v1`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `DRAFT-2026-v1` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. + +Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. + +## Authoring an MRTR tool + +A tool participates in MRTR by throwing with an describing what it needs. On retry, the client's responses arrive on the request parameters and the tool inspects them to decide what to do next. + +### Checking MRTR support + +Tools should check before throwing `InputRequiredException`. It returns `true` when either: + +- The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or +- The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). + +```csharp +[McpServerTool, Description("A tool that uses MRTR")] +public static string MyTool( + McpServer server, + RequestContext context) +{ + if (!server.IsMrtrSupported) + { + return "This tool requires a client that negotiates DRAFT-2026-v1, " + + "or a stateful current-protocol session."; + } + + // ... MRTR logic +} +``` + +### Returning an incomplete result + +Throw to return an incomplete result. The exception carries an containing `inputRequests` and/or `requestState`: + +```csharp +[McpServerTool, Description("Tool managing its own MRTR flow")] +public static string AnswerTool( + McpServer server, + RequestContext context, + [Description("The user's question")] string question) +{ + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + // On retry, process the client's responses + if (requestState is not null && inputResponses is not null) + { + var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultJsonTypeInfo); + return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; + } + + if (!server.IsMrtrSupported) + { + return "MRTR is not supported by this client."; + } + + // First call — request user input + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["user_answer"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Please answer: {question}", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["answer"] = new ElicitRequestParams.StringSchema + { + Description = "Your answer" + } + } + } + }) + }, + requestState: "awaiting-answer"); +} +``` + +### Accessing retry data + +When the client retries a tool call, the retry data is available on the request parameters: + +- — a dictionary of client responses keyed by the same keys used in `inputRequests`. +- — the opaque state string echoed back by the client. + +Use with the `JsonTypeInfo` matching the response type. The expected type follows from the matching in the original `inputRequests` map — there is no on-the-wire discriminator. + +- Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` +- Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` +- Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` + +### Load shedding with requestState-only responses + +A server can return a `requestState`-only incomplete result (without any `inputRequests`) to defer processing. This is useful for load shedding or breaking up long-running work across multiple requests: + +```csharp +[McpServerTool, Description("Tool that defers work using requestState")] +public static string DeferredTool( + McpServer server, + RequestContext context) +{ + var requestState = context.Params!.RequestState; + + if (requestState is not null) + { + // Resume deferred work + var state = JsonSerializer.Deserialize( + Convert.FromBase64String(requestState)); + return $"Completed step {state!.Step}"; + } + + if (!server.IsMrtrSupported) + { + return "MRTR is not supported by this client."; + } + + // Defer work to a later retry + var initialState = new MyState { Step = 1 }; + throw new InputRequiredException( + requestState: Convert.ToBase64String( + JsonSerializer.SerializeToUtf8Bytes(initialState))); +} +``` + +The client automatically retries `requestState`-only incomplete results, echoing the state back without needing to resolve any input requests. + +### Multiple round trips + +A tool can perform multiple rounds of interaction by throwing `InputRequiredException` multiple times across retries. Use `requestState` to track which round you're on: + +```csharp +[McpServerTool, Description("Multi-step wizard")] +public static string WizardTool( + McpServer server, + RequestContext context) +{ + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + if (requestState == "step-2" && inputResponses is not null) + { + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; + var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; + return $"Welcome, {name}! You are {age} years old."; + } + + if (requestState == "step-1" && inputResponses is not null) + { + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; + + // Second round — ask for age + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["age"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Hi {name}! How old are you?", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["age"] = new ElicitRequestParams.NumberSchema + { + Description = "Your age" + } + } + } + }) + }, + requestState: "step-2"); + } + + if (!server.IsMrtrSupported) + { + return "MRTR is not supported. Please use a compatible client."; + } + + // First round — ask for name + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What's your name?", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema + { + Description = "Your name" + } + } + } + }) + }, + requestState: "step-1"); +} +``` + +### Providing custom error messages + +When MRTR is not supported, you can provide domain-specific guidance: + +```csharp +if (!server.IsMrtrSupported) +{ + return "This tool requires interactive input. To use it:\n" + + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" + + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" + + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; +} +``` + +## Compatibility + +The SDK supports `InputRequiredException` across two protocol revisions and two session modes: + +| Negotiated protocol | Session mode | Behavior | +|---|---|---| +| `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | +| `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | +| Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | +| Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | + +> [!NOTE] +> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). + +### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers + +`ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). + +Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `DRAFT-2026-v1`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft 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. + +### Future direction + +The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `DRAFT-2026-v1` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. + +This work is a follow-up to the present PR. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 7c09e53ad..213d317c0 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -103,3 +103,43 @@ server.RegisterNotificationHandler( Console.WriteLine($"Roots updated. {result.Roots.Count} roots available."); }); ``` + +### Multi Round-Trip Requests (MRTR) + +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. + +> [!IMPORTANT] +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. + +For example: + +```csharp +[McpServerTool, Description("Tool that requests roots via MRTR")] +public static string ListRootsWithMrtr( + McpServer server, + RequestContext context) +{ + // On retry, process the client's roots response + if (context.Params!.InputResponses?.TryGetValue("get_roots", out var response) is true) + { + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots ?? []; + return $"Found {roots.Count} roots: {string.Join(", ", roots.Select(r => r.Uri))}"; + } + + if (!server.IsMrtrSupported) + { + return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + } + + // First call — request the client's root list + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["get_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "awaiting-roots"); +} +``` + +> [!TIP] +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 4f14a4ee0..bac6ed5ab 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -120,3 +120,55 @@ McpClientOptions options = new() ### Capability negotiation Sampling requires the client to advertise the `sampling` capability. This is handled automatically — when a is set, the client includes the sampling capability during initialization. The server can check whether the client supports sampling before calling ; if sampling is not supported, the method throws . + +### Multi Round-Trip Requests (MRTR) + +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. + +> [!IMPORTANT] +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. + +For example: + +```csharp +[McpServerTool, Description("Tool that samples via MRTR")] +public static string SampleWithMrtr( + McpServer server, + RequestContext context) +{ + // On retry, process the client's sampling response + if (context.Params!.InputResponses?.TryGetValue("llm_call", out var response) is true) + { + var text = response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content + .OfType().FirstOrDefault()?.Text; + return $"LLM said: {text}"; + } + + if (!server.IsMrtrSupported) + { + return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + } + + // First call — request LLM completion from the client + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["llm_call"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Summarize the data" }] + } + ], + MaxTokens = 256 + }) + }, + requestState: "awaiting-sample"); +} +``` + +> [!TIP] +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index bd5474338..e0708cb75 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -19,6 +19,8 @@ items: uid: cancellation - name: Tasks uid: tasks + - name: Multi Round-Trip Requests (MRTR) + uid: mrtr - name: Client Features items: - name: Sampling diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index 7e7e969bb..e356480ed 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -110,4 +110,23 @@ internal static class Experimentals /// URL for the experimental RunSessionHandler API. /// public const string RunSessionHandler_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; + + /// + /// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature. + /// + /// + /// This uses the same diagnostic ID as because MRTR + /// is an experimental feature in the MCP specification (SEP-2322). + /// + public const string Mrtr_DiagnosticId = "MCPEXP001"; + + /// + /// Message for the experimental MRTR feature. + /// + public const string Mrtr_Message = "The Multi Round-Trip Requests (MRTR) feature is experimental per the MCP specification (SEP-2322) and is subject to change."; + + /// + /// URL for the experimental MRTR feature. + /// + public const string Mrtr_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001"; } diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 762091667..44bac1bc9 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -10,6 +10,7 @@ ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK. README.md true + $(NoWarn);MCPEXP001 diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 9763a6cf5..a489cd9e6 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -508,12 +508,25 @@ internal static string MakeNewSessionId() // Implementation for reading a JSON-RPC message from the request body var message = await context.Request.ReadFromJsonAsync(s_messageTypeInfo, context.RequestAborted); - if (context.User?.Identity?.IsAuthenticated == true && message is not null) + if (message is not null) { - message.Context = new() + var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + var isAuthenticated = context.User?.Identity?.IsAuthenticated == true; + + if (isAuthenticated || !string.IsNullOrEmpty(protocolVersion)) { - User = context.User, - }; + message.Context ??= new(); + + if (isAuthenticated) + { + message.Context.User = context.User; + } + + if (!string.IsNullOrEmpty(protocolVersion)) + { + message.Context.ProtocolVersion = protocolVersion; + } + } } return message; @@ -812,7 +825,7 @@ private static bool ValuesMatch(string? actual, string? expected, System.Text.Js // JSON Schema defines two numeric types: "number" (any numeric value including // decimals like 3.14) and "integer" (whole numbers only like 42). Both produce // JsonValueKind.Number in the JSON body and are sent as numeric strings in headers. - // We check for both because different SDKs may serialize them differently — + // We check for both because different SDKs may serialize them differently - // e.g., a client might send header "42.0" for an "integer" body value of 42, // or header "42" for a "number" body value of 42.0. Without handling both types, // valid cross-SDK requests would be incorrectly rejected. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 66410b272..e6ab3aae4 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using System.Collections.Concurrent; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Client; @@ -142,6 +143,8 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not RequestMethods.SamplingCreateMessage, async (request, jsonRpcRequest, cancellationToken) => { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); + // Check if this is a task-augmented request if (request?.Task is { } taskMetadata) { @@ -176,10 +179,14 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not { requestHandlers.Set( RequestMethods.SamplingCreateMessage, - (request, _, cancellationToken) => samplingHandler( - request, - request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, - cancellationToken), + (request, _, cancellationToken) => + { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); + return samplingHandler( + request, + request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, + cancellationToken); + }, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult); } @@ -192,7 +199,11 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not { requestHandlers.Set( RequestMethods.RootsList, - (request, _, cancellationToken) => rootsHandler(request, cancellationToken), + (request, _, cancellationToken) => + { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.RootsList); + return rootsHandler(request, cancellationToken); + }, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsResult); @@ -209,6 +220,8 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not RequestMethods.ElicitationCreate, async (request, jsonRpcRequest, cancellationToken) => { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); + // Check if this is a task-augmented request if (request?.Task is { } taskMetadata) { @@ -241,6 +254,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not RequestMethods.ElicitationCreate, async (request, _, cancellationToken) => { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false); return ElicitResult.WithDefaults(request, result); }, @@ -547,6 +561,98 @@ private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore /// public override Task Completion => _sessionHandler.CompletionTask; + /// + private async ValueTask> ResolveInputRequestsAsync( + IDictionary inputRequests, + CancellationToken cancellationToken) + { + // Resolve all input requests concurrently. If any fails, cancel the rest so user-facing + // handlers (sampling/elicitation prompts) don't keep running for a request whose caller + // has already given up, and ensure exceptions from late-completing tasks are observed. + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var keyed = new (string Key, Task Task)[inputRequests.Count]; + int i = 0; + foreach (var kvp in inputRequests) + { + keyed[i++] = (kvp.Key, ResolveInputRequestAsync(kvp.Value, linkedCts.Token)); + } + + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + linkedCts.Cancel(); + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + // Observed; the original exception is the one we want to surface. + } + throw; + } + + var responses = new Dictionary(keyed.Length); + foreach (var (key, task) in keyed) + { + responses[key] = task.Result; + } + return responses; + } + + private async Task ResolveInputRequestAsync(InputRequest inputRequest, CancellationToken cancellationToken) + { + switch (inputRequest.Method) + { + case RequestMethods.SamplingCreateMessage: + if (_options.Handlers.SamplingHandler is { } samplingHandler) + { + var samplingParams = inputRequest.SamplingParams + ?? throw new McpException($"Failed to deserialize sampling parameters from MRTR input request."); + var result = await samplingHandler( + samplingParams, + samplingParams.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, + cancellationToken).ConfigureAwait(false); + return InputResponse.FromSamplingResult(result); + } + + throw new InvalidOperationException( + $"Server sent a sampling input request, but no {nameof(McpClientHandlers.SamplingHandler)} is registered."); + + case RequestMethods.ElicitationCreate: + if (_options.Handlers.ElicitationHandler is { } elicitationHandler) + { + var elicitParams = inputRequest.ElicitationParams + ?? throw new McpException($"Failed to deserialize elicitation parameters from MRTR input request."); + var result = await elicitationHandler(elicitParams, cancellationToken).ConfigureAwait(false); + result = ElicitResult.WithDefaults(elicitParams, result); + return InputResponse.FromElicitResult(result); + } + + throw new InvalidOperationException( + $"Server sent an elicitation input request, but no {nameof(McpClientHandlers.ElicitationHandler)} is registered."); + + case RequestMethods.RootsList: + if (_options.Handlers.RootsHandler is { } rootsHandler) + { + // ListRootsRequest params are optional per the spec, so fall back to an empty params instance. + var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams(); + var result = await rootsHandler(rootsParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromRootsResult(result); + } + + throw new InvalidOperationException( + $"Server sent a roots list input request, but no {nameof(McpClientHandlers.RootsHandler)} is registered."); + + default: + throw new NotSupportedException($"Unsupported input request method: '{inputRequest.Method}'."); + } + } + /// /// Asynchronously connects to an MCP server, establishes the transport connection, and completes the initialization handshake. /// @@ -718,13 +824,13 @@ public override void ClearKnownTools() } /// - public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + public override async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { // For tools/call requests, attach the cached tool definition to the message context // so the transport can add custom Mcp-Param-* headers based on x-mcp-header schema annotations. if (request.Method == RequestMethods.ToolsCall && - request.Params is System.Text.Json.Nodes.JsonObject paramsObj && - paramsObj.TryGetPropertyValue("name", out var nameNode) && + request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && + paramsObjForHeaders.TryGetPropertyValue("name", out var nameNode) && nameNode?.GetValue() is { } toolName) { if (_toolCache.TryGetValue(toolName, out var tool)) @@ -739,7 +845,67 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObj && } } - return _sessionHandler.SendRequestAsync(request, cancellationToken); + const int maxRetries = 10; + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + + // Check if the result is an InputRequiredResult by looking at result_type. + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() is "input_required") + { + WarnIfInputRequiredResultOnNonMrtrSession(request.Method); + + var inputRequiredResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.InputRequiredResult) + ?? throw new JsonException("Failed to deserialize InputRequiredResult."); + + if (inputRequiredResult.InputRequests is { Count: > 0 } inputRequests) + { + IDictionary inputResponses = + await ResolveInputRequestsAsync(inputRequests, cancellationToken).ConfigureAwait(false); + + // Clone the original request params and add inputResponses + requestState for the retry. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + + if (inputRequiredResult.RequestState is { } requestState) + { + paramsObj["requestState"] = requestState; + } + else + { + // Strip any stale requestState carried over from the previous round's clone so + // the server doesn't see a continuation token the current round is not using. + paramsObj.Remove("requestState"); + } + + request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + } + else if (inputRequiredResult.RequestState is not null) + { + // No input requests but has requestState (e.g., load shedding) - just retry with state. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + paramsObj["requestState"] = inputRequiredResult.RequestState; + paramsObj.Remove("inputResponses"); + + request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + } + else + { + throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState."); + } + + continue; // retry with the updated request + } + + return response; + } + + throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times."); } /// @@ -775,6 +941,30 @@ public override async ValueTask DisposeAsync() await Completion.ConfigureAwait(false); } + /// Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request. + private void WarnIfLegacyRequestOnMrtrSession(string method) + { + if (_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion) + { + LogLegacyRequestOnMrtrSession(_endpointName, method); + } + } + + /// Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult. + private void WarnIfInputRequiredResultOnNonMrtrSession(string method) + { + if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion) + { + LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use InputRequiredResult instead of sending direct requests.")] + private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received InputRequiredResult for '{Method}' on session that did not negotiate MRTR (protocol version '{ProtocolVersion}'). The server may not be spec-compliant.")] + private partial void LogInputRequiredResultOnNonMrtrSession(string endpointName, string method, string? protocolVersion); + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")] private partial void LogServerCapabilitiesReceived(string endpointName, string capabilities, string serverInfo); @@ -798,5 +988,4 @@ public override async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")] private partial void LogToolRejected(string toolName, string reason); - } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 70eb30d0d..f1d18b4f9 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using ModelContextProtocol.Authentication; using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; @@ -144,6 +144,13 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(SubscribeRequestParams))] [JsonSerializable(typeof(UnsubscribeRequestParams))] + // MCP MRTR (Multi Round-Trip Requests) + [JsonSerializable(typeof(InputRequiredResult))] + [JsonSerializable(typeof(InputRequest))] + [JsonSerializable(typeof(InputResponse))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(IDictionary))] + // MCP Task Request Params / Results [JsonSerializable(typeof(McpTask))] [JsonSerializable(typeof(McpTaskStatus))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 4201f9833..73d99da71 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -68,7 +68,7 @@ public abstract partial class McpSession : IAsyncDisposable /// /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous send operation. - /// The transport is not connected. + /// The transport is not connected, or is a . Use for requests. /// is . /// /// diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 77c18b8be..e874e6724 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -31,6 +31,13 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// The latest version of the protocol supported by this implementation. internal const string LatestProtocolVersion = "2025-11-25"; + /// + /// The draft protocol version that enables MRTR (Multi Round-Trip Requests) per SEP-2322. + /// Clients and servers opt in by setting + /// or to this value. + /// + internal const string DraftProtocolVersion = "DRAFT-2026-v1"; + /// /// All protocol versions supported by this implementation. /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. @@ -41,7 +48,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "2025-03-26", "2025-06-18", LatestProtocolVersion, - "DRAFT-2026-v1", + DraftProtocolVersion, ]; /// @@ -642,6 +649,13 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can { Throw.IfNull(message); + if (message is JsonRpcRequest request) + { + throw new InvalidOperationException( + $"Cannot send '{request.Method}' request via {nameof(SendMessageAsync)}. " + + $"Use {nameof(SendRequestAsync)} instead to get a correlated response."); + } + cancellationToken.ThrowIfCancellationRequested(); Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs new file mode 100644 index 000000000..bd9161423 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs @@ -0,0 +1,197 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a server-initiated request that the client must fulfill as part of an MRTR +/// (Multi Round-Trip Request) flow. +/// +/// +/// +/// An wraps a server-to-client request such as +/// , , +/// or . It is included in an +/// when the server needs additional input before it can complete a client-initiated request. +/// +/// +/// The property identifies the type of request, and the corresponding +/// parameters can be accessed via the typed accessor properties. +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +[JsonConverter(typeof(Converter))] +public sealed class InputRequest +{ + /// + /// Gets or sets the method name identifying the type of this input request. + /// + /// + /// Standard values include: + /// + /// A sampling request. + /// An elicitation request. + /// A roots list request. + /// + /// + [JsonPropertyName("method")] + public required string Method { get; set; } + + /// + /// Gets or sets the raw JSON parameters for this input request. + /// + /// + /// Use the typed accessor properties (, , + /// ) for convenient strongly-typed access. + /// + [JsonPropertyName("params")] + public JsonElement? Params { get; set; } + + /// + /// Gets the parameters as when + /// is . + /// + /// The deserialized sampling parameters, or if the method does not match or params are absent. + [JsonIgnore] + public CreateMessageRequestParams? SamplingParams => + string.Equals(Method, RequestMethods.SamplingCreateMessage, StringComparison.Ordinal) && Params is { } p + ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams) + : null; + + /// + /// Gets the parameters as when + /// is . + /// + /// The deserialized elicitation parameters, or if the method does not match or params are absent. + [JsonIgnore] + public ElicitRequestParams? ElicitationParams => + string.Equals(Method, RequestMethods.ElicitationCreate, StringComparison.Ordinal) && Params is { } p + ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ElicitRequestParams) + : null; + + /// + /// Gets the parameters as when + /// is . + /// + /// The deserialized roots list parameters, or if the method does not match or params are absent. + [JsonIgnore] + public ListRootsRequestParams? RootsParams => + string.Equals(Method, RequestMethods.RootsList, StringComparison.Ordinal) && Params is { } p + ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams) + : null; + + /// + /// Creates an for a sampling request. + /// + /// The sampling request parameters. + /// A new instance. + public static InputRequest ForSampling(CreateMessageRequestParams requestParams) + { + Throw.IfNull(requestParams); + return new() + { + Method = RequestMethods.SamplingCreateMessage, + Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams), + }; + } + + /// + /// Creates an for an elicitation request. + /// + /// The elicitation request parameters. + /// A new instance. + public static InputRequest ForElicitation(ElicitRequestParams requestParams) + { + Throw.IfNull(requestParams); + return new() + { + Method = RequestMethods.ElicitationCreate, + Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams), + }; + } + + /// + /// Creates an for a roots list request. + /// + /// The roots list request parameters. + /// A new instance. + public static InputRequest ForRootsList(ListRootsRequestParams requestParams) + { + Throw.IfNull(requestParams); + return new() + { + Method = RequestMethods.RootsList, + Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams), + }; + } + + /// Provides JSON serialization support for . + public sealed class Converter : JsonConverter + { + /// + public override InputRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token."); + } + + string? method = null; + JsonElement? parameters = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token."); + } + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "method": + method = reader.GetString(); + break; + case "params": + parameters = JsonElement.ParseValue(ref reader); + break; + default: + reader.Skip(); + break; + } + } + + if (method is null) + { + throw new JsonException("InputRequest must have a 'method' property."); + } + + return new InputRequest + { + Method = method, + Params = parameters, + }; + } + + /// + public override void Write(Utf8JsonWriter writer, InputRequest value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("method", value.Method); + if (value.Params is { } p) + { + writer.WritePropertyName("params"); + p.WriteTo(writer); + } + writer.WriteEndObject(); + } + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs new file mode 100644 index 000000000..4f39b17a5 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs @@ -0,0 +1,109 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Protocol; + +/// +/// The exception that is thrown by a server handler to return an +/// to the client, signaling that additional input is needed before the request can be completed. +/// +/// +/// +/// This exception is part of the Multi Round-Trip Requests (MRTR) API. Tool handlers +/// throw this exception to directly control the input-required result payload, including +/// and . +/// +/// +/// For stateless servers, this enables multi-round-trip flows without requiring the handler to stay +/// alive between round trips. The server encodes its state in +/// and receives it back on retry via . +/// +/// +/// To return a requestState-only response (e.g., for load shedding), omit +/// and set only . +/// The client will retry the request with the state echoed back. +/// +/// +/// This exception can only be used when MRTR is supported by the client. Check +/// before throwing. If thrown when MRTR is not +/// supported, the exception will propagate as a JSON-RPC internal error. +/// +/// +/// +/// +/// [McpServerTool, Description("A stateless tool using MRTR")] +/// public static string MyTool(McpServer server, RequestContext<CallToolRequestParams> context) +/// { +/// if (context.Params.RequestState is { } state) +/// { +/// // Retry: process accumulated state and input responses +/// var responses = context.Params.InputResponses; +/// return "Final result"; +/// } +/// +/// if (!server.IsMrtrSupported) +/// { +/// return "This tool requires MRTR support."; +/// } +/// +/// throw new InputRequiredException( +/// inputRequests: new Dictionary<string, InputRequest> +/// { +/// ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams { ... }) +/// }, +/// requestState: "encoded-state"); +/// } +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +public class InputRequiredException : Exception +{ + /// + /// Initializes a new instance of the class + /// with the specified . + /// + /// The input-required result to return to the client. + public InputRequiredException(InputRequiredResult result) + : base("The server returned an input-required result requiring additional client input.") + { + Throw.IfNull(result); + Result = result; + } + + /// + /// Initializes a new instance of the class + /// with the specified input requests and/or request state. + /// + /// + /// Server-initiated requests that the client must fulfill before retrying. + /// Keys are server-assigned identifiers. + /// + /// + /// Opaque state to be echoed back by the client when retrying. The client must + /// treat this as an opaque blob and must not inspect or modify it. + /// + /// + /// Both and are . + /// At least one must be provided. + /// + public InputRequiredException( + IDictionary? inputRequests = null, + string? requestState = null) + : base("The server returned an input-required result requiring additional client input.") + { + if (inputRequests is null && requestState is null) + { + throw new ArgumentException("At least one of inputRequests or requestState must be provided."); + } + + Result = new InputRequiredResult + { + InputRequests = inputRequests, + RequestState = requestState, + }; + } + + /// + /// Gets the input-required result to return to the client. + /// + public InputRequiredResult Result { get; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs new file mode 100644 index 000000000..b02bfa9b2 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents an input-required result sent by the server to indicate that additional input is needed +/// before the request can be completed. +/// +/// +/// +/// An is returned in response to a client-initiated request when +/// the server needs the client to fulfill one or more server-initiated requests before it can produce +/// a final result. Per SEP-2322 the wire format is valid for , +/// , and resources/read; this SDK wires the MRTR +/// interceptor into all three methods. +/// +/// +/// At least one of or must be present. +/// +/// +/// This type is part of the Multi Round-Trip Requests (MRTR) mechanism defined in SEP-2322. +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +public sealed class InputRequiredResult : Result +{ + /// + /// Initializes a new instance of the class. + /// + public InputRequiredResult() + { + ResultType = "input_required"; + } + + /// + /// Gets or sets the server-initiated requests that the client must fulfill before retrying the original request. + /// + /// + /// + /// The keys are server-assigned identifiers. The client must include a response for each key in the + /// map when retrying the original request. + /// + /// + [JsonPropertyName("inputRequests")] + public IDictionary? InputRequests { get; set; } + + /// + /// Gets or sets opaque state to be echoed back by the client when retrying the original request. + /// + /// + /// + /// The client must treat this as an opaque blob and must not inspect, parse, modify, or make + /// any assumptions about the contents. If present, the client must include this value in the + /// property when retrying the original request. + /// + /// + /// Servers may encode request state in any format (e.g., plain JSON, base64-encoded JSON, + /// encrypted JWT, serialized binary). If the state contains sensitive data, servers should + /// encrypt it to ensure confidentiality and integrity. + /// + /// + [JsonPropertyName("requestState")] + public string? RequestState { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs new file mode 100644 index 000000000..465ea3235 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -0,0 +1,128 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a client's response to a server-initiated as part of an MRTR +/// (Multi Round-Trip Request) flow. +/// +/// +/// +/// An wraps the result of a server-to-client request such as +/// , , or . +/// The type of the inner response corresponds to the of the +/// associated input request. +/// +/// +/// The input response does not carry its own type discriminator in JSON. The type is determined by +/// the corresponding key in the map. +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +[JsonConverter(typeof(Converter))] +public sealed class InputResponse +{ + /// + /// Gets or sets the raw JSON element representing the response. + /// + /// + /// Use with the JsonTypeInfo<T> matching the + /// associated - for elicitation, sampling, or roots see + /// , , and + /// . + /// + [JsonIgnore] + public JsonElement RawValue { get; set; } + + /// + /// Deserializes the raw value to the specified result type. + /// + /// The type to deserialize to (e.g., , ). + /// The JSON type information for . + /// The deserialized result, or if deserialization fails. + public T? Deserialize(System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) => + JsonSerializer.Deserialize(RawValue, typeInfo); + + /// + /// Gets the for , suitable for use with + /// when the corresponding is + /// . + /// + public static JsonTypeInfo ElicitResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult; + + /// + /// Gets the for , suitable for use with + /// when the corresponding is + /// . + /// + public static JsonTypeInfo CreateMessageResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult; + + /// + /// Gets the for , suitable for use with + /// when the corresponding is + /// . + /// + public static JsonTypeInfo ListRootsResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult; + + /// + /// Creates an from a . + /// + /// The sampling result. + /// A new instance. + public static InputResponse FromSamplingResult(CreateMessageResult result) + { + Throw.IfNull(result); + return new() + { + RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CreateMessageResult), + }; + } + + /// + /// Creates an from an . + /// + /// The elicitation result. + /// A new instance. + public static InputResponse FromElicitResult(ElicitResult result) + { + Throw.IfNull(result); + return new() + { + RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult), + }; + } + + /// + /// Creates an from a . + /// + /// The roots list result. + /// A new instance. + public static InputResponse FromRootsResult(ListRootsResult result) + { + Throw.IfNull(result); + return new() + { + RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ListRootsResult), + }; + } + + /// Provides JSON serialization support for . + public sealed class Converter : JsonConverter + { + /// + public override InputResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + return new InputResponse { RawValue = element }; + } + + /// + public override void Write(Utf8JsonWriter writer, InputResponse value, JsonSerializerOptions options) + { + value.RawValue.WriteTo(writer); + } + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 2fa9839f0..e5c0f3931 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -74,4 +74,15 @@ public sealed class JsonRpcMessageContext /// /// public IDictionary? Items { get; set; } + + /// + /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version) + /// that accompanied 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. + /// + public string? ProtocolVersion { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 0a0586a71..004f1711f 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -25,6 +26,52 @@ private protected RequestParams() [JsonPropertyName("_meta")] public JsonObject? Meta { get; set; } + /// + /// Gets or sets the responses to server-initiated input requests from a previous . + /// + /// + /// + /// This property is populated when retrying a request after receiving an . + /// Each key corresponds to a key from the map, and + /// the value is the client's response to that input request. + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + [JsonIgnore] + public IDictionary? InputResponses + { + get => InputResponsesCore; + set => InputResponsesCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] + [JsonPropertyName("inputResponses")] + internal IDictionary? InputResponsesCore { get; set; } + + /// + /// Gets or sets opaque request state echoed back from a previous . + /// + /// + /// + /// This property is populated when retrying a request after receiving an + /// that included a value. The client must echo back the + /// exact value without modification. + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + [JsonIgnore] + public string? RequestState + { + get => RequestStateCore; + set => RequestStateCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] + [JsonPropertyName("requestState")] + internal string? RequestStateCore { get; set; } + /// /// Gets the opaque token that will be attached to any subsequent progress notifications. /// diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 58b076ddb..6e43249a1 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -21,4 +21,18 @@ private protected Result() /// [JsonPropertyName("_meta")] public JsonObject? Meta { get; set; } + + /// + /// Gets or sets the type of the result, which allows the client to determine how to parse the result object. + /// + /// + /// + /// When absent or set to "complete", the result is a normal completed response. + /// When set to "input_required", the result is an indicating + /// that additional input is needed before the request can be completed. + /// + /// + /// Defaults to , which is equivalent to "complete". + [JsonPropertyName("resultType")] + public string? ResultType { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 957f58a51..bf87980e5 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -1,5 +1,6 @@ -using ModelContextProtocol.Protocol; -using System.Diagnostics; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; @@ -15,6 +16,14 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; + /// + /// Gets or sets the MRTR context for the current request, if any. + /// Set by when an MRTR-aware handler invocation is in progress. + /// + internal MrtrContext? ActiveMrtrContext { get; set; } + + public override bool IsMrtrSupported => server.IsMrtrSupported; + public override ValueTask DisposeAsync() => server.DisposeAsync(); public override IAsyncDisposable RegisterNotificationHandler(string method, Func handler) => server.RegisterNotificationHandler(method, handler); @@ -39,6 +48,16 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { + // When an MRTR context is active, intercept server-to-client requests (sampling, elicitation, roots) + // and route them through the MRTR mechanism instead of sending them over the wire. + // Task-augmented requests (SampleAsTaskAsync/ElicitAsTaskAsync) have a "task" property on their params + // and expect a CreateTaskResult response, so they must bypass MRTR and go over the wire. + if (ActiveMrtrContext is { } mrtrContext && + !(request.Params is JsonObject paramsObj && paramsObj.ContainsKey("task"))) + { + return SendRequestViaMrtrAsync(mrtrContext, request, cancellationToken); + } + if (request.Context is not null) { throw new ArgumentException("Only transports can provide a JsonRpcMessageContext."); @@ -51,4 +70,23 @@ public override Task SendRequestAsync(JsonRpcRequest request, C return server.SendRequestAsync(request, cancellationToken); } + + private static async Task SendRequestViaMrtrAsync( + MrtrContext mrtrContext, JsonRpcRequest request, CancellationToken cancellationToken) + { + var inputRequest = new InputRequest + { + Method = request.Method, + Params = request.Params is { } paramsNode + ? JsonSerializer.Deserialize(paramsNode, McpJsonUtilities.JsonContext.Default.JsonElement) + : null, + }; + var inputResponse = await mrtrContext.RequestInputAsync(inputRequest, cancellationToken).ConfigureAwait(false); + + return new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), + }; + } } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index b8b41bdc3..444365361 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -64,6 +64,25 @@ protected McpServer() /// Gets the last logging level set by the client, or if it's never been set. public abstract LoggingLevel? LoggingLevel { get; } + /// + /// Gets a value indicating whether the connected client supports Multi Round-Trip Requests (MRTR). + /// + /// + /// + /// When this property returns , tool handlers can throw + /// to return an + /// with and/or + /// to the client. + /// + /// + /// When this property returns , tool handlers should provide a fallback + /// experience (for example, returning a text message explaining that the client does not support + /// the required feature) instead of throwing . + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + public virtual bool IsMrtrSupported => false; + /// /// Runs the server, listening for and handling client requests. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 203856814..c48ee2da0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; +using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Server; @@ -27,6 +29,13 @@ internal sealed partial class McpServerImpl : McpServer private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; + private readonly ConcurrentDictionary _mrtrContinuations = new(); + private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); + + // Track MRTR handler tasks using the same inFlightCount + TCS pattern as + // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. + private int _mrtrInFlightCount = 1; + private readonly TaskCompletionSource _allMrtrHandlersCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); private ClientCapabilities? _clientCapabilities; private Implementation? _clientInfo; @@ -91,6 +100,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureLogging(options); ConfigureCompletion(options); ConfigureExperimentalAndExtensions(options); + ConfigureMrtr(); // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) @@ -210,9 +220,35 @@ public override async ValueTask DisposeAsync() _disposed = true; + // Dispose the session handler first - cancels message processing and waits for all + // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. + // After this returns, no new requests can be processed and no new MRTR continuations + // can be created, so _mrtrContinuations is effectively frozen. _taskCancellationTokenProvider?.Dispose(); _disposables.ForEach(d => d()); await _sessionHandler.DisposeAsync().ConfigureAwait(false); + + // Cancel all orphaned MRTR handlers still suspended in continuations (waiting for + // retries that will never arrive now that the session handler is disposed). + int cancelledCount = _mrtrContinuations.Count; + foreach (var continuation in _mrtrContinuations.Values) + { + continuation.CancelHandler(); + } + + if (cancelledCount > 0) + { + MrtrContinuationsCancelled(cancelledCount); + } + + // Wait for all MRTR handler tasks to complete using the same inFlightCount + TCS + // pattern as McpSessionHandler.ProcessMessagesCoreAsync. The count started at 1 + // (for DisposeAsync itself); decrementing it here triggers the drain if handlers + // are still in flight. ObserveHandlerCompletionAsync decrements for each handler. + if (Interlocked.Decrement(ref _mrtrInFlightCount) != 0) + { + await _allMrtrHandlersCompleted.Task.ConfigureAwait(false); + } } private void ConfigureInitialize(McpServerOptions options) @@ -231,7 +267,8 @@ private void ConfigureInitialize(McpServerOptions options) // Otherwise, try to use whatever the client requested as long as it's supported. // If it's not supported, fall back to the latest supported version. string? protocolVersion = options.ProtocolVersion; - protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? + protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && + McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? clientProtocolVersion : McpSessionHandler.LatestProtocolVersion; @@ -725,7 +762,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpErrorCode.InvalidParams); } - // Task augmentation requested - return CreateTaskResult + // Task augmentation requested with immediate creation return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false); } @@ -774,9 +811,18 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) } catch (Exception e) { - ToolCallError(request.Params?.Name ?? string.Empty, e); + // Skip logging for OperationCanceledException when the cancellation token + // is signaled - tool handler cancellation is an expected lifecycle event + // (client request cancellation, session shutdown, MRTR teardown), not a + // tool error. + // Skip logging for InputRequiredException - it's normal MRTR control flow, + // not an error (tools throw it to signal an InputRequiredResult). + if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) + { + ToolCallError(request.Params?.Name ?? string.Empty, e); + } - if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException) + if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) { throw; } @@ -990,7 +1036,7 @@ private ValueTask InvokeHandlerAsync( { return _servicesScopePerRequest ? InvokeScopedAsync(handler, args, jsonRpcRequest, cancellationToken) : - handler(new(new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport), jsonRpcRequest, args), cancellationToken); + handler(new(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args), cancellationToken); async ValueTask InvokeScopedAsync( McpRequestHandler handler, @@ -1002,7 +1048,7 @@ async ValueTask InvokeScopedAsync( try { return await handler( - new RequestContext(new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport), jsonRpcRequest, args) + new RequestContext(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args) { Services = scope?.ServiceProvider ?? Services, }, @@ -1018,6 +1064,18 @@ async ValueTask InvokeScopedAsync( } } + private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) + { + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); + + if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) + { + server.ActiveMrtrContext = mrtrContext; + } + + return server; + } + private void SetHandler( string method, McpRequestHandler handler, @@ -1106,6 +1164,436 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => _ => Protocol.LoggingLevel.Emergency, }; + /// + /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1). + /// + internal bool ClientSupportsMrtr() => + _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; + + /// + /// Returns when the session is stateful - the same server instance handles + /// subsequent requests on the same session. The legacy backcompat resolver in + /// needs a stateful session so it can send + /// elicitation/create / sampling/createMessage / roots/list to the client and + /// retry the handler with the responses. + /// + internal bool IsStatefulSession() => + _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; + + /// + public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession(); + + /// + /// Invokes a handler and catches to convert it to an + /// JSON response. When MRTR is negotiated or the server is stateless, + /// the result is serialized directly. Otherwise, input requests are resolved via standard JSON-RPC + /// calls (elicitation, sampling, roots) and the handler is retried with the responses - allowing + /// MRTR-native tools to work transparently with clients that don't support MRTR. + /// + private async Task InvokeWithInputRequiredResultHandlingAsync( + Func> handler, + JsonRpcRequest request, + CancellationToken cancellationToken) + { + const int MaxRetries = 10; + + // In stateless mode, pick up the negotiated draft protocol version from the + // transport-provided request context because there is no long-lived initialize handshake state. + if (_negotiatedProtocolVersion is null && + request.Context?.ProtocolVersion is { } headerProtocolVersion) + { + _negotiatedProtocolVersion = headerProtocolVersion; + } + + for (int retry = 0; ; retry++) + { + try + { + return await handler(request, cancellationToken).ConfigureAwait(false); + } + catch (InputRequiredException ex) + { + // If the client natively supports MRTR, serialize and return directly - + // the client will drive the retry loop. + if (ClientSupportsMrtr()) + { + return SerializeInputRequiredResult(ex.Result); + } + + // 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. + // TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. + if (!IsStatefulSession()) + { + throw new McpException( + "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + + "MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex); + } + + // Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler. + if (ex.Result.InputRequests is not { Count: > 0 } inputRequests) + { + throw new McpException( + "A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex); + } + + if (retry >= MaxRetries) + { + throw new McpException( + $"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex); + } + + // Resolve each input request by sending the corresponding JSON-RPC call to the client. + // Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool + // handlers, so they go through the POST's response stream (RelatedTransport) rather than + // the session-level transport. Without this, the messages can race with the client's GET + // stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync + // when no GET request has arrived yet. + var destinationServer = CreateDestinationBoundServer(request); + var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false); + + // Reconstruct request params with inputResponses and requestState for the retry. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + (IDictionary)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + + if (ex.Result.RequestState is { } requestState) + { + paramsObj["requestState"] = requestState; + } + else + { + // Strip any stale requestState carried over from the previous round's clone so + // the next tool invocation doesn't see a continuation token the current round is not using. + paramsObj.Remove("requestState"); + } + + request = new JsonRpcRequest + { + Id = request.Id, + Method = request.Method, + Params = paramsObj, + Context = request.Context, + }; + } + } + } + + /// + /// Resolves a batch of MRTR input requests concurrently by dispatching each as a standard + /// JSON-RPC request to the client. The requests are routed via + /// so they go out through the POST's response stream (matching the behavior of tool-initiated + /// server-to-client requests like server.SampleAsync) and avoid racing with the client's + /// GET stream startup. On the first failure all remaining handlers are cancelled so user-facing + /// flows (sampling/elicitation prompts) don't keep running once the caller has given up, and + /// exceptions from late-completing tasks are observed before the original exception is rethrown. + /// + private static async Task> ResolveInputRequestsAsync( + McpServer destinationServer, + IDictionary inputRequests, + CancellationToken cancellationToken) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var keyed = new (string Key, Task Task)[inputRequests.Count]; + int i = 0; + foreach (var kvp in inputRequests) + { + keyed[i++] = (kvp.Key, ResolveInputRequestAsync(destinationServer, kvp.Value, linkedCts.Token)); + } + + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + linkedCts.Cancel(); + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + // Observed; the original exception is the one we want to surface. + } + throw; + } + + var responses = new Dictionary(keyed.Length); + foreach (var (key, task) in keyed) + { + responses[key] = task.Result; + } + return responses; + } + + /// + /// Resolves a single MRTR by dispatching it as a standard JSON-RPC + /// request to the client via . This is the server-side mirror + /// of the client's input resolution logic, used for backward compatibility when the client doesn't + /// support MRTR. + /// + private static async Task ResolveInputRequestAsync(McpServer destinationServer, InputRequest inputRequest, CancellationToken cancellationToken) + { + switch (inputRequest.Method) + { + case RequestMethods.ElicitationCreate: + var elicitParams = inputRequest.ElicitationParams + ?? throw new McpException("Failed to deserialize elicitation parameters from MRTR input request."); + var elicitResult = await destinationServer.ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromElicitResult(elicitResult); + + case RequestMethods.SamplingCreateMessage: + var samplingParams = inputRequest.SamplingParams + ?? throw new McpException("Failed to deserialize sampling parameters from MRTR input request."); + var samplingResult = await destinationServer.SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromSamplingResult(samplingResult); + + case RequestMethods.RootsList: + var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams(); + var rootsResult = await destinationServer.RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromRootsResult(rootsResult); + + default: + throw new McpException($"Unsupported input request method: '{inputRequest.Method}'."); + } + } + + private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) => + JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); + + /// + /// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync/RequestRootsAsync, + /// an is returned early and the handler is suspended until the retry arrives. + /// + private void ConfigureMrtr() + { + // Wrap all methods that may trigger MRTR (server calling ElicitAsync/SampleAsync/RequestRootsAsync + // during handler execution). These methods may produce InputRequiredResult if the handler needs input. + WrapHandlerWithMrtr(RequestMethods.ToolsCall); + WrapHandlerWithMrtr(RequestMethods.PromptsGet); + WrapHandlerWithMrtr(RequestMethods.ResourcesRead); + } + + /// + /// Replaces an existing request handler entry with an MRTR-aware wrapper that supports + /// handler suspension and responses. + /// + private void WrapHandlerWithMrtr(string method) + { + if (!_requestHandlers.TryGetValue(method, out var originalHandler)) + { + return; + } + + _requestHandlers[method] = async (request, cancellationToken) => + { + // In stateless mode, each request creates a new server instance that never saw the + // initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the + // Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext. + if (_negotiatedProtocolVersion is null && + request.Context?.ProtocolVersion is { } headerProtocolVersion) + { + _negotiatedProtocolVersion = headerProtocolVersion; + } + + // Check for MRTR retry: if requestState is present, look up the continuation. + if (request.Params is JsonObject paramsObj && + paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) && + requestStateNode?.GetValueKind() == JsonValueKind.String && + requestStateNode.GetValue() is { } requestState) + { + if (_mrtrContinuations.TryRemove(requestState, out var existingContinuation)) + { + // Implicit MRTR retry: resume the suspended handler with client responses. + IDictionary? inputResponses = null; + if (paramsObj.TryGetPropertyValue("inputResponses", out var responsesNode) && responsesNode is not null) + { + inputResponses = JsonSerializer.Deserialize(responsesNode, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + } + + var exchange = existingContinuation.PendingExchange!; + var nextExchangeTask = existingContinuation.MrtrContext.ResetForNextExchange(exchange); + + if (inputResponses is not null && + inputResponses.TryGetValue(exchange.Key, out var response)) + { + if (!exchange.ResponseTcs.TrySetResult(response)) + { + throw new McpProtocolException( + $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", + McpErrorCode.InternalError); + } + } + else + { + if (!exchange.ResponseTcs.TrySetException( + new McpProtocolException($"Missing input response for key '{exchange.Key}'.", McpErrorCode.InvalidParams))) + { + throw new McpProtocolException( + $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", + McpErrorCode.InternalError); + } + } + + return await AwaitMrtrHandlerAsync( + existingContinuation.HandlerTask, existingContinuation, nextExchangeTask, cancellationToken).ConfigureAwait(false); + } + + // Explicit MRTR retry or invalid requestState: no continuation found. + // Fall through to the standard MRTR-aware invocation path below. The retry data + // (inputResponses, requestState) is already in the deserialized request params + // for low-level handlers to access, and the MrtrContext will be set up for + // high-level handlers that call ElicitAsync/SampleAsync. + } + + // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits + // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, + // and requires the same server instance to handle the retry (stateful session). + // 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() || !IsStatefulSession()) + { + return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); + } + + // Start a new MRTR-aware handler invocation. + var mrtrContext = new MrtrContext(); + + // Create a long-lived CTS for the handler that survives across retries. + // The original request's combinedCts will be disposed when this lambda returns, + // breaking the cancellation chain. This CTS keeps the handler cancellable. + // Like Kestrel's HttpContext.RequestAborted, the CTS is never disposed - Cancel() + // is thread-safe with itself, and not disposing avoids deadlock risks from + // calling Cancel/Dispose inside locks or Interlocked guards. + var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Store the MrtrContext so CreateDestinationBoundServer can pick it up and set it + // on the per-request DestinationBoundMcpServer. This is picked up synchronously + // before any await, so the finally cleanup is safe. + _mrtrContextsByRequestId[request.Id] = mrtrContext; + Task handlerTask; + try + { + handlerTask = originalHandler(request, handlerCts.Token); + } + finally + { + _mrtrContextsByRequestId.TryRemove(request.Id, out _); + } + + // Wrap handler state into a continuation for lifecycle management across retries. + var continuation = new MrtrContinuation(handlerCts, handlerTask, mrtrContext); + + // Track the handler task for lifecycle management. The observer logs unhandled + // exceptions and decrements _mrtrInFlightCount when the handler completes, + // mirroring how McpSessionHandler tracks in-flight handlers. + Interlocked.Increment(ref _mrtrInFlightCount); + _ = ObserveHandlerCompletionAsync(handlerTask); + + return await AwaitMrtrHandlerAsync( + handlerTask, continuation, mrtrContext.InitialExchangeTask, cancellationToken).ConfigureAwait(false); + }; + } + + /// + /// Awaits the outcome of an MRTR-enabled handler invocation. + /// If the handler completes, returns its result. If an exchange arrives (handler needs input), + /// builds and returns an and stores the continuation for future retries. + /// If the handler throws , the result is returned directly + /// without storing a continuation (explicit MRTR path). + /// + private async Task AwaitMrtrHandlerAsync( + Task handlerTask, + MrtrContinuation continuation, + Task exchangeTask, + CancellationToken cancellationToken) + { + // Link the current request's cancellation to the handler's long-lived CTS. + // On the initial call this is redundant (handlerCts is already linked to cancellationToken) + // but on retries this is critical: the retry's combinedCts cancellation must flow to the handler. + // This is how notifications/cancelled for the retry's request ID reaches the handler. + using var registration = cancellationToken.Register( + static state => ((MrtrContinuation)state!).CancelHandler(), continuation); + + // Race handler against MRTR exchange. + var completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false); + + if (completedTask == handlerTask) + { + // Handler completed - return its result, propagate its exception, or handle InputRequiredException. + return await AwaitHandlerWithInputRequiredResultHandlingAsync(handlerTask).ConfigureAwait(false); + } + + // Exchange arrived - handler needs input from the client (implicit MRTR path). + var exchange = await exchangeTask.ConfigureAwait(false); + + var correlationId = Guid.NewGuid().ToString("N"); + var inputRequiredResult = new InputRequiredResult + { + InputRequests = new Dictionary { [exchange.Key] = exchange.InputRequest }, + RequestState = correlationId, + }; + + // Store the continuation so the retry can resume the handler. + continuation.PendingExchange = exchange; + _mrtrContinuations[correlationId] = continuation; + + return SerializeInputRequiredResult(inputRequiredResult); + } + + /// + /// Fire-and-forget observer for an MRTR handler task. Logs unhandled exceptions at Debug + /// level (the same exception still propagates to the request pipeline, so Debug avoids + /// double-reporting at Error) and decrements when the + /// handler completes, following the same in-flight tracking pattern as . + /// + private async Task ObserveHandlerCompletionAsync(Task handlerTask) + { + try + { + await handlerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Handler cancelled - expected lifecycle event (disposal, client cancel, session shutdown). + } + catch (InputRequiredException) + { + // Explicit MRTR: handler explicitly signaling an InputRequiredResult. Not an error. + } + catch (Exception ex) + { + MrtrHandlerError(ex); + } + finally + { + if (Interlocked.Decrement(ref _mrtrInFlightCount) == 0) + { + _allMrtrHandlersCompleted.TrySetResult(true); + } + } + } + + /// + /// Awaits a handler task, catching to convert it to an + /// JSON response without storing a continuation. + /// + private static async Task AwaitHandlerWithInputRequiredResultHandlingAsync(Task handlerTask) + { + try + { + return await handlerTask.ConfigureAwait(false); + } + catch (InputRequiredException ex) + { + return SerializeInputRequiredResult(ex.Result); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")] private partial void ToolCallError(string toolName, Exception exception); @@ -1124,6 +1612,12 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => [LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")] private partial void ReadResourceCompleted(string resourceUri); + [LoggerMessage(Level = LogLevel.Debug, Message = "Cancelled {Count} pending MRTR continuation(s) during session disposal.")] + private partial void MrtrContinuationsCancelled(int count); + + [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] + private partial void MrtrHandlerError(Exception exception); + /// /// Executes a tool call as a task and returns a CallToolTaskResult immediately. /// diff --git a/src/ModelContextProtocol.Core/Server/MrtrContext.cs b/src/ModelContextProtocol.Core/Server/MrtrContext.cs new file mode 100644 index 000000000..e849cf4eb --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/MrtrContext.cs @@ -0,0 +1,78 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Manages the MRTR (Multi Round-Trip Request) coordination between a handler and the pipeline. +/// When a handler calls or +/// , +/// the handler sets the exchange TCS and suspends on a response TCS. The pipeline detects the exchange +/// via or the task returned by , +/// sends an , and later completes the response TCS when the retry arrives. +/// +internal sealed class MrtrContext +{ + private TaskCompletionSource _exchangeTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _nextInputRequestId; + + /// + /// Gets the task for the initial MRTR exchange. Set once in the constructor and never changes. + /// For subsequent exchanges after a retry, use the task returned by . + /// + public Task InitialExchangeTask { get; } + + public MrtrContext() + { + InitialExchangeTask = _exchangeTcs.Task; + } + + /// + /// Prepares the context for the next round of exchange after a retry arrives. + /// Uses to atomically validate that + /// still references the TCS that produced , + /// ensuring concurrent calls reliably fail. + /// + /// The exchange from the previous round whose + /// response has been (or is about to be) completed. + /// A task that completes when the handler requests input via + /// . + /// The context state was modified concurrently. + public Task ResetForNextExchange(MrtrExchange previousExchange) + { + var newTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (Interlocked.CompareExchange(ref _exchangeTcs, newTcs, previousExchange.SourceTcs) != previousExchange.SourceTcs) + { + throw new InvalidOperationException("MrtrContext was modified concurrently."); + } + + return newTcs.Task; + } + + /// + /// Called by + /// or + /// to request input from the client via the MRTR mechanism. + /// + /// The input request describing what the server needs. + /// A token to cancel the wait for input. + /// The client's response to the input request. + /// A concurrent server-to-client request is already pending. + public async Task RequestInputAsync(InputRequest inputRequest, CancellationToken cancellationToken) + { + var key = $"input_{Interlocked.Increment(ref _nextInputRequestId)}"; + var tcs = _exchangeTcs; + var exchange = new MrtrExchange(key, inputRequest, tcs); + + // TrySetResult is the sole atomicity gate. If it returns false, + // the TCS was already completed by a prior call - concurrent exchanges + // are not supported. + if (!tcs.TrySetResult(exchange)) + { + throw new InvalidOperationException( + "Concurrent server-to-client requests are not supported. " + + "Await each ElicitAsync, SampleAsync, or RequestRootsAsync call before making another."); + } + + return await exchange.ResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs new file mode 100644 index 000000000..f2cc65e3f --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Server; + +/// +/// Represents the lifecycle state for an MRTR handler invocation across retries. +/// Created when the handler starts and stored in _mrtrContinuations when +/// the handler suspends waiting for client input. +/// +internal sealed class MrtrContinuation +{ + private readonly CancellationTokenSource _handlerCts; + + public MrtrContinuation(CancellationTokenSource handlerCts, Task handlerTask, MrtrContext mrtrContext) + { + _handlerCts = handlerCts; + HandlerTask = handlerTask; + MrtrContext = mrtrContext; + } + + /// + /// Gets a token that cancels when the handler should be aborted. + /// Passed to the handler at creation and remains valid across retries. + /// + public CancellationToken HandlerToken => _handlerCts.Token; + + /// + /// The handler task that is suspended awaiting input. + /// + public Task HandlerTask { get; } + + /// + /// The MRTR context for the handler's async flow. + /// + public MrtrContext MrtrContext { get; } + + /// + /// The exchange that is awaiting a response from the client. + /// Set each time the handler suspends on a new exchange. + /// + public MrtrExchange? PendingExchange { get; set; } + + /// + /// Cancels the handler. Safe to call multiple times and concurrently - + /// is thread-safe with itself. + /// The CTS is intentionally never disposed to avoid deadlock risks from + /// calling Cancel/Dispose inside synchronization primitives. + /// + public void CancelHandler() => _handlerCts.Cancel(); +} diff --git a/src/ModelContextProtocol.Core/Server/MrtrExchange.cs b/src/ModelContextProtocol.Core/Server/MrtrExchange.cs new file mode 100644 index 000000000..cf0a86af4 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/MrtrExchange.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a single exchange between the handler and the pipeline during an MRTR flow. +/// The handler creates the exchange and awaits the response TCS. The pipeline reads the exchange, +/// sends the to the client, and completes the TCS when the response arrives. +/// +internal sealed class MrtrExchange +{ + public MrtrExchange(string key, InputRequest inputRequest, TaskCompletionSource sourceTcs) + { + Key = key; + InputRequest = inputRequest; + SourceTcs = sourceTcs; + ResponseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// + /// The unique key identifying this exchange within the MRTR round trip. + /// + public string Key { get; } + + /// + /// The input request that needs to be fulfilled by the client. + /// + public InputRequest InputRequest { get; } + + /// + /// The that this exchange was set as the result of. + /// Used by on retry to validate + /// the expected state via . + /// + internal TaskCompletionSource SourceTcs { get; } + + /// + /// The TCS that will be completed with the client's response. + /// + public TaskCompletionSource ResponseTcs { get; } +} diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index a30dd3fc3..ef1686abb 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -205,6 +205,44 @@ public static bool HasSep2243Scenarios() } } + /// + /// Checks whether the SEP-2322 (Multi Round-Trip Requests / IncompleteResult) + /// conformance scenarios are available by reading the conformance package version + /// from the repo's package.json. MRTR scenarios require a conformance package version + /// that includes SEP-2322 support (see + /// https://github.com/modelcontextprotocol/conformance/pull/188). + /// + public static bool HasMrtrScenarios() + { + try + { + var repoRoot = FindRepoRoot(); + var packageJsonPath = Path.Combine(repoRoot, "package.json"); + if (!File.Exists(packageJsonPath)) + { + return false; + } + + var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("dependencies", out var deps) && + deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) + { + var versionStr = versionElement.GetString(); + if (versionStr is not null && Version.TryParse(versionStr, out var version)) + { + // SEP-2322 scenarios are expected in conformance package >= 0.2.0 + return version >= new Version(0, 2, 0); + } + } + + return false; + } + catch + { + return false; + } + } + private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/Common/Utils/ServerMessageTracker.cs b/tests/Common/Utils/ServerMessageTracker.cs new file mode 100644 index 000000000..66a80c681 --- /dev/null +++ b/tests/Common/Utils/ServerMessageTracker.cs @@ -0,0 +1,95 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using Xunit; + +namespace ModelContextProtocol.Tests.Utils; + +/// +/// Tracks MRTR protocol mode via incoming and outgoing message filters. +/// Used by MRTR tests to verify the correct protocol mode (MRTR vs legacy) was used. +/// +internal sealed class ServerMessageTracker +{ + private static readonly HashSet LegacyMrtrMethods = + [ + RequestMethods.ElicitationCreate, + RequestMethods.SamplingCreateMessage, + RequestMethods.RootsList, + ]; + + private readonly ConcurrentBag _legacyRequestMethods = []; + private int _mrtrRetryCount; + private int _incompleteResultCount; + + /// + /// Adds incoming and outgoing message filters to track MRTR protocol usage. + /// Call this in services.Configure<McpServerOptions> or AddMcpServer callbacks. + /// + public void AddFilters(McpMessageFilters messageFilters) + { + // Track outgoing legacy JSON-RPC requests and InputRequiredResult responses. + messageFilters.OutgoingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest request && LegacyMrtrMethods.Contains(request.Method)) + { + _legacyRequestMethods.Add(request.Method); + } + else if (context.JsonRpcMessage is JsonRpcResponse response && + response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() == "input_required") + { + Interlocked.Increment(ref _incompleteResultCount); + } + + await next(context, cancellationToken); + }); + + // Track incoming MRTR retries (requests with inputResponses or requestState in params). + messageFilters.IncomingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest request && + request.Params is JsonObject paramsObj && + (paramsObj.ContainsKey("inputResponses") || paramsObj.ContainsKey("requestState"))) + { + Interlocked.Increment(ref _mrtrRetryCount); + } + + await next(context, cancellationToken); + }); + } + + /// + /// Asserts that MRTR was used: at least one InputRequiredResult response was sent + /// and no legacy JSON-RPC requests (elicitation/create, sampling/createMessage, roots/list) were sent. + /// + public void AssertMrtrUsed() + { + Assert.True(_incompleteResultCount > 0, + "Expected at least one InputRequiredResult response (MRTR mode), but none were detected."); + Assert.Empty(_legacyRequestMethods); + } + + /// + /// Asserts that MRTR was used at least once (at least one InputRequiredResult response was sent), + /// independent of whether the session also issued any legacy server-to-client requests. + /// + public void AssertMrtrUsedAtLeastOnce() + { + Assert.True(_incompleteResultCount > 0, + "Expected at least one InputRequiredResult response (MRTR mode), but none were detected."); + } + + /// + /// Asserts that legacy mode was used: at least one legacy JSON-RPC request was sent + /// and no MRTR retries or InputRequiredResult responses were detected. + /// + public void AssertMrtrNotUsed() + { + Assert.NotEmpty(_legacyRequestMethods); + Assert.Equal(0, _mrtrRetryCount); + Assert.Equal(0, _incompleteResultCount); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs index 5552b5395..ac19953bf 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs @@ -1,7 +1,45 @@ -namespace ModelContextProtocol.AspNetCore.Tests; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore.Tests; public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStreamableHttpTests(outputHelper) { protected override bool UseStreamableHttp => true; protected override bool Stateless => true; + + [Fact] + public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() + { + InvalidOperationException? capturedException = null; + var pollingTool = McpServerTool.Create(async (RequestContext context) => + { + try + { + await context.EnablePollingAsync(retryInterval: TimeSpan.FromSeconds(1)); + } + catch (InvalidOperationException ex) + { + capturedException = ex; + } + + return "Complete"; + }, options: new() { Name = "polling_tool" }); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools([pollingTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(); + + await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(capturedException); + Assert.Contains("stateless", capturedException.Message, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 40b9e8217..3d532802b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -348,9 +348,9 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia await app.StartAsync(TestContext.Current.CancellationToken); - await using var mcpClient = await ConnectAsync(clientOptions: new() + await using var mcpClient = await ConnectAsync(configureClient: options => { - ProtocolVersion = "2025-06-18", + options.ProtocolVersion = "2025-06-18"; }); Assert.Equal("2025-06-18", mcpClient.NegotiatedProtocolVersion); @@ -458,41 +458,6 @@ public async Task CanResumeSessionWithMapMcpAndRunSessionHandler() Assert.Equal(1, runSessionCount); } - [Fact] - public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() - { - Assert.SkipUnless(Stateless, "This test only applies to stateless mode."); - - InvalidOperationException? capturedException = null; - var pollingTool = McpServerTool.Create(async (RequestContext context) => - { - try - { - await context.EnablePollingAsync(retryInterval: TimeSpan.FromSeconds(1)); - } - catch (InvalidOperationException ex) - { - capturedException = ex; - } - - return "Complete"; - }, options: new() { Name = "polling_tool" }); - - Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools([pollingTool]); - - await using var app = Builder.Build(); - app.MapMcp(); - - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var mcpClient = await ConnectAsync(); - - await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(capturedException); - Assert.Contains("stateless", capturedException.Message, StringComparison.OrdinalIgnoreCase); - } - [Fact] public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenNoEventStreamStoreConfigured() { @@ -793,13 +758,13 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() await using var app = Builder.Build(); - // This is the pattern documented in sessions.md — verify it actually works. + // This is the pattern documented in sessions.md - verify it actually works. // Tag before next() so child spans inherit the value. app.MapMcp().AddEndpointFilter(async (context, next) => { var httpContext = context.HttpContext; - // Read from request headers — available on all non-initialize requests in stateful mode. + // Read from request headers - available on all non-initialize requests in stateful mode. string? beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"]; // Tag before next() so child activities created during the handler inherit it. @@ -828,7 +793,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); // The filter must have observed at least one MCP request. Don't assert an exact - // minimum — the initialized notification or GET stream may not have completed yet. + // minimum - the initialized notification or GET stream may not have completed yet. Assert.NotEmpty(capturedSessionIds); if (Stateless) @@ -855,7 +820,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() }); // At least one POST should have the session ID in the request header too - // (the initialized notification or list_tools — but not the initial initialize request). + // (the initialized notification or list_tools - but not the initial initialize request). Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId); // Verify Activity.Current was available and the AddTag pattern works before next(). diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs new file mode 100644 index 000000000..ddae6c66b --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -0,0 +1,779 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public abstract partial class MapMcpTests +{ + private ServerMessageTracker ConfigureServer(params Delegate[] tools) + { + var messageTracker = new ServerMessageTracker(); + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; + // Do not pin a protocol version - let it be negotiated based on what the client requests. + // DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get + // the latest non-draft. + messageTracker.AddFilters(options.Filters.Message); + }) + .WithHttpTransport(ConfigureStateless) + .WithTools(tools.Select(t => McpServerTool.Create(t))); + return messageTracker; + } + + private Task ConnectExperimentalAsync() => + ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + options.ProtocolVersion = "DRAFT-2026-v1"; + }); + + private Task ConnectDefaultAsync() => + ConnectAsync(configureClient: ConfigureMrtrHandlers); + + /// Configures elicitation, sampling, and roots handlers on client options. + private static void ConfigureMrtrHandlers(McpClientOptions options) + { + options.Handlers.ElicitationHandler = (request, ct) => + { + var message = request?.Message ?? ""; + var answer = message.Contains("name", StringComparison.OrdinalIgnoreCase) ? "Alice" + : message.Contains("greet", StringComparison.OrdinalIgnoreCase) ? "Hello" + : "yes"; + + return new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse($"\"{answer}\"").RootElement.Clone() + } + }); + }; + options.Handlers.SamplingHandler = (request, progress, ct) => + { + var prompt = request?.Messages?.LastOrDefault()?.Content + .OfType().FirstOrDefault()?.Text ?? ""; + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = $"LLM:{prompt}" }], + Model = "test-model" + }); + }; + options.Handlers.RootsHandler = (request, ct) => + { + return new ValueTask(new ListRootsResult + { + Roots = [ + new Root { Uri = "file:///project", Name = "Project" }, + new Root { Uri = "file:///data", Name = "Data" } + ] + }); + }; + } + + // ===================================================================== + // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. + // Each test creates its own server with DRAFT-2026-v1 enabled. + // ===================================================================== + + [McpServerTool(Name = "mrtr-mixed")] + private static async Task MrtrMixed(McpServer server, RequestContext context, CancellationToken ct) + { + var state = context.Params!.RequestState; + var responses = context.Params!.InputResponses; + + // Round 3 entry: confirmation from round 2 available. Transition to await API. + if (state == "round-2" && responses?.TryGetValue("confirm", out var confirmResponse) == true) + { + var confirmation = confirmResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action ?? "unknown"; + + // Await API: sequential sampling then elicitation + var sampleResult = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Write greeting" }] }], + MaxTokens = 100 + }, ct); + var greeting = sampleResult.Content.OfType().FirstOrDefault()?.Text ?? ""; + + var signoffResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Sign off as?", + RequestedSchema = new() + }, ct); + var signoff = signoffResult.Action; + + return $"{confirmation}|{greeting}|{signoff}"; + } + + // Round 2 entry: parallel results from round 1 available. + if (state == "round-1" && responses is not null) + { + var name = responses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; + var weather = responses["weather"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content + .OfType().FirstOrDefault()?.Text ?? ""; + var root = responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots?.FirstOrDefault()?.Name ?? ""; + + // Exception API: single elicitation with requestState + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Confirm {name} in {weather} near {root}?", + RequestedSchema = new() + }) + }, + requestState: "round-2"); + } + + // Round 1: Exception API with 3 PARALLEL input requests + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }), + ["weather"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Describe the weather" }] }], + MaxTokens = 100 + }), + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "round-1"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) + { + // The server always supports DRAFT-2026-v1 (it's in SupportedProtocolVersions). The + // client opts in by pinning ProtocolVersion = "DRAFT-2026-v1"; otherwise it negotiates + // the latest non-draft version and the server falls back to the exception path with + // legacy JSON-RPC resolution. + var messageTracker = ConfigureServer(MrtrMixed); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + : ConfigureMrtrHandlers; + + // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. + // In stateless mode, those calls succeed only when the request is still open on the same + // SSE stream - which it is - so the tool runs end-to-end as long as the input requests + // themselves can be resolved (MRTR client) or replayed via legacy JSON-RPC (stateful + legacy). + if (Stateless && !experimentalClient) + { + // Stateless + legacy client: InputRequiredException cannot be resolved (no MRTR wire + // and no persistent server instance for the backcompat retry loop). The server returns + // a JSON-RPC error. + await using var client = await ConnectAsync(configureClient: configureClient); + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-mixed", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MRTR", ex.Message); + return; + } + + if (Stateless && experimentalClient) + { + // Stateless + MRTR client: the await-style portion (server.SampleAsync on round 3) + // requires handler suspension across requests, which only works in stateful mode. + // Skip this combination - the await API is documented as stateful-only. + Assert.SkipWhen(true, "Await-style API requires handler suspension (stateful only)."); + return; + } + + // Stateful path - both client modes complete all 3 rounds. + await using var statefulClient = await ConnectAsync(configureClient: configureClient); + + Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", + statefulClient.NegotiatedProtocolVersion); + + var result = await statefulClient.CallToolAsync("mrtr-mixed", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.True(result.IsError is not true); + var parts = text.Split('|'); + Assert.Equal(3, parts.Length); + Assert.Equal("accept", parts[0]); + Assert.StartsWith("LLM:", parts[1]); + Assert.Equal("accept", parts[2]); + + if (experimentalClient) + { + // Rounds 1-2 use wire-format MRTR (InputRequiredResult), but round 3's await calls + // still issue legacy elicitation/create + sampling/createMessage requests, so this + // configuration is mixed-mode. + messageTracker.AssertMrtrUsedAtLeastOnce(); + } + else + { + messageTracker.AssertMrtrNotUsed(); + } + } + + [McpServerTool(Name = "mrtr-parallel-await")] + private static async Task MrtrParallelAwait(McpServer server, CancellationToken ct) + { + var elicitTask = server.ElicitAsync(new ElicitRequestParams + { + Message = "Parallel elicit", + RequestedSchema = new() + }, ct); + + // Start the second await - with MRTR, this throws InvalidOperationException + // because MrtrContext only supports one pending exchange at a time. + try + { + var sampleTask = server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Parallel sample" }] }], + MaxTokens = 100 + }, ct); + + // If we get here, both calls succeeded (non-MRTR path) + var sampleResult = await sampleTask; + var elicitResult = await elicitTask; + return $"parallel-ok:{elicitResult.Action}:{sampleResult.Content.OfType().First().Text}"; + } + catch (InvalidOperationException ex) + { + return ex.Message; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Mrtr_ParallelAwaits(bool experimentalClient) + { + // Parallel awaits work with regular JSON-RPC but fail with MRTR because + // MrtrContext only supports one exchange at a time (TrySetResult gate). + Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + + ConfigureServer(MrtrParallelAwait); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + : ConfigureMrtrHandlers; + + await using var client = await ConnectAsync(configureClient: configureClient); + + if (experimentalClient) + { + // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second + // call throws InvalidOperationException, which the tool catches and returns as text. + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-parallel-await", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("Concurrent server-to-client requests are not supported", text); + Assert.True(result.IsError is not true); + } + else + { + // Non-MRTR: awaits go through regular JSON-RPC - concurrent calls work. + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-parallel-await", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.StartsWith("parallel-ok:", text); + Assert.True(result.IsError is not true); + } + } + + [McpServerTool(Name = "mrtr-elicit")] + private static string MrtrElicit(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_input", out var response)) + { + return $"elicit-ok:{response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new() + }) + }, + requestState: "elicit-state"); + } + + [Fact] + public async Task Mrtr_Roots_CompletesViaMrtr() + { + var messageTracker = ConfigureServer( + [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("roots", out var response)) + { + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; + return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "roots-state"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectExperimentalAsync(); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-roots", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("roots-ok:file:///project,file:///data", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrUsed(); + } + + [McpServerTool(Name = "mrtr-multi")] + private static string MrtrMulti(RequestContext context) + { + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + if (requestState == "round-2" && inputResponses is not null) + { + var greeting = inputResponses["greeting"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; + return $"multi-done:greeting={greeting}"; + } + + if (requestState == "round-1" && inputResponses is not null) + { + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["greeting"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"How should I greet {name}?", + RequestedSchema = new() + }) + }, + requestState: "round-2"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }) + }, + requestState: "round-1"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) + { + var messageTracker = ConfigureServer(MrtrMulti); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Configure client - experimental or default based on parameter. + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + : ConfigureMrtrHandlers; + await using var client = await ConnectAsync(configureClient: configureClient); + + if (!experimentalClient && Stateless) + { + // Stateless without MRTR: InputRequiredException can't be resolved + // (no MRTR negotiated and no stateful backcompat path). + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-multi", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + return; + } + + var result = await client.CallToolAsync("mrtr-multi", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("multi-done:greeting=accept", text); + Assert.True(result.IsError is not true); + + if (experimentalClient) + { + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + messageTracker.AssertMrtrUsed(); + } + else + { + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + messageTracker.AssertMrtrNotUsed(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Mrtr_IsMrtrSupported(bool experimentalClient) + { + ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Configure client - experimental or default based on parameter. + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } + : ConfigureMrtrHandlers; + await using var client = await ConnectAsync(configureClient: configureClient); + Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-check", + cancellationToken: TestContext.Current.CancellationToken); + + // IsMrtrSupported is false only when stateless AND client didn't negotiate MRTR + // (no backcompat path available). All other combos have MRTR or backcompat support. + var expected = Stateless && !experimentalClient ? "False" : "True"; + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal(expected, text); + } + + [McpServerTool(Name = "mrtr-concurrent-three")] + private static string MrtrConcurrentThree(RequestContext context) + { + if (context.Params!.InputResponses is { Count: 3 } responses && + responses.ContainsKey("elicit") && + responses.ContainsKey("sample") && + responses.ContainsKey("roots")) + { + var elicitAction = responses["elicit"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; + var sampleText = responses["sample"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)? + .Content.OfType().FirstOrDefault()?.Text; + var rootUris = string.Join(",", + responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots.Select(r => r.Uri) ?? []); + return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["elicit"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Confirm action", + RequestedSchema = new() + }), + ["sample"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Generate summary" }] + }], + MaxTokens = 50 + }), + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "concurrent-state"); + } + + [Fact] + public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() + { + var messageTracker = ConfigureServer(MrtrConcurrentThree); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + var elicitCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var samplingCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var rootsCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var client = await ConnectAsync(configureClient: options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + options.Handlers.ElicitationHandler = async (request, ct) => + { + elicitCalled.TrySetResult(); + await Task.WhenAll(samplingCalled.Task.WaitAsync(ct), rootsCalled.Task.WaitAsync(ct)); + return new ElicitResult { Action = "accept" }; + }; + options.Handlers.SamplingHandler = async (request, progress, ct) => + { + samplingCalled.TrySetResult(); + await Task.WhenAll(elicitCalled.Task.WaitAsync(ct), rootsCalled.Task.WaitAsync(ct)); + return new CreateMessageResult + { + Content = [new TextContentBlock { Text = "AI-summary" }], + Model = "test-model" + }; + }; + options.Handlers.RootsHandler = async (request, ct) => + { + rootsCalled.TrySetResult(); + await Task.WhenAll(elicitCalled.Task.WaitAsync(ct), samplingCalled.Task.WaitAsync(ct)); + return new ListRootsResult + { + Roots = [new Root { Uri = "file:///workspace", Name = "Workspace" }] + }; + }; + }); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-concurrent-three", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("all-ok:elicit=accept,sample=AI-summary,roots=file:///workspace", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() + { + var messageTracker = ConfigureServer( + [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => + { + if (context.Params!.RequestState is { } state) + { + return $"resumed:{state}"; + } + + // requestState-only InputRequiredException (no inputRequests) + throw new InputRequiredException(requestState: "deferred-work"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectExperimentalAsync(); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-loadshed", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("resumed:deferred-work", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + var messageTracker = ConfigureServer( + [McpServerTool(Name = "mrtr-roots-backcompat")] (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("roots", out var response)) + { + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; + return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "roots-state"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectDefaultAsync(); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-roots-backcompat", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("roots-ok:Project", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrNotUsed(); + } + + [Fact] + public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + var messageTracker = ConfigureServer( + [McpServerTool(Name = "mrtr-multi-input")] (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("confirm", out var elicitResponse) && + responses.TryGetValue("summarize", out var sampleResponse)) + { + var action = elicitResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; + var text = sampleResponse.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content.OfType().FirstOrDefault()?.Text; + return $"both:{action}:{text}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new() + }), + ["summarize"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Summarize" }] + }], + MaxTokens = 100 + }) + }, + requestState: "multi-input-state"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectDefaultAsync(); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-multi-input", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("both:accept:LLM:Summarize", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrNotUsed(); + } + + [Fact] + public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + int elicitCallCount = 0; + + ConfigureServer( + [McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext context) => + { + // Always throw - never complete + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Confirm again", + RequestedSchema = new() + }) + }, + requestState: "infinite"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + var originalHandler = options.Handlers.ElicitationHandler!; + options.Handlers.ElicitationHandler = (request, ct) => + { + Interlocked.Increment(ref elicitCallCount); + return originalHandler(request, ct); + }; + }); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-always-incomplete", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("exceeded", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("10", ex.Message); + Assert.Equal(10, elicitCallCount); + } + + [Fact] + public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + ConfigureServer( + [McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext context) => + { + throw new InputRequiredException( + inputRequests: new Dictionary(), + requestState: "empty"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectDefaultAsync(); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-empty-inputs", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("without input requests", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + } + + [Fact] + public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + + ConfigureServer(MrtrElicit); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + options.Handlers.ElicitationHandler = (request, ct) => + { + throw new InvalidOperationException("Client-side elicitation failure"); + }; + }); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + // Handler exception propagates through the backcompat JSON-RPC round-trip. + // The original exception message gets wrapped in "Request failed (remote)" during backcompat. + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-elicit", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 678b27022..b9b8381ca 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -14,7 +14,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; -public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) +public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) { protected abstract bool UseStreamableHttp { get; } protected abstract bool Stateless { get; } @@ -27,9 +27,8 @@ protected virtual void ConfigureStateless(HttpServerTransportOptions options) protected async Task ConnectAsync( string? path = null, HttpClientTransportOptions? transportOptions = null, - McpClientOptions? clientOptions = null) + Action? configureClient = null) { - // Default behavior when no options are provided path ??= UseStreamableHttp ? "/" : "/sse"; await using var transport = new HttpClientTransport(transportOptions ?? new HttpClientTransportOptions @@ -38,6 +37,8 @@ protected async Task ConnectAsync( TransportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse, }, HttpClient, LoggerFactory); + var clientOptions = new McpClientOptions(); + configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } @@ -156,29 +157,24 @@ public async Task Sampling_DoesNotCloseStreamPrematurely() await app.StartAsync(TestContext.Current.CancellationToken); var sampleCount = 0; - var clientOptions = new McpClientOptions() + await using var mcpClient = await ConnectAsync(configureClient: options => { - Handlers = new() + options.Handlers.SamplingHandler = async (parameters, _, _) => { - SamplingHandler = async (parameters, _, _) => - { - Assert.NotNull(parameters?.Messages); - var message = Assert.Single(parameters.Messages); - Assert.Equal(Role.User, message.Role); - Assert.Equal("Test prompt for sampling", Assert.IsType(Assert.Single(message.Content)).Text); + Assert.NotNull(parameters?.Messages); + var message = Assert.Single(parameters.Messages); + Assert.Equal(Role.User, message.Role); + Assert.Equal("Test prompt for sampling", Assert.IsType(Assert.Single(message.Content)).Text); - sampleCount++; - return new CreateMessageResult - { - Model = "test-model", - Role = Role.Assistant, - Content = [new TextContentBlock { Text = "Sampling response from client" }], - }; - } - } - }; - - await using var mcpClient = await ConnectAsync(clientOptions: clientOptions); + sampleCount++; + return new CreateMessageResult + { + Model = "test-model", + Role = Role.Assistant, + Content = [new TextContentBlock { Text = "Sampling response from client" }], + }; + }; + }); var result = await mcpClient.CallToolAsync("sampling-tool", new Dictionary { @@ -375,7 +371,11 @@ public async Task OutgoingFilter_SeesResponsesAndRequests() }, }; - await using var client = await ConnectAsync(clientOptions: clientOptions); + await using var client = await ConnectAsync(configureClient: opts => + { + opts.Capabilities = clientOptions.Capabilities; + opts.Handlers = clientOptions.Handlers; + }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); await client.CallToolAsync("echo_claims_principal", @@ -385,10 +385,12 @@ await client.CallToolAsync("sampling-tool", new Dictionary { ["prompt"] = "Hello" }, cancellationToken: TestContext.Current.CancellationToken); - Assert.Contains("initialize-response", observedMessageTypes); - Assert.Contains("tools-list-response", observedMessageTypes); - Assert.Contains("tool-call-response", observedMessageTypes); - Assert.Contains($"request:{RequestMethods.SamplingCreateMessage}", observedMessageTypes); + // Exact counts catch regressions where the outgoing filter pipeline gets applied more than once + // per outbound message (e.g., SendRequestAsync double-wrapping SendToRelatedTransportAsync). + Assert.Equal(1, observedMessageTypes.Count(m => m == "initialize-response")); + Assert.Equal(1, observedMessageTypes.Count(m => m == "tools-list-response")); + Assert.Equal(2, observedMessageTypes.Count(m => m == "tool-call-response")); // one per CallToolAsync + Assert.Equal(2, observedMessageTypes.Count(m => m == $"request:{RequestMethods.SamplingCreateMessage}")); // sampling-tool makes two SampleAsync calls } [Fact] @@ -496,6 +498,7 @@ public async Task OutgoingFilter_CanSendAdditionalMessages() Assert.Equal("injected", extraMessage); } + private ClaimsPrincipal CreateUser(string name) => new(new ClaimsIdentity( [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)], @@ -566,4 +569,5 @@ public static async Task LongRunningOperation( return $"Operation completed after {durationMs}ms"; } } + } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs new file mode 100644 index 000000000..6be82aec0 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -0,0 +1,469 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Net; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Protocol-level tests for Multi Round-Trip Requests (MRTR). +/// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior +/// including InputRequiredResult structure, retry with inputResponses, and error handling. +/// +public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = nameof(MrtrProtocolTests), + Version = "1", + }; + options.ProtocolVersion = "DRAFT-2026-v1"; + }).WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-tool", + Description = "Elicits from client" + }), + McpServerTool.Create( + static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams), + new McpServerToolCreateOptions + { + Name = "throwing-tool", + Description = "A tool that throws immediately" + }), + ]).WithHttpTransport(); + + _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")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + var response = await PostJsonRpcAsync(CallTool("throwing-tool")); + + // Should be a JSON-RPC error, not an InputRequiredResult + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); + var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); + var error = Assert.IsType(message); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains("Tool validation failed", error.Error.Message); + } + + [Fact] + public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + // Send a retry with a requestState that doesn't match any active continuation + var retryParams = new JsonObject + { + ["name"] = "elicit-tool", + ["arguments"] = new JsonObject { ["message"] = "test" }, + ["inputResponses"] = new JsonObject { ["key1"] = new JsonObject { ["action"] = "confirm" } }, + ["requestState"] = "nonexistent-state-id" + }; + + var response = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + + // Read as a generic JsonRpcMessage to check if it's an error + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); + var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); + + // Invalid requestState should result in a fresh tool invocation + // (the tool will return InputRequiredResult since it calls ElicitAsync) + // or an error, depending on the implementation. + // In our implementation, unrecognized requestState triggers a new invocation. + Assert.True( + message is JsonRpcResponse or JsonRpcError, + $"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}"); + } + + [Fact] + public async Task SessionDelete_CancelsPendingMrtrContinuation() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + // 1. Call a tool that suspends at ElicitAsync (implicit MRTR path). + var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); + var rpcResponse = await AssertSingleSseResponseAsync(response); + + // Verify we got an InputRequiredResult (handler is now suspended, continuation stored). + var resultObj = Assert.IsType(rpcResponse.Result); + Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); + var requestState = resultObj["requestState"]!.GetValue(); + Assert.False(string.IsNullOrEmpty(requestState)); + + // 2. DELETE the session while the handler is suspended. + using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + // Poll for the async cancellation to propagate through the handler task. + // Under thread pool starvation, this can take significantly longer than 100ms. + var deadline = DateTime.UtcNow.AddSeconds(30); + while (true) + { + if (MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) + || DateTime.UtcNow >= deadline) + { + break; + } + + await Task.Delay(100, TestContext.Current.CancellationToken); + } + + // 3. Verify that the MRTR cancellation was logged at Debug level. + var mrtrCancelledLog = MockLoggerProvider.LogMessages + .Where(m => m.Message.Contains("pending MRTR continuation")) + .ToList(); + Assert.Single(mrtrCancelledLog); + Assert.Equal(LogLevel.Debug, mrtrCancelledLog[0].LogLevel); + Assert.Contains("1", mrtrCancelledLog[0].Message); + + // 4. Verify no error-level log was emitted for the cancellation. + // The handler's OperationCanceledException should be silently observed, not logged as an error. + var errorLogs = MockLoggerProvider.LogMessages + .Where(m => m.LogLevel >= LogLevel.Error && m.Message.Contains("elicit")) + .ToList(); + Assert.Empty(errorLogs); + } + + [Fact] + public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + // 1. Call a tool that suspends at ElicitAsync. + var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); + var rpcResponse = await AssertSingleSseResponseAsync(response); + + var resultObj = Assert.IsType(rpcResponse.Result); + var requestState = resultObj["requestState"]!.GetValue(); + var inputRequests = resultObj["inputRequests"]!.AsObject(); + var inputKey = inputRequests.First().Key; + + // 2. DELETE the session. + using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + // 3. Attempt to retry with the old requestState - session is gone. + var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); + var retryParams = new JsonObject + { + ["name"] = "elicit-tool", + ["arguments"] = new JsonObject { ["message"] = "Please confirm" }, + ["requestState"] = requestState, + ["inputResponses"] = new JsonObject + { + [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) + }, + }; + + using var retryResponse = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + + // The session was deleted, so we should get a 404 with a JSON-RPC error. + Assert.Equal(HttpStatusCode.NotFound, retryResponse.StatusCode); + Assert.Equal("application/json", retryResponse.Content.Headers.ContentType?.MediaType); + } + + /// + /// Regression test for a CI hang where the server-side MRTR backcompat resolver routed its + /// outgoing roots/list request through the session-level transport, which silently + /// dropped the message when the client's GET stream had not been established yet. The + /// outgoing request must instead go through the POST's response stream (the request's + /// ) so it + /// reaches the client without depending on the GET stream at all. + /// + /// This test deliberately never opens a GET stream - it only POSTs the initialize, the + /// initialized notification, the tools/call, and the roots/list response. If the + /// server falls back to _transport.SendMessageAsync, the test times out instead of + /// reading the expected roots/list SSE event off the tools/call POST response. + /// + [Fact] + public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() + { + // Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current + // protocol with a legacy client. The backcompat resolver path only runs when the + // negotiated version is not DRAFT-2026-v1. + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = nameof(MrtrProtocolTests), + Version = "1", + }; + }).WithTools([ + McpServerTool.Create( + static string (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("roots", out var response)) + { + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; + return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "roots-state"); + }, + new McpServerToolCreateOptions + { + Name = "backcompat-roots-tool", + Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", + }), + ]).WithHttpTransport(); + + _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")); + + // Initialize with the current (non-draft) protocol so the server's backcompat resolver runs. + var initJson = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} + """; + + string sessionId; + using (var initResponse = await PostJsonRpcAsync(initJson)) + { + var initRpcResponse = await AssertSingleSseResponseAsync(initResponse); + Assert.NotNull(initRpcResponse.Result); + Assert.Equal("2025-11-25", initRpcResponse.Result["protocolVersion"]?.GetValue()); + + sessionId = Assert.Single(initResponse.Headers.GetValues("mcp-session-id")); + } + + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-11-25"); + + // Send the initialized notification. + using (var initializedResponse = await PostJsonRpcAsync( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""")) + { + Assert.True(initializedResponse.IsSuccessStatusCode); + } + + _lastRequestId = 1; + + // POST the tools/call and start reading the response SSE stream. We deliberately do NOT + // open a GET stream - the server-to-client roots/list must be delivered on this POST's + // response. Use HttpCompletionOption.ResponseHeadersRead so the POST returns as soon as + // the response headers arrive instead of waiting for the SSE stream to close. + var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) + { + Content = JsonContent(CallTool("backcompat-roots-tool")), + }; + callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); + callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); + + using var callResponse = await HttpClient.SendAsync( + callRequest, + HttpCompletionOption.ResponseHeadersRead, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, callResponse.StatusCode); + Assert.Equal("text/event-stream", callResponse.Content.Headers.ContentType?.MediaType); + + var sseEvents = ReadSseAsync(callResponse.Content) + .GetAsyncEnumerator(TestContext.Current.CancellationToken); + + try + { + // First SSE event on this POST should be the server-initiated roots/list request. + Assert.True(await sseEvents.MoveNextAsync(), + "Server did not send a roots/list request on the tools/call POST response stream. " + + "If this hangs/times out, the MRTR backcompat resolver is routing the outgoing request " + + "through the session-level transport instead of the POST's RelatedTransport."); + + var rootsRequestNode = JsonNode.Parse(sseEvents.Current) as JsonObject; + Assert.NotNull(rootsRequestNode); + Assert.Equal("roots/list", rootsRequestNode["method"]?.GetValue()); + var rootsRequestId = rootsRequestNode["id"]; + Assert.NotNull(rootsRequestId); + + // POST the roots/list response on a separate connection. The server's pending + // RequestRootsAsync await will complete and the backcompat resolver will retry the tool. + var rootsIdLiteral = rootsRequestId.ToJsonString(); + var rootsResponseJson = + "{\"jsonrpc\":\"2.0\",\"id\":" + rootsIdLiteral + + ",\"result\":{\"roots\":[{\"uri\":\"file:///workspace\",\"name\":\"Workspace\"}]}}"; + using (var rootsResponseHttp = await PostJsonRpcAsync(rootsResponseJson)) + { + Assert.True(rootsResponseHttp.IsSuccessStatusCode); + } + + // Next SSE event on the original POST should be the final tools/call response. + Assert.True(await sseEvents.MoveNextAsync(), "Server did not return the final tools/call response."); + var finalResponse = JsonSerializer.Deserialize(sseEvents.Current, GetJsonTypeInfo()); + Assert.NotNull(finalResponse); + Assert.NotNull(finalResponse.Result); + + var content = finalResponse.Result["content"]?.AsArray(); + Assert.NotNull(content); + var firstContent = Assert.Single(content); + Assert.Equal("roots-ok:Workspace", firstContent?["text"]?.GetValue()); + } + finally + { + await sseEvents.DisposeAsync(); + } + } + + // --- Helpers --- + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + private static async IAsyncEnumerable ReadSseAsync(HttpContent responseContent) + { + var responseStream = await responseContent.ReadAsStreamAsync(TestContext.Current.CancellationToken); + await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken)) + { + Assert.Equal("message", sseItem.EventType); + yield return sseItem.Data; + } + } + + private static async Task AssertSingleSseResponseAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); + + var sseItem = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); + var jsonRpcResponse = JsonSerializer.Deserialize(sseItem, GetJsonTypeInfo()); + + Assert.NotNull(jsonRpcResponse); + return jsonRpcResponse; + } + + private Task PostJsonRpcAsync(string json) + { + var content = JsonContent(json); + + // DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. + // Parse the body to derive them and attach to this request only. + var bodyNode = JsonNode.Parse(json); + if (bodyNode is JsonObject obj) + { + if (obj["method"]?.GetValue() is { } method) + { + content.Headers.Add("Mcp-Method", method); + + if (obj["params"] is JsonObject paramsObj) + { + string? mcpName = method switch + { + "tools/call" or "prompts/get" => paramsObj["name"]?.GetValue(), + "resources/read" => paramsObj["uri"]?.GetValue(), + _ => null, + }; + if (mcpName is not null) + { + content.Headers.Add("Mcp-Name", mcpName); + } + } + } + } + + return HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + } + + private long _lastRequestId = 1; + + private string Request(string method, string parameters = "{}") + { + var id = Interlocked.Increment(ref _lastRequestId); + return $$""" + {"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}} + """; + } + + private string CallTool(string toolName, string arguments = "{}") => + Request("tools/call", $$""" + {"name":"{{toolName}}","arguments":{{arguments}}} + """); + + /// + /// Initialize a session requesting the experimental protocol version that enables MRTR. + /// + private async Task InitializeWithMrtrAsync() + { + var initJson = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} + """; + + using var response = await PostJsonRpcAsync(initJson); + var rpcResponse = await AssertSingleSseResponseAsync(response); + Assert.NotNull(rpcResponse.Result); + + // Verify the server negotiated to the experimental version + var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); + Assert.Equal("DRAFT-2026-v1", protocolVersion); + + var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + + // Set the MCP-Protocol-Version header for subsequent requests + HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + + // Reset request ID counter since initialize used ID 1 + _lastRequestId = 1; + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 98cc5971a..ea4187a95 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -159,6 +159,34 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios. + // The csharp-sdk ConformanceServer surfaces the matching tools/prompts via + // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts. + // Each scenario uses the conformance harness's RawMcpSession, which negotiates DRAFT-2026-v1 + // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the + // upstream conformance package ships with SEP-2322 scenarios + // (https://github.com/modelcontextprotocol/conformance/pull/188). + [Theory] + [InlineData("incomplete-result-basic-elicitation")] + [InlineData("incomplete-result-basic-sampling")] + [InlineData("incomplete-result-basic-list-roots")] + [InlineData("incomplete-result-request-state")] + [InlineData("incomplete-result-multiple-input-requests")] + [InlineData("incomplete-result-multi-round")] + [InlineData("incomplete-result-missing-input-response")] + [InlineData("incomplete-result-non-tool-request")] + public async Task RunMrtrConformanceTest(string scenario) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + + var result = await RunConformanceTestsAsync( + $"server --url {fixture.ServerUrl} --scenario {scenario}"); + + Assert.True(result.Success, + $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments); diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 017ec235f..f30d58a4d 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -31,6 +31,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide .WithHttpTransport() .WithDistributedCacheEventStreamStore() .WithTools() + .WithTools() .WithTools([ConformanceTools.CreateJsonSchema202012Tool()]) .WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) => { @@ -47,6 +48,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide return result; })) .WithPrompts() + .WithPrompts() .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs new file mode 100644 index 000000000..4dfe6dfb0 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -0,0 +1,68 @@ +#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental. + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; + +namespace ConformanceServer.Prompts; + +/// +/// Prompt implementing the SEP-2322 D1 conformance scenario (incomplete-result-non-tool-request), +/// proving that prompts/get can return an just like +/// tools/call. +/// +[McpServerPromptType] +public sealed class IncompleteResultPrompts +{ + [McpServerPrompt(Name = "test_incomplete_result_prompt")] + [Description("SEP-2322 D1: prompts/get returns IncompleteResult until user_context is supplied.")] + public static GetPromptResult IncompleteResultPrompt(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_context", out var response)) + { + var elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + var contextValue = TryReadString(elicit?.Content, "context") ?? "(unknown)"; + return new GetPromptResult + { + Description = "Prompt customized with elicited user context.", + Messages = + [ + new PromptMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = $"Please continue using context: {contextValue}" }, + }, + ], + }; + } + + throw new InputRequiredException( + new Dictionary + { + ["user_context"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What context should the prompt use?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["context"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["context"], + }, + }), + }); + } + + private static string? TryReadString(IDictionary? content, string key) + { + if (content is null || !content.TryGetValue(key, out var element)) + { + return null; + } + return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString(); + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs new file mode 100644 index 000000000..caf91237a --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -0,0 +1,279 @@ +#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental. + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ConformanceServer.Tools; + +/// +/// Tools implementing the SEP-2322 (MRTR / IncompleteResult) conformance scenarios from +/// incomplete-result.ts in the conformance test suite. All tools use the +/// API so they work both in stateful sessions with +/// MRTR-aware clients and in legacy-resolve mode (the SDK will translate exceptions to the +/// proper wire shape based on negotiated protocol version). +/// +[McpServerToolType] +public sealed class IncompleteResultTools +{ + // ──── A1: Basic Elicitation ───────────────────────────────────────────── + [McpServerTool(Name = "test_tool_with_elicitation")] + [Description("SEP-2322 A1: returns IncompleteResult with elicitation/create keyed 'user_name'.")] + public static CallToolResult ToolWithElicitation(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_name", out var response)) + { + var elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + var name = TryReadString(elicit?.Content, "name") ?? "world"; + return TextResult($"Hello, {name}!"); + } + + throw new InputRequiredException( + new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }); + } + + // ──── A2: Basic Sampling ──────────────────────────────────────────────── + [McpServerTool(Name = "test_incomplete_result_sampling")] + [Description("SEP-2322 A2: returns IncompleteResult with sampling/createMessage keyed 'capital_question'.")] + public static CallToolResult ToolWithSampling(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("capital_question", out var response)) + { + var text = response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content?.OfType().FirstOrDefault()?.Text ?? "(no text)"; + return TextResult($"Sampling said: {text}"); + } + + throw new InputRequiredException( + new Dictionary + { + ["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "What is the capital of France?" }], + }, + ], + MaxTokens = 100, + }), + }); + } + + // ──── A3: Basic ListRoots ─────────────────────────────────────────────── + [McpServerTool(Name = "test_incomplete_result_list_roots")] + [Description("SEP-2322 A3: returns IncompleteResult with roots/list keyed 'client_roots'.")] + public static CallToolResult ToolWithListRoots(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("client_roots", out var response)) + { + var count = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots?.Count ?? 0; + return TextResult($"Got {count} root(s) from the client."); + } + + throw new InputRequiredException( + new Dictionary + { + ["client_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()), + }); + } + + // ──── B1: requestState round-trip ─────────────────────────────────────── + private const string RequestStateToken = "mrtr-conformance-state-v1"; + + [McpServerTool(Name = "test_incomplete_result_request_state")] + [Description("SEP-2322 B1: round-trips a requestState string; R2 echoes 'state-ok' on success.")] + public static CallToolResult ToolWithRequestState(RequestContext context) + { + if (context.Params!.RequestState is { } state) + { + if (state != RequestStateToken) + { + return TextResult("state-mismatch: client echoed an unexpected requestState"); + } + return TextResult("state-ok: server received and validated the echoed requestState"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["ok"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["ok"], + }, + }), + }, + requestState: RequestStateToken); + } + + // ──── B2: Multiple input requests in one round ────────────────────────── + [McpServerTool(Name = "test_incomplete_result_multiple_inputs")] + [Description("SEP-2322 B2: returns 3 simultaneous inputRequests (elicit + sampling + roots) plus requestState.")] + public static CallToolResult ToolWithMultipleInputs(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && responses.Count >= 3) + { + return TextResult("multiple-inputs-ok: received elicit + sampling + roots responses"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + ["greeting"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Generate a greeting" }], + }, + ], + MaxTokens = 50, + }), + ["client_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()), + }, + requestState: "multi-input-state"); + } + + // ──── B3: Multi-round (R1 -> incomplete, R2 -> incomplete (new state), R3 -> complete) ───── + [McpServerTool(Name = "test_incomplete_result_multi_round")] + [Description("SEP-2322 B3: three-round flow whose requestState changes between rounds.")] + public static CallToolResult ToolWithMultiRound(RequestContext context) + { + var state = context.Params!.RequestState; + if (state is null) + { + // Round 1: elicit name. + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["step1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Step 1: What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }, + requestState: "round-1"); + } + + if (state == "round-1") + { + // Round 2: elicit color (new state). + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["step2"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Step 2: What is your favorite color?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["color"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["color"], + }, + }), + }, + requestState: "round-2"); + } + + // Round 3: complete. + return TextResult("multi-round-ok"); + } + + // ──── C1: Missing/wrong inputResponses key - re-request rather than error ──── + [McpServerTool(Name = "test_incomplete_result_elicitation")] + [Description("SEP-2322 C1: re-requests missing inputResponses key instead of erroring.")] + public static CallToolResult ToolForMissingResponse(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_name", out var response)) + { + var elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + var name = TryReadString(elicit?.Content, "name") ?? "world"; + return TextResult($"Hello, {name}!"); + } + + // Either no inputResponses or wrong key - re-request via a fresh InputRequiredResult + // (per SEP-2322 recommendation in scenario C1). + throw new InputRequiredException( + new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }); + } + + private static CallToolResult TextResult(string text) => new() + { + Content = [new TextContentBlock { Text = text }], + }; + + private static string? TryReadString(IDictionary? content, string key) + { + if (content is null || !content.TryGetValue(key, out var element)) + { + return null; + } + return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString(); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 262efbd40..749ef51eb 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -587,6 +587,14 @@ public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) Assert.Equal(protocolVersion ?? "2025-11-25", client.NegotiatedProtocolVersion); } + [Fact] + public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() + { + Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1"; + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" }); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + } + [Fact] public async Task EndToEnd_SamplingWithTools_ServerUsesIChatClientWithFunctionInvocation_ClientHandlesSamplingWithIChatClient() { diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs new file mode 100644 index 000000000..90864d393 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -0,0 +1,570 @@ +#if !NET472 +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Edge-case and guardrail tests for MRTR over in-memory pipe transport. These focus on +/// scenarios not easily covered by +/// which provides broad happy-path coverage across StreamableHttp, SSE, and Stateless transports. +/// +public class MrtrIntegrationTests : ClientServerTestBase +{ + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + }, + new McpServerToolCreateOptions + { + Name = "elicitation-tool", + Description = "A tool that requests elicitation from the client" + }), + McpServerTool.Create( + async (McpServer server) => + { + // Attempt to send a JsonRpcRequest via SendMessageAsync - should always throw + // since requests must go through SendRequestAsync for response correlation. + try + { + await server.SendMessageAsync(new JsonRpcRequest + { + Id = new RequestId(999), + Method = RequestMethods.ElicitationCreate, + Params = JsonSerializer.SerializeToNode(new ElicitRequestParams + { + Message = "Bypass attempt", + RequestedSchema = new() + }, McpJsonUtilities.DefaultOptions) + }); + return "NOT BLOCKED - expected InvalidOperationException"; + } + catch (InvalidOperationException ex) + { + return $"blocked:{ex.Message}"; + } + }, + new McpServerToolCreateOptions + { + Name = "sendmessage-bypass-tool", + Description = "A tool that attempts to bypass MRTR via SendMessageAsync" + }) + ]); + } + + [Fact] + public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCaller() + { + // When the CLIENT's elicitation handler throws during MRTR input resolution, + // the retry never reaches the server - the server's handler remains suspended + // on ElicitAsync(). The exception should surface to the CallToolAsync caller, + // and the server's orphaned handler should be cleaned up on disposal. + // This is a fundamental MRTR limitation: the client has no channel to communicate + // input resolution failures back to the server. + StartServer(); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + throw new InvalidOperationException("Client-side elicitation failure"); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + // The client handler throws during input resolution, so the exception + // escapes ResolveInputRequestAsync and surfaces directly to the caller. + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync("elicitation-tool", + new Dictionary { ["message"] = "Will fail" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Client-side elicitation failure", ex.Message); + + // Dispose the server to trigger cleanup of the orphaned MRTR continuation. + // The server should cancel the handler suspended on ElicitAsync() and log + // the cancelled continuation at Debug level. + await Server.DisposeAsync(); + + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Debug && + m.Message.Contains("Cancelled") && + m.Message.Contains("MRTR continuation")); + } + + [Fact] + public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() + { + // SendMessageAsync should throw InvalidOperationException if the message is a + // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. + StartServer(); + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("sendmessage-bypass-tool", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.StartsWith("blocked:", text); + Assert.Contains("SendMessageAsync", text); + Assert.Contains("SendRequestAsync", text); + } + + [Fact] + public async Task LegacyRequestOnMrtrSession_LogsWarning() + { + // This test simulates a non-compliant server that negotiates MRTR + // but sends legacy elicitation/create JSON-RPC requests instead of + // using InputRequiredResult. The client should handle it but log a warning. + StartServer(); // Required for base class DisposeAsync cleanup + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + clientOptions.Handlers.SamplingHandler = (request, progress, ct) => + new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "sampled" }], + Model = "test-model" + }); + + // Start the client task - it will send initialize and block waiting for response + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + clientOptions, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Simulate server: read initialize request, respond with experimental version + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + // Read the initialize request from client + var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initLine); + var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(initRequest); + Assert.Equal("initialize", initRequest.Method); + + // Respond with experimental protocol version (MRTR negotiated) + var initResponse = new JsonRpcResponse + { + Id = initRequest.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, initResponse); + + // Read the initialized notification from client + var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initializedLine); + + // Client is now connected with MRTR negotiated + await using var client = await clientTask; + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + // Now simulate the non-compliant server sending a legacy elicitation/create request + var legacyRequest = new JsonRpcRequest + { + Id = new RequestId(42), + Method = RequestMethods.ElicitationCreate, + Params = JsonSerializer.SerializeToNode(new ElicitRequestParams + { + Message = "Legacy elicitation from non-compliant server", + RequestedSchema = new() + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, legacyRequest); + + // Read the client's response to the legacy request + var responseLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(responseLine); + var clientResponse = JsonSerializer.Deserialize(responseLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(clientResponse); + Assert.Equal(new RequestId(42), clientResponse.Id); + + // Verify the client handled the request (returned ElicitResult) + var elicitResult = JsonSerializer.Deserialize(clientResponse.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(elicitResult); + Assert.Equal("accept", elicitResult.Action); + + // Verify the warning was logged + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && + m.Message.Contains("elicitation/create") && + m.Message.Contains("MRTR")); + + // Clean up + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + [Fact] + public async Task IncompleteResultOnNonMrtrSession_LogsWarning() + { + // This test simulates a non-compliant server that sends an InputRequiredResult + // to a client that did NOT negotiate MRTR. The client should still process it + // (resilience), but log a warning about the unexpected protocol behavior. + StartServer(); // Required for base class DisposeAsync cleanup + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + // Client does NOT set DRAFT-2026-v1 - standard protocol only + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["confirmed"] = JsonDocument.Parse("\"yes\"").RootElement.Clone() + } + }); + + // Start the client task - it will send initialize and block waiting for response + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + clientOptions, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + // Read the initialize request from client + var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initLine); + var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(initRequest); + Assert.Equal("initialize", initRequest.Method); + + // Respond with standard protocol version (no MRTR) + var initResponse = new JsonRpcResponse + { + Id = initRequest.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-03-26", + Capabilities = new ServerCapabilities { Tools = new() }, + ServerInfo = new Implementation { Name = "NonCompliantServer", Version = "1.0" } + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, initResponse); + + // Read the initialized notification from client + var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initializedLine); + + // Client is now connected with standard protocol (no MRTR) + await using var client = await clientTask; + Assert.Equal("2025-03-26", client.NegotiatedProtocolVersion); + + // Start a background task to handle the client's tools/call request + var cancellationToken = TestContext.Current.CancellationToken; + var serverLoop = Task.Run(async () => + { + // Read tools/call request from client + var callLine = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(callLine); + var callRequest = JsonSerializer.Deserialize(callLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(callRequest); + Assert.Equal("tools/call", callRequest.Method); + + // Non-compliant server sends InputRequiredResult on standard protocol session! + var InputRequiredResult = new JsonObject + { + ["resultType"] = "input_required", + ["inputRequests"] = new JsonObject + { + ["confirm_1"] = JsonSerializer.SerializeToNode( + InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Unexpected elicitation from non-compliant server", + RequestedSchema = new() + }), McpJsonUtilities.DefaultOptions) + }, + ["requestState"] = "non-mrtr-state" + }; + + var incompleteResponse = new JsonRpcResponse + { + Id = callRequest.Id, + Result = InputRequiredResult, + }; + await WriteJsonRpcAsync(serverWriter, incompleteResponse); + + // Read the retry request with inputResponses from client + var retryLine = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(retryLine); + var retryRequest = JsonSerializer.Deserialize(retryLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(retryRequest); + Assert.Equal("tools/call", retryRequest.Method); + + // Verify the retry contains inputResponses and requestState + var retryParams = retryRequest.Params as JsonObject; + Assert.NotNull(retryParams); + Assert.NotNull(retryParams["inputResponses"]); + Assert.Equal("non-mrtr-state", retryParams["requestState"]?.GetValue()); + + // Now respond with a normal result + var normalResult = new JsonRpcResponse + { + Id = retryRequest.Id, + Result = JsonSerializer.SerializeToNode(new CallToolResult + { + Content = [new TextContentBlock { Text = "completed-without-mrtr" }] + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, normalResult); + }, cancellationToken); + + // Client calls the tool - the non-compliant server will send InputRequiredResult + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams + { + Name = "any-tool", + }, McpJsonUtilities.DefaultOptions) + }, + cancellationToken); + + await serverLoop; + + Assert.NotNull(response.Result); + var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + var content = Assert.Single(result.Content); + Assert.Equal("completed-without-mrtr", Assert.IsType(content).Text); + + // Verify the warning was logged about InputRequiredResult on non-MRTR session + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && + m.Message.Contains("InputRequiredResult") && + m.Message.Contains("did not negotiate MRTR")); + + // Clean up + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + [Fact] + public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFromRetryParams() + { + // Regression test for #1458 review feedback: when the server returns InputRequiredResult + // with requestState on round 1 and then InputRequiredResult WITHOUT requestState on round 2, + // the client's third retry must NOT carry the stale round-1 requestState forward via the + // params deep clone. Without the fix, the third retry's params contain {"requestState": "round1-state"} + // even though the round-2 InputRequiredResult cleared it. + StartServer(); // base-class disposal hook + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["confirmed"] = JsonDocument.Parse("\"yes\"").RootElement.Clone() + } + }); + + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + clientOptions, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + // Initialize handshake - negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR. + var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initLine); + var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(initRequest); + Assert.Equal("initialize", initRequest.Method); + + var initResponse = new JsonRpcResponse + { + Id = initRequest.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ServerCapabilities { Tools = new() }, + ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, initResponse); + + var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initializedLine); + + await using var client = await clientTask; + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var cancellationToken = TestContext.Current.CancellationToken; + + // Capture the retry payloads sent by the client so we can inspect them after the call completes. + JsonObject? retry1Params = null; + JsonObject? retry2Params = null; + + var serverLoop = Task.Run(async () => + { + // --- Round 1: receive original tools/call, respond with InputRequiredResult + requestState="round1-state". + var call1Line = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(call1Line); + var call1 = JsonSerializer.Deserialize(call1Line, McpJsonUtilities.DefaultOptions); + Assert.NotNull(call1); + Assert.Equal("tools/call", call1.Method); + + var round1Result = new JsonObject + { + ["resultType"] = "input_required", + ["inputRequests"] = new JsonObject + { + ["q1"] = JsonSerializer.SerializeToNode( + InputRequest.ForElicitation(new ElicitRequestParams { Message = "round1", RequestedSchema = new() }), + McpJsonUtilities.DefaultOptions), + }, + ["requestState"] = "round1-state", + }; + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse { Id = call1.Id, Result = round1Result }); + + // --- Round 2: receive first retry (should include requestState="round1-state" + inputResponses). + var call2Line = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(call2Line); + var call2 = JsonSerializer.Deserialize(call2Line, McpJsonUtilities.DefaultOptions); + Assert.NotNull(call2); + retry1Params = call2.Params as JsonObject; + + // Respond with another InputRequiredResult - this time WITHOUT requestState - to force the + // client to clear any stale state on the next retry params clone. + var round2Result = new JsonObject + { + ["resultType"] = "input_required", + ["inputRequests"] = new JsonObject + { + ["q2"] = JsonSerializer.SerializeToNode( + InputRequest.ForElicitation(new ElicitRequestParams { Message = "round2", RequestedSchema = new() }), + McpJsonUtilities.DefaultOptions), + }, + // Intentionally NO "requestState" key. + }; + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse { Id = call2.Id, Result = round2Result }); + + // --- Round 3: receive second retry - assertion target. Must NOT contain "requestState". + var call3Line = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(call3Line); + var call3 = JsonSerializer.Deserialize(call3Line, McpJsonUtilities.DefaultOptions); + Assert.NotNull(call3); + retry2Params = call3.Params as JsonObject; + + // Final success response so the client's call completes cleanly. + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse + { + Id = call3.Id, + Result = JsonSerializer.SerializeToNode(new CallToolResult + { + Content = [new TextContentBlock { Text = "done" }] + }, McpJsonUtilities.DefaultOptions), + }); + }, cancellationToken); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "any-tool" }, McpJsonUtilities.DefaultOptions), + }, + cancellationToken); + + await serverLoop; + + // Sanity check the final result reached us. + Assert.NotNull(response.Result); + var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Equal("done", Assert.IsType(Assert.Single(result.Content)).Text); + + // The first retry must carry requestState="round1-state". + Assert.NotNull(retry1Params); + Assert.NotNull(retry1Params!["inputResponses"]); + Assert.Equal("round1-state", retry1Params["requestState"]?.GetValue()); + + // The second retry must NOT carry a stale requestState. Pre-fix, the deep clone of the + // round-1 request kept "round1-state" in paramsObj because the client only OVERWROTE it + // when InputRequiredResult.RequestState was non-null. With the fix, it explicitly removes + // the key whenever the server's new InputRequiredResult clears it. + Assert.NotNull(retry2Params); + Assert.NotNull(retry2Params!["inputResponses"]); + Assert.False(retry2Params.ContainsKey("requestState"), + "Retry params must not carry a stale requestState from the previous round."); + + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions); + await writer.WriteAsync(bytes, TestContext.Current.CancellationToken); + await writer.WriteAsync("\n"u8.ToArray(), TestContext.Current.CancellationToken); + await writer.FlushAsync(TestContext.Current.CancellationToken); + } +} + +#endif diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs index 171c6bead..a39d4896f 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs @@ -87,8 +87,14 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // The message filter should intercept JsonRpcRequest messages - Assert.Contains("JsonRpcRequest", messageTypes); + // The message filter should intercept JsonRpcRequest messages. + // Use strict counts so a regression that invokes the filter pipeline more than once per + // incoming message (analogous to the SendRequestAsync double-wrap regression on the outgoing + // side) would fail this test instead of slipping through Assert.Contains. + // A single ListToolsAsync drives three server-bound messages: initialize (request), + // notifications/initialized (notification), and tools/list (request). + Assert.Equal(2, messageTypes.Count(m => m == nameof(JsonRpcRequest))); + Assert.Equal(1, messageTypes.Count(m => m == nameof(JsonRpcNotification))); } [Fact] @@ -142,6 +148,13 @@ public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() Assert.True(idx1Before < idx2Before); Assert.True(idx2Before < idx2After); Assert.True(idx2After < idx1After); + + // Verify each filter ran exactly once per incoming message (initialize + notifications/initialized + tools/list). + // Strict counts catch regressions where the incoming filter pipeline gets invoked more than once per message. + Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 before")); + Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 before")); + Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 after")); + Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 after")); } [Fact] @@ -372,15 +385,20 @@ public async Task AddOutgoingMessageFilter_Sees_Responses_Notifications_And_Requ await client.CallToolAsync("sampling-tool", new Dictionary { ["prompt"] = "Hello" }, cancellationToken: TestContext.Current.CancellationToken); + // Exact counts catch regressions where the outgoing filter pipeline gets applied more than once + // per outbound message (e.g., SendRequestAsync double-wrapping SendToRelatedTransportAsync). + Assert.Equal(1, observedMessages.Count(m => m == "initialize")); + Assert.Equal(2, observedMessages.Count(m => m == "progress")); // ProgressTool sends two NotifyProgressAsync calls + Assert.Equal(2, observedMessages.Count(m => m == "response")); // one tool-call response per CallToolAsync + Assert.Equal(1, observedMessages.Count(m => m == $"request:{RequestMethods.SamplingCreateMessage}")); + + // Preserve the original ordering intent: initialize first, then progress, then the final response. int initializeIndex = observedMessages.IndexOf("initialize"); int progressIndex = observedMessages.IndexOf("progress"); int responseIndex = observedMessages.LastIndexOf("response"); - int requestIndex = observedMessages.IndexOf($"request:{RequestMethods.SamplingCreateMessage}"); - Assert.True(initializeIndex >= 0); Assert.True(progressIndex > initializeIndex); Assert.True(responseIndex > progressIndex); - Assert.True(requestIndex >= 0); } [Fact] @@ -516,7 +534,7 @@ public async Task AddIncomingMessageFilter_SkipNext_DoesNotLogSendingResponse() McpServerBuilder .WithMessageFilters(filters => filters.AddIncomingFilter((next) => (context, cancellationToken) => { - // Skip processing tools/list requests — handler never runs, no response sent + // Skip processing tools/list requests - handler never runs, no response sent if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList) { return Task.CompletedTask; @@ -552,7 +570,7 @@ public async Task AddIncomingMessageFilter_CallsNext_LogsSendingResponse() McpServerBuilder .WithMessageFilters(filters => filters.AddIncomingFilter((next) => (context, cancellationToken) => { - // Pass through — handler runs, response is sent + // Pass through - handler runs, response is sent return next(context, cancellationToken); })) .WithTools(); diff --git a/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs new file mode 100644 index 000000000..e44f6527c --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs @@ -0,0 +1,298 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class MrtrSerializationTests +{ + [Fact] + public static void IncompleteResult_SerializationRoundTrip_PreservesAllProperties() + { + var original = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }), + ["input_2"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], + MaxTokens = 100 + }) + }, + RequestState = "correlation-123", + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("input_required", deserialized.ResultType); + Assert.Equal("correlation-123", deserialized.RequestState); + Assert.NotNull(deserialized.InputRequests); + Assert.Equal(2, deserialized.InputRequests.Count); + Assert.True(deserialized.InputRequests.ContainsKey("input_1")); + Assert.True(deserialized.InputRequests.ContainsKey("input_2")); + } + + [Fact] + public static void IncompleteResult_HasResultTypeIncomplete() + { + var result = new InputRequiredResult(); + Assert.Equal("input_required", result.ResultType); + } + + [Fact] + public static void IncompleteResult_ResultType_AppearsInJson() + { + var result = new InputRequiredResult + { + RequestState = "abc", + }; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("input_required", (string?)node["resultType"]); + Assert.Equal("abc", (string?)node["requestState"]); + } + + [Fact] + public static void InputRequest_ForElicitation_SerializesCorrectly() + { + var inputRequest = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Enter name", + RequestedSchema = new() + }); + + string json = JsonSerializer.Serialize(inputRequest, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("elicitation/create", (string?)node["method"]); + Assert.NotNull(node["params"]); + Assert.Equal("Enter name", (string?)node["params"]!["message"]); + } + + [Fact] + public static void InputRequest_ForSampling_SerializesCorrectly() + { + var inputRequest = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Prompt" }] }], + MaxTokens = 50 + }); + + string json = JsonSerializer.Serialize(inputRequest, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("sampling/createMessage", (string?)node["method"]); + Assert.NotNull(node["params"]); + Assert.Equal(50, (int?)node["params"]!["maxTokens"]); + } + + [Fact] + public static void InputRequest_ForRootsList_SerializesCorrectly() + { + var inputRequest = InputRequest.ForRootsList(new ListRootsRequestParams()); + + string json = JsonSerializer.Serialize(inputRequest, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("roots/list", (string?)node["method"]); + } + + [Fact] + public static void InputRequest_Elicitation_RoundTrip() + { + var original = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "test message", + RequestedSchema = new() + }); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("elicitation/create", deserialized.Method); + Assert.NotNull(deserialized.ElicitationParams); + Assert.Equal("test message", deserialized.ElicitationParams.Message); + } + + [Fact] + public static void InputRequest_Sampling_RoundTrip() + { + var original = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], + MaxTokens = 200 + }); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("sampling/createMessage", deserialized.Method); + Assert.NotNull(deserialized.SamplingParams); + Assert.Equal(200, deserialized.SamplingParams.MaxTokens); + } + + [Fact] + public static void InputRequest_RootsList_RoundTrip() + { + var original = InputRequest.ForRootsList(new ListRootsRequestParams()); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("roots/list", deserialized.Method); + Assert.NotNull(deserialized.RootsParams); + } + + [Fact] + public static void InputResponse_FromSamplingResult_RoundTrip() + { + var samplingResult = new CreateMessageResult + { + Content = [new TextContentBlock { Text = "Response text" }], + Model = "test-model" + }; + + var inputResponse = InputResponse.FromSamplingResult(samplingResult); + + // Serialize → deserialize + string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + var sampling = deserialized.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo); + Assert.NotNull(sampling); + Assert.Equal("test-model", sampling.Model); + } + + [Fact] + public static void InputResponse_FromElicitResult_RoundTrip() + { + var elicitResult = new ElicitResult + { + Action = "confirm", + Content = new Dictionary + { + ["key"] = JsonDocument.Parse("\"value\"").RootElement.Clone() + } + }; + + var inputResponse = InputResponse.FromElicitResult(elicitResult); + + string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + var elicit = deserialized.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + Assert.NotNull(elicit); + Assert.Equal("confirm", elicit.Action); + } + + [Fact] + public static void InputResponse_FromRootsResult_RoundTrip() + { + var rootsResult = new ListRootsResult + { + Roots = [new Root { Uri = "file:///test", Name = "Test" }] + }; + + var inputResponse = InputResponse.FromRootsResult(rootsResult); + + string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + var roots = deserialized.Deserialize(InputResponse.ListRootsResultJsonTypeInfo); + Assert.NotNull(roots); + Assert.Single(roots.Roots); + Assert.Equal("file:///test", roots.Roots[0].Uri); + } + + [Fact] + public static void InputRequestDictionary_SerializationRoundTrip() + { + IDictionary requests = new Dictionary + { + ["a"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "q1", RequestedSchema = new() }), + ["b"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "q2" }] }], + MaxTokens = 50 + }), + }; + + string json = JsonSerializer.Serialize(requests, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize>(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + Assert.Equal("elicitation/create", deserialized["a"].Method); + Assert.Equal("sampling/createMessage", deserialized["b"].Method); + } + + [Fact] + public static void InputResponseDictionary_SerializationRoundTrip() + { + IDictionary responses = new Dictionary + { + ["a"] = InputResponse.FromElicitResult(new ElicitResult { Action = "confirm" }), + ["b"] = InputResponse.FromSamplingResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "AI" }], + Model = "m1" + }), + }; + + string json = JsonSerializer.Serialize(responses, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize>(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + } + + [Fact] + public static void Result_ResultType_DefaultsToNull() + { + var result = new CallToolResult + { + Content = [new TextContentBlock { Text = "test" }] + }; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + // result_type should not appear for normal results + Assert.Null(node?["resultType"]); + } + + [Fact] + public static void RequestParams_InputResponses_NotSerializedByDefault() + { + var callParams = new CallToolRequestParams + { + Name = "test-tool", + }; + + string json = JsonSerializer.Serialize(callParams, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + // inputResponses and requestState should not appear when null + Assert.Null(node?["inputResponses"]); + Assert.Null(node?["requestState"]); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs new file mode 100644 index 000000000..662ffdb27 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the server-to-client request methods (, +/// , +/// ) keep working when the negotiated protocol revision is +/// DRAFT-2026-v1 on a stateful session - for example, stdio. +/// +/// +/// Under DRAFT-2026-v1 the spec removes the corresponding server-to-client request methods, but +/// the SDK only fails fast in stateless mode (where the existing ThrowIf*Unsupported guards already +/// throw "X is not supported in stateless mode" because is +/// ). Stdio is implicitly stateful - one per process - so the +/// legacy elicitation/create / sampling/createMessage / roots/list flow still works. +/// A future PR is expected to force DRAFT-2026-v1 Streamable HTTP servers to stateless mode, at which +/// point those configurations will start throwing through the existing stateless guard. +/// +public sealed class DraftProtocolBackcompatTests : ClientServerTestBase +{ + public DraftProtocolBackcompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create(ElicitToolAsync, new() { Name = "elicit-tool" }), + McpServerTool.Create(SampleToolAsync, new() { Name = "sample-tool" }), + McpServerTool.Create(RootsToolAsync, new() { Name = "roots-tool" }), + ]); + } + + [Fact] + public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability(), + }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { Action = "accept" }), + }, + }); + + var result = await client.CallToolAsync("elicit-tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("elicit-ok:accept", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + }, + Handlers = new McpClientHandlers + { + SamplingHandler = (_, _, _) => new ValueTask(new CreateMessageResult + { + Model = "test-model", + Role = Role.Assistant, + Content = [new TextContentBlock { Text = "hello back" }], + }), + }, + }); + + var result = await client.CallToolAsync("sample-tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("sample-ok:hello back", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task RequestRootsAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability(), + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => new ValueTask(new ListRootsResult + { + Roots = [new Root { Uri = "file:///home", Name = "home" }], + }), + }, + }); + + var result = await client.CallToolAsync("roots-tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("roots-ok:file:///home", Assert.IsType(result.Content[0]).Text); + } + + private static async Task ElicitToolAsync(McpServer server, CancellationToken cancellationToken) + { + var elicit = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Need input", + RequestedSchema = new(), + }, cancellationToken); + return $"elicit-ok:{elicit.Action}"; + } + + private static async Task SampleToolAsync(McpServer server, CancellationToken cancellationToken) + { + var sample = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "ping" }], + }, + ], + MaxTokens = 16, + }, cancellationToken); + var text = sample.Content.OfType().FirstOrDefault()?.Text; + return $"sample-ok:{text}"; + } + + private static async Task RootsToolAsync(McpServer server, CancellationToken cancellationToken) + { + var roots = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken); + return $"roots-ok:{roots.Roots.FirstOrDefault()?.Uri}"; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs new file mode 100644 index 000000000..9a408ce78 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -0,0 +1,438 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the server's MRTR handler lifecycle management - cancellation, disposal, and error +/// logging during multi round-trip request processing. +/// +public class MrtrHandlerLifecycleTests : ClientServerTestBase +{ + private readonly TaskCompletionSource _handlerTokenCancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _handlerStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _handlerResumed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseHandler = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrHandlerLifecycleTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + }, + new McpServerToolCreateOptions + { + Name = "elicitation-tool", + Description = "A tool that requests elicitation from the client" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + var handlerTokenCancelled = _handlerTokenCancelled; + ct.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), handlerTokenCancelled); + _handlerStarted.TrySetResult(true); + + await server.ElicitAsync(new ElicitRequestParams + { + Message = "Cancellation test", + RequestedSchema = new() + }, ct); + + return "done"; + }, + new McpServerToolCreateOptions + { + Name = "cancellation-test-tool", + Description = "A tool that monitors its CancellationToken during MRTR" + }), + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + // Elicit first, then block forever - the retry request stays in-flight + // until the client cancels, verifying that notifications/cancelled for + // the retry's request ID flows through to cancel this handler. + _handlerStarted.TrySetResult(true); + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + // Signal that we resumed after ElicitAsync, then block. + _handlerResumed.TrySetResult(true); + await Task.Delay(Timeout.Infinite, ct); + return "unreachable"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-then-block-tool", + Description = "A tool that elicits then blocks forever for cancellation testing" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + // Two sequential MRTR rounds. The client will inject a stale cancellation + // notification for the original request ID between round 1 and round 2. + var r1 = await server.ElicitAsync(new ElicitRequestParams + { + Message = "First elicitation", + RequestedSchema = new() + }, ct); + + // Signal that round 1 completed so the test can inject the stale notification. + _handlerResumed.TrySetResult(true); + + var r2 = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Second elicitation", + RequestedSchema = new() + }, ct); + + return $"{r1.Action},{r2.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "double-elicit-tool", + Description = "A tool that elicits twice for stale cancellation testing" + }), + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + // Elicit, resume, then wait on _releaseHandler for the dispose test. + _handlerStarted.TrySetResult(true); + await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + _handlerResumed.TrySetResult(true); + await _releaseHandler.Task; + return "handler-completed"; + }, + new McpServerToolCreateOptions + { + Name = "dispose-wait-tool", + Description = "A tool that elicits, resumes, then waits on a signal for disposal testing" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + await server.ElicitAsync(new ElicitRequestParams + { + Message = "elicit-then-throw", + RequestedSchema = new() + }, ct); + + throw new InvalidOperationException("Deliberate MRTR handler error for testing"); + }, + new McpServerToolCreateOptions + { + Name = "elicit-then-throw-tool", + Description = "A tool that elicits then throws an exception for error logging testing" + }), + McpServerTool.Create( + (McpServer server) => + { + // Low-level MRTR: throw InputRequiredException directly instead of using ElicitAsync. + // This should NOT be logged at Error level - it's normal MRTR control flow. + throw new InputRequiredException(new InputRequiredResult + { + InputRequests = new Dictionary + { + ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "low-level elicit", + RequestedSchema = new() + }) + } + }); + }, + new McpServerToolCreateOptions + { + Name = "incomplete-result-tool", + Description = "A tool that throws InputRequiredException for low-level MRTR" + }) + ]); + } + + [Fact] + public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCanceled() + { + // Verify that cancelling the CancellationToken during the MRTR retry loop + // (specifically during the elicitation handler callback) stops the loop. + StartServer(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + // Cancel the token during the callback. The retry loop will throw + // OperationCanceledException on the next await after this handler returns. + cts.Cancel(); + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + await Assert.ThrowsAsync(async () => + await client.CallToolAsync("elicitation-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: cts.Token)); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() + { + // Verify that disposing the server cancels the handler's own CancellationToken + // (the `ct` parameter), not just the exchange ResponseTcs. Before the HandlerCts fix, + // the handler's CT was from a disposed CTS and could never be triggered. + StartServer(); + var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = async (request, ct) => + { + // Signal that the MRTR round trip reached the client, then block indefinitely. + elicitHandlerCalled.TrySetResult(true); + await Task.Delay(Timeout.Infinite, ct); + throw new OperationCanceledException(ct); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start the tool call in the background. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + var callTask = client.CallToolAsync("cancellation-test-tool", cancellationToken: cts.Token).AsTask(); + + // Wait for the handler to start on the server. + await _handlerStarted.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Wait for the MRTR round trip to reach the client's elicitation handler. + await elicitHandlerCalled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Dispose the server - HandlerCts.Cancel() should trigger the handler's CancellationToken. + await Server.DisposeAsync(); + + // Verify the handler's CancellationToken was actually cancelled via HandlerCts, + // not just the exchange ResponseTcs.TrySetCanceled(). + await _handlerTokenCancelled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // The client call should fail (server disposed mid-MRTR). + await Assert.ThrowsAnyAsync(async () => await callTask); + } + + [Fact] + public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandler() + { + // Verify that cancelling the client's CancellationToken while a retry request is in-flight + // sends notifications/cancelled with the retry's request ID, and the server correctly + // routes it to cancel the handler. This proves end-to-end that: + // (a) the client sends the notification with the CURRENT request ID (not the original), + // (b) the server's _handlingRequests lookup finds the retry's CTS, + // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. + StartServer(); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + var callTask = client.CallToolAsync( + "elicit-then-block-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: cts.Token).AsTask(); + + // Wait for the handler to resume after ElicitAsync - at this point the retry + // request is in-flight (server is awaiting WhenAny in AwaitMrtrHandlerAsync). + await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Cancel the client's token. The client is inside _sessionHandler.SendRequestAsync + // awaiting the retry response. RegisterCancellation fires and sends + // notifications/cancelled with the retry's request ID. + cts.Cancel(); + + // The call should throw OperationCanceledException. + await Assert.ThrowsAnyAsync(async () => await callTask); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHandler() + { + // Verify that a stale cancellation notification for the original (now-completed) + // request ID does not interfere with an active MRTR handler. The original request's + // entry was removed from _handlingRequests when it returned InputRequiredResult, so + // the notification should be a no-op. + StartServer(); + + int elicitationCount = 0; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + Interlocked.Increment(ref elicitationCount); + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start the double-elicit tool. Between round 1 and round 2, we'll inject a stale + // cancellation notification for a fake (expired) request ID. + var callTask = client.CallToolAsync( + "double-elicit-tool", + cancellationToken: TestContext.Current.CancellationToken).AsTask(); + + // Wait for handler to resume after the first ElicitAsync. + await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Send a stale cancellation notification for a non-existent request ID. + // This simulates a delayed notification for the original request that already completed. + await client.SendMessageAsync(new JsonRpcNotification + { + Method = NotificationMethods.CancelledNotification, + Params = JsonSerializer.SerializeToNode( + new CancelledNotificationParams { RequestId = new RequestId("stale-id-999"), Reason = "stale test" }, + McpJsonUtilities.DefaultOptions), + }, TestContext.Current.CancellationToken); + + // The tool should complete successfully - the stale notification didn't affect it. + var result = await callTask; + Assert.Contains("accept", result.Content.OfType().First().Text); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() + { + // Verify that McpServer.DisposeAsync() waits for an MRTR handler to complete + // before returning, similar to RunAsync_WaitsForInFlightHandlersBeforeReturning + // which tests the same invariant for regular request handlers in McpSessionHandler. + StartServer(); + bool handlerCompleted = false; + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start the tool call that calls ElicitAsync, then blocks on _releaseHandler. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + _ = client.CallToolAsync( + "dispose-wait-tool", + new Dictionary { ["message"] = "dispose-wait-test" }, + cancellationToken: cts.Token); + + // Wait for the handler to resume after ElicitAsync - it's now blocking on _releaseHandler. + await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Dispose the server. The handler is still running (blocked on _releaseHandler). + // Release the handler after a delay - DisposeAsync must wait for it. + var ct = TestContext.Current.CancellationToken; + _ = Task.Run(async () => + { + await Task.Delay(200, ct); + handlerCompleted = true; + _releaseHandler.SetResult(true); + }, ct); + + await Server.DisposeAsync(); + + // DisposeAsync should not have returned until the handler completed. + Assert.True(handlerCompleted, "DisposeAsync should wait for MRTR handlers to complete before returning."); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() + { + // Verify that when a tool handler throws an unhandled exception during MRTR + // (after resuming from ElicitAsync), the error is logged at Error level. + StartServer(); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Call the tool that elicits then throws. The retry returns an error result. + var result = await client.CallToolAsync( + "elicit-then-throw-tool", + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.IsError); + + // Verify the tool error was logged at Error level during the MRTR retry. + // The ToolsCall handler catches the exception, logs it via ToolCallError, + // and converts it to an error result - so the error is properly surfaced. + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Error && + m.Message.Contains("elicit-then-throw-tool") && + m.Exception is InvalidOperationException); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() + { + // InputRequiredException is normal MRTR control flow (low-level API), + // not an error. It should not be logged via ToolCallError at Error level. + StartServer(); + + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // The tool always throws InputRequiredException (low-level MRTR path), + // so the client will retry until hitting the max retry limit. + await Assert.ThrowsAsync(() => client.CallToolAsync( + "incomplete-result-tool", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Error && + m.Exception is InputRequiredException); + + _messageTracker.AssertMrtrUsed(); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs new file mode 100644 index 000000000..664429b13 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the MRTR server API - IsMrtrSupported, InputRequiredException, +/// and client auto-retry of incomplete results. +/// +public class MrtrInputRequiredExceptionTests : ClientServerTestBase +{ + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrInputRequiredExceptionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + static string (McpServer server) => + { + throw new InputRequiredException(requestState: "should-not-work"); + }, + new McpServerToolCreateOptions + { + Name = "always-incomplete", + Description = "Tool that always throws InputRequiredException" + }), + ]); + } + + [Fact] + public async Task InputRequiredException_WithoutInputRequests_ExhaustsRetries() + { + StartServer(); + var clientOptions = new McpClientOptions(); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // The always-incomplete tool throws InputRequiredException with only requestState + // and no inputRequests. The client has nothing to dispatch, so it keeps retrying + // with the same requestState until the retry budget is exhausted. + var exception = await Assert.ThrowsAsync(() => + client.CallToolAsync("always-incomplete", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("more than", exception.Message); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs new file mode 100644 index 000000000..fd9098734 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests that message filters correctly observe MRTR protocol behavior - verifying that +/// InputRequiredResult responses are visible to outgoing filters, and that no legacy +/// elicitation/sampling requests are sent when MRTR is active. +/// +public class MrtrMessageFilterTests : ClientServerTestBase +{ + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrMessageFilterTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder + .WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-tool", + Description = "A tool that requests elicitation" + }), + McpServerTool.Create( + async (string prompt, McpServer server, CancellationToken ct) => + { + var result = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], + MaxTokens = 100 + }, ct); + + return result.Content.OfType().FirstOrDefault()?.Text ?? ""; + }, + new McpServerToolCreateOptions + { + Name = "sample-tool", + Description = "A tool that requests sampling" + }), + ]); + } + + [Fact] + public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() + { + // When both sides are on the experimental protocol, the server should use MRTR + // (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests. + StartServer(); + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(result.Content); + Assert.Equal("accept", Assert.IsType(content).Text); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() + { + StartServer(); + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.SamplingHandler = (request, progress, ct) => + { + var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = $"Sampled: {text}" }], + Model = "test-model" + }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("sample-tool", + new Dictionary { ["prompt"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(result.Content); + Assert.Equal("Sampled: test", Assert.IsType(content).Text); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task OutgoingFilter_SeesIncompleteResultResponse() + { + // Verify that transport middleware can observe the raw InputRequiredResult + // in outgoing JSON-RPC responses (validates MRTR transport visibility). + var sawIncompleteResult = false; + + StartServer(); + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + // If we reach this handler, it means the client received an InputRequiredResult + // from the server, resolved the elicitation, and is retrying. + sawIncompleteResult = true; + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + // The elicitation handler was called, confirming MRTR round-trip occurred + // (InputRequiredResult was sent by server and processed by client). + Assert.True(sawIncompleteResult, "Expected MRTR round-trip with InputRequiredResult"); + _messageTracker.AssertMrtrUsed(); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs new file mode 100644 index 000000000..d8fa6f32b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the legacy MRTR backcompat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync. +/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful - +/// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler +/// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks +/// a non-draft version during initialize negotiation. +/// +public class MrtrServerBackcompatTests : ClientServerTestBase +{ + private readonly List _observedRequestStates = []; + private int _attempt; + + public MrtrServerBackcompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([ + McpServerTool.Create( + (RequestContext context) => + { + var attempt = Interlocked.Increment(ref _attempt); + _observedRequestStates.Add(context.Params?.RequestState); + + return attempt switch + { + // Round 1: caller has no state; emit one and request elicitation. + 1 => throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "round1", + RequestedSchema = new() + }) + }, + requestState: "round1"), + // Round 2: deliberately clear the state by passing requestState: null while still + // asking for another elicitation. This exercises the params clone path that + // previously preserved the stale "round1" carry-over from round 1's deep clone. + 2 => throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "round2", + RequestedSchema = new() + }) + }, + requestState: null), + // Round 3 (final): report what the handler observed so the test can assert it. + _ => $"final-state:{context.Params?.RequestState ?? ""}", + }; + }, + new McpServerToolCreateOptions + { + Name = "requeststate-transition", + Description = "Tool that transitions requestState from set to null across MRTR rounds." + }), + ]); + } + + [Fact] + public async Task InputRequiredException_TransitioningRequestStateToNull_DoesNotLeakStaleState() + { + StartServer(); + + // Non-MRTR client → server falls into the legacy backcompat resolver path on InputRequiredException. + var clientOptions = new McpClientOptions + { + ProtocolVersion = "2025-06-18", + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse("\"ok\"").RootElement, + }, + }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "requeststate-transition", + cancellationToken: TestContext.Current.CancellationToken); + + // Three attempts: round 1 (no state) → round 2 (state="round1") → round 3 (state=null after fix). + // Without the fix, the third observed state would erroneously remain "round1" because the deep-clone + // of the prior request params carried it forward when InputRequiredException.RequestState was null. + Assert.Equal(3, _observedRequestStates.Count); + Assert.Null(_observedRequestStates[0]); + Assert.Equal("round1", _observedRequestStates[1]); + Assert.Null(_observedRequestStates[2]); + + var content = Assert.Single(result.Content); + var text = Assert.IsType(content).Text; + Assert.Equal("final-state:", text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs new file mode 100644 index 000000000..1836d4d13 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs @@ -0,0 +1,183 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for session-scoped MRTR resource governance - verifying that outgoing message +/// filters can track and limit MRTR round trips per session. +/// +public class MrtrSessionLimitTests : ClientServerTestBase +{ + /// + /// Tracks the number of pending MRTR flows per session. Incremented when an InputRequiredResult + /// is sent (outgoing filter), decremented when a retry with requestState arrives (incoming filter). + /// + private readonly ConcurrentDictionary _pendingFlowsPerSession = new(); + + /// + /// Records every (sessionId, pendingCount) observation from the outgoing filter, + /// so the test can verify the tracking was correct. + /// + private readonly ConcurrentBag<(string SessionId, int PendingCount)> _observations = []; + + private readonly ServerMessageTracker _messageTracker = new(); + + /// + /// Maximum allowed concurrent MRTR flows per session. If exceeded, the outgoing filter + /// replaces the InputRequiredResult with an error response. + /// + private int _maxFlowsPerSession = int.MaxValue; + + /// + /// Counts how many IncompleteResults were blocked by the per-session limit. + /// + private int _blockedFlowCount; + + public MrtrSessionLimitTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + _messageTracker.AddFilters(options.Filters.Message); + + // Outgoing filter: detect InputRequiredResult responses and track per session. + options.Filters.Message.OutgoingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse response && + response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() is "input_required") + { + var sessionId = context.Server.SessionId ?? "unknown"; + var newCount = _pendingFlowsPerSession.AddOrUpdate(sessionId, 1, (_, c) => c + 1); + _observations.Add((sessionId, newCount)); + + // Enforce per-session limit: if exceeded, replace the InputRequiredResult + // with a JSON-RPC error. This prevents the client from receiving the + // InputRequiredResult and starting another retry cycle. + if (newCount > _maxFlowsPerSession) + { + // Undo the increment since we're blocking this flow. + _pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1)); + Interlocked.Increment(ref _blockedFlowCount); + + // Replace the outgoing message with a JSON-RPC error. + context.JsonRpcMessage = new JsonRpcError + { + Id = response.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidRequest, + Message = $"Too many pending MRTR flows for this session (limit: {_maxFlowsPerSession}).", + } + }; + } + } + + await next(context, cancellationToken); + }); + + // Incoming filter: detect retries (requests with requestState) and decrement. + options.Filters.Message.IncomingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest request && + request.Params is JsonObject paramsObj && + paramsObj.TryGetPropertyValue("requestState", out var stateNode) && + stateNode is not null) + { + var sessionId = context.Server.SessionId ?? "unknown"; + _pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1)); + } + + await next(context, cancellationToken); + }); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-tool", + Description = "A tool that requests elicitation" + }), + ]); + } + + [Fact] + public async Task OutgoingFilter_TracksIncompleteResultsPerSession() + { + // Verify that an outgoing message filter can observe InputRequiredResult responses + // and track the pending MRTR flow count per session using context.Server.SessionId. + StartServer(); + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Call the tool - triggers one MRTR round-trip. + var result = await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "confirm?" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("accept", Assert.IsType(Assert.Single(result.Content)).Text); + + // Verify the filter observed exactly one InputRequiredResult and tracked it. + Assert.Single(_observations); + var (sessionId, pendingCount) = _observations.First(); + Assert.NotNull(sessionId); + Assert.Equal(1, pendingCount); + + // After the retry completed, the count should be back to 0. + Assert.Equal(0, _pendingFlowsPerSession.GetValueOrDefault(sessionId)); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() + { + // Verify that an outgoing message filter can enforce a per-session MRTR flow limit + // by replacing the InputRequiredResult with a JSON-RPC error when the limit is exceeded. + // Set the limit to 0 so the very first MRTR flow is blocked. + _maxFlowsPerSession = 0; + + StartServer(); + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // The tool call should fail because the outgoing filter blocks the InputRequiredResult. + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "confirm?" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("Too many pending MRTR flows", ex.Message); + Assert.Equal(1, _blockedFlowCount); + } +} From 4c4c5c66e2592a54a401e6ca4f7c4c704f25f6a4 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 3 Jun 2026 16:38:05 -0700 Subject: [PATCH 13/50] Bump version to 2.0.0-preview.1 (#1621) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Directory.Build.props | 5 +- .../CompatibilitySuppressions.xml | 60 ------------------- 2 files changed, 3 insertions(+), 62 deletions(-) delete mode 100644 src/ModelContextProtocol.Core/CompatibilitySuppressions.xml diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 21be76a9e..5758c4936 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,8 @@ https://csharp.sdk.modelcontextprotocol.io https://github.com/modelcontextprotocol/csharp-sdk git - 1.3.0 + 2.0.0 + preview.1 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm @@ -17,7 +18,7 @@ $(RepoRoot)\Open.snk true true - 1.0.0 + 1.3.0 diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml deleted file mode 100644 index 640351668..000000000 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - CP0005 - M:ModelContextProtocol.Client.McpClient.get_Completion - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0005 - P:ModelContextProtocol.Client.McpClient.Completion - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.get_Completion - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0005 - P:ModelContextProtocol.Client.McpClient.Completion - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.get_Completion - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0005 - P:ModelContextProtocol.Client.McpClient.Completion - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0005 - M:ModelContextProtocol.Client.McpClient.get_Completion - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0005 - P:ModelContextProtocol.Client.McpClient.Completion - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - \ No newline at end of file From 7ba084acf800ed960aa915f2b9dd0d4c5a75b56e Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 3 Jun 2026 16:38:28 -0700 Subject: [PATCH 14/50] Update CodeQL workflow to include release branches (#1624) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5e53d60b..f07284436 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ "main", "validation/**" ] + branches: [ "main", "release/**", "validation/**" ] pull_request: - branches: [ "main", "validation/**" ] + branches: [ "main", "release/**", "validation/**" ] schedule: - cron: '23 9 * * 6' From 719eade5e0817d643167a6913582dec9b96dc4a5 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:06:16 -0700 Subject: [PATCH 15/50] Fix flaky AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order test (#1627) Co-authored-by: Tarek Mahmoud Sayed --- ...cpServerBuilderExtensionsMessageFilterTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs index a39d4896f..c59c6a09e 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs @@ -100,6 +100,12 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() [Fact] public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() { + // The client sends notifications/initialized fire-and-forget, so unlike the initialize and + // tools/list request/response exchanges it has no synchronization point the test can await. + // Signal once the outermost filter finishes processing it so the strict counts below observe a + // complete, stable log instead of racing the still-in-flight notification. + var initializedNotificationProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerBuilder .WithMessageFilters(filters => { @@ -109,6 +115,11 @@ public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() logger.LogInformation("MessageFilter1 before"); await next(context, cancellationToken); logger.LogInformation("MessageFilter1 after"); + + if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification }) + { + initializedNotificationProcessed.TrySetResult(true); + } }); filters.AddIncomingFilter((next) => async (context, cancellationToken) => @@ -127,6 +138,10 @@ public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + // Wait for the fire-and-forget initialized notification to flow through the filter pipeline + // before snapshotting the log; otherwise the strict counts below can race the notification. + await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var logMessages = MockLoggerProvider.LogMessages .Where(m => m.Category.StartsWith("MessageFilter")) .Select(m => m.Message) From ed19286a08f341cd8b0b8ab4f2e852bd27e62d67 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:45:08 -0700 Subject: [PATCH 16/50] Align x-mcp-header implementation with SEP-2243 spec clarifications (#1619) Co-authored-by: Tarek Mahmoud Sayed --- .../StreamableHttpHandler.cs | 213 ++++++++++--- .../Client/McpClient.Methods.cs | 6 +- .../Client/McpHeaderExtractor.cs | 298 ++++++++++++++++-- .../Protocol/McpHeaderEncoder.cs | 48 +-- .../Server/AIFunctionMcpServerTool.cs | 16 +- .../Server/McpHeaderAttribute.cs | 27 +- .../AddKnownToolsHeaderTests.cs | 72 +++++ .../HttpHeaderConformanceTests.cs | 171 +++++++++- .../Client/McpHeaderEncoderTests.cs | 48 ++- .../McpHeaderExtractorValidationTests.cs | 218 +++++++++++++ .../Server/McpHeaderAttributeTests.cs | 20 ++ .../Server/McpServerToolTests.cs | 14 + 12 files changed, 1035 insertions(+), 116 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index a489cd9e6..ad4930e80 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -716,8 +716,41 @@ private static bool ValidateCustomParamHeaders( // Check that every x-mcp-header annotated parameter has a corresponding header, // that the header value is validly encoded, and that it matches the body value. + return ValidateCustomParamHeadersFromProperties(context, properties, arguments, out errorMessage); + } + + /// + /// Recursively validates x-mcp-header annotated properties at any nesting depth. + /// + private static bool ValidateCustomParamHeadersFromProperties( + HttpContext context, + System.Text.Json.JsonElement properties, + System.Text.Json.Nodes.JsonNode? arguments, + [NotNullWhen(false)] out string? errorMessage) + { foreach (var property in properties.EnumerateObject()) { + if (property.Value.ValueKind != System.Text.Json.JsonValueKind.Object) + { + continue; + } + + // Recurse into nested object properties + if (property.Value.TryGetProperty("properties", out var nestedProperties) && + nestedProperties.ValueKind == System.Text.Json.JsonValueKind.Object) + { + System.Text.Json.Nodes.JsonNode? nestedArgs = null; + if (arguments is System.Text.Json.Nodes.JsonObject parentObj) + { + parentObj.TryGetPropertyValue(property.Name, out nestedArgs); + } + + if (!ValidateCustomParamHeadersFromProperties(context, nestedProperties, nestedArgs, out errorMessage)) + { + return false; + } + } + if (!property.Value.TryGetProperty("x-mcp-header", out var headerNameElement)) { continue; @@ -776,10 +809,14 @@ argForMissing is not null && if (expectedHeaderValue is not null) { var decodedExpected = McpHeaderEncoder.DecodeValue(expectedHeaderValue); - if (!ValuesMatch(decodedActual, decodedExpected, property.Value)) + switch (ValuesMatch(decodedActual, decodedExpected, property.Value)) { - errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'."; - return false; + case HeaderValueComparison.IntegerOutOfRange: + errorMessage = $"Header mismatch: {fullHeaderName} integer value for parameter '{property.Name}' is outside the JavaScript safe integer range (-{MaxSafeInteger} to {MaxSafeInteger})."; + return false; + case HeaderValueComparison.Mismatch: + errorMessage = $"Header mismatch: {fullHeaderName} header value does not match body argument '{property.Name}'."; + return false; } } } @@ -812,50 +849,152 @@ private static bool IsValidHeaderValue(string value) => value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0; /// - /// Compares two decoded header values, using numeric comparison for number-typed - /// parameters to handle cross-SDK representation differences (e.g., "42" vs "42.0"). + /// The maximum magnitude for an integer that can be represented exactly by an IEEE 754 + /// double-precision value (2^53 - 1). Per SEP-2243 integer x-mcp-header values MUST be within + /// the JavaScript safe integer range (-2^53+1 to 2^53-1). /// - private static bool ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema) + private const long MaxSafeInteger = 9007199254740991L; + + private enum HeaderValueComparison { - if (string.Equals(actual, expected, StringComparison.Ordinal)) - { - return true; - } + Match, + Mismatch, + IntegerOutOfRange, + } - // JSON Schema defines two numeric types: "number" (any numeric value including - // decimals like 3.14) and "integer" (whole numbers only like 42). Both produce - // JsonValueKind.Number in the JSON body and are sent as numeric strings in headers. - // We check for both because different SDKs may serialize them differently - - // e.g., a client might send header "42.0" for an "integer" body value of 42, - // or header "42" for a "number" body value of 42.0. Without handling both types, - // valid cross-SDK requests would be incorrectly rejected. - if (propertySchema.TryGetProperty("type", out var typeElement) && - typeElement.ValueKind == System.Text.Json.JsonValueKind.String && - actual is not null && expected is not null) - { - var schemaType = typeElement.GetString(); - - // For "integer" type, prefer exact long comparison to preserve full precision - // for values beyond double's ~15-17 significant digit limit. - if (schemaType == "integer" && - long.TryParse(actual, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var actualLong) && - long.TryParse(expected, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var expectedLong)) + /// + /// Compares two decoded header values. For integer-typed parameters the values are + /// compared numerically (so cross-SDK forms such as "42" and "42.0" are treated + /// as equal) and validated against the JavaScript safe integer range per SEP-2243. + /// + private static HeaderValueComparison ValuesMatch(string? actual, string? expected, System.Text.Json.JsonElement propertySchema) + { + // Per SEP-2243, x-mcp-header may only be applied to integer, string, or boolean parameters. + // For "integer" the spec recommends numeric comparison so that representations like "42" and + // "42.0" are considered equal, while still requiring values to stay within the safe range. + // This must run before the ordinal comparison below so that an invalid integer value is + // rejected even when the header and body strings are byte-for-byte identical. + if (actual is not null && expected is not null && SchemaTypeIsInteger(propertySchema)) + { + var actualResult = ParseSafeInteger(actual, out long actualValue); + var expectedResult = ParseSafeInteger(expected, out long expectedValue); + + // A numeric value outside the safe integer range is always rejected. + if (actualResult == SafeIntegerParse.OutOfRange || expectedResult == SafeIntegerParse.OutOfRange) { - return actualLong == expectedLong; + return HeaderValueComparison.IntegerOutOfRange; } - // For "number" type, or "integer" values in decimal format (e.g., cross-SDK "42.0" vs "42"), - // use double comparison with tolerance. - if (schemaType is "number" or "integer" && - double.TryParse(actual, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var actualNum) && - double.TryParse(expected, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var expectedNum) && - Math.Abs(actualNum - expectedNum) < 1e-9) + if (actualResult == SafeIntegerParse.SafeInteger && expectedResult == SafeIntegerParse.SafeInteger) + { + return actualValue == expectedValue ? HeaderValueComparison.Match : HeaderValueComparison.Mismatch; + } + + // A numeric-but-non-integer value (e.g. "42.5") for an integer-typed parameter is invalid + // and must not be allowed to slip through the ordinal comparison just because the header + // and body strings happen to be identical. + if (actualResult == SafeIntegerParse.NonInteger || expectedResult == SafeIntegerParse.NonInteger) + { + return HeaderValueComparison.Mismatch; + } + + // Otherwise at least one side is not numeric at all (NotNumeric); fall through to the + // ordinal comparison below. + } + + return string.Equals(actual, expected, StringComparison.Ordinal) + ? HeaderValueComparison.Match + : HeaderValueComparison.Mismatch; + } + + /// + /// Determines whether the property schema's type keyword declares an integer type, + /// either directly or as a member of a JSON Schema union array (e.g. ["integer", "null"]). + /// + private static bool SchemaTypeIsInteger(System.Text.Json.JsonElement propertySchema) + { + if (!propertySchema.TryGetProperty("type", out var typeElement)) + { + return false; + } + + switch (typeElement.ValueKind) + { + case System.Text.Json.JsonValueKind.String: + return typeElement.ValueEquals("integer"); + + case System.Text.Json.JsonValueKind.Array: + foreach (var entry in typeElement.EnumerateArray()) + { + if (entry.ValueKind == System.Text.Json.JsonValueKind.String && entry.ValueEquals("integer")) + { + return true; + } + } + + return false; + + default: + return false; + } + } + + /// + /// Classifies how a header/body string parses as a SEP-2243 integer value. + /// + private enum SafeIntegerParse + { + /// A whole number within the JavaScript safe integer range. + SafeInteger, + + /// A numeric value whose magnitude is outside the safe integer range. + OutOfRange, + + /// A numeric value that is not a whole number (e.g. "42.5"). + NonInteger, + + /// The value is not a numeric literal at all. + NotNumeric, + } + + /// + /// Parses a header/body value as a whole integer within the JavaScript safe integer range. + /// Decimal and exponent forms whose fractional part is zero (e.g. "42.0", "4.2e1") + /// are accepted. + /// inspects the actual digits (so it rejects non-integers such as "42.5" without rounding) + /// and fails fast on overflow (so a huge literal such as "1e1000000" cannot allocate a + /// large number). + /// + private static SafeIntegerParse ParseSafeInteger(string text, out long value) + { + value = 0; + + const System.Globalization.NumberStyles Styles = + System.Globalization.NumberStyles.AllowLeadingSign | + System.Globalization.NumberStyles.AllowDecimalPoint | + System.Globalization.NumberStyles.AllowExponent; + + if (long.TryParse(text, Styles, System.Globalization.CultureInfo.InvariantCulture, out long parsed)) + { + if (parsed < -MaxSafeInteger || parsed > MaxSafeInteger) { - return true; + return SafeIntegerParse.OutOfRange; } + + value = parsed; + return SafeIntegerParse.SafeInteger; + } + + // The value is not representable as a 64-bit integer. Use double only as an order-of-magnitude + // gate to distinguish a numeric literal beyond the safe range (e.g. "1e100") from a numeric but + // non-integer value (e.g. "42.5"). double's loss of precision is irrelevant for this magnitude + // comparison because every in-range value was already handled exactly by long.TryParse above. + if (double.TryParse(text, Styles, System.Globalization.CultureInfo.InvariantCulture, out double d)) + { + return System.Math.Abs(d) > MaxSafeInteger ? SafeIntegerParse.OutOfRange : SafeIntegerParse.NonInteger; } - return false; + return SafeIntegerParse.NotNumeric; } private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index f04c32ffd..79d4b0a02 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -186,8 +186,10 @@ public async ValueTask> ListToolsAsync( tools ??= new(toolResults.Tools.Count); foreach (var tool in toolResults.Tools) { - // Validate x-mcp-header annotations per SEP-2243. - // Clients MUST exclude tools with invalid annotations and SHOULD log a warning. + // Validate x-mcp-header annotations per SEP-2243. The spec requires Streamable HTTP + // clients to exclude tools with invalid annotations and permits other transports + // (e.g., stdio) to ignore the annotations entirely. This client validates on all + // transports so a malformed definition is rejected consistently regardless of transport. if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason)) { ToolRejected?.Invoke(tool, rejectionReason!); diff --git a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs index 349168f04..99fe5462a 100644 --- a/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs +++ b/src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs @@ -1,5 +1,9 @@ using System.Net.Http.Headers; +using System.Globalization; using System.Text.Json; +#if NET +using System.Buffers; +#endif using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -36,10 +40,35 @@ public static void AddParameterHeaders( return; } + AddParameterHeadersFromProperties(headers, properties, arguments.Value); + } + + /// + /// Recursively extracts parameter values from properties at any nesting depth + /// and adds them as HTTP headers. + /// + private static void AddParameterHeadersFromProperties( + HttpRequestHeaders headers, + JsonElement properties, + JsonElement arguments) + { foreach (var property in properties.EnumerateObject()) { - if (property.Value.ValueKind != JsonValueKind.Object || - !property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement)) + if (property.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + // Recurse into nested object properties + if (property.Value.TryGetProperty("properties", out var nestedProperties) && + nestedProperties.ValueKind == JsonValueKind.Object && + arguments.TryGetProperty(property.Name, out var nestedArgs) && + nestedArgs.ValueKind == JsonValueKind.Object) + { + AddParameterHeadersFromProperties(headers, nestedProperties, nestedArgs); + } + + if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement)) { continue; } @@ -51,7 +80,7 @@ public static void AddParameterHeaders( } // Look for the corresponding argument value - if (!arguments.Value.TryGetProperty(property.Name, out var argValue)) + if (!arguments.TryGetProperty(property.Name, out var argValue)) { continue; } @@ -62,7 +91,7 @@ public static void AddParameterHeaders( continue; } - var headerValue = McpHeaderEncoder.ConvertToHeaderValue(argValue); + var headerValue = ConvertArgumentToHeaderValue(property.Value, property.Name, argValue); if (headerValue is not null) { headers.Add($"{McpHttpHeaders.ParamPrefix}{headerName}", headerValue); @@ -70,6 +99,92 @@ public static void AddParameterHeaders( } } + // The maximum magnitude for an integer that can be represented exactly by an IEEE 754 + // double-precision value (2^53 - 1). Per SEP-2243 integer x-mcp-header values MUST be within + // the JavaScript safe integer range (-2^53+1 to 2^53-1) so intermediaries can compare them. + private const long MaxSafeInteger = 9007199254740991L; + + /// + /// Converts an argument value to its encoded header representation. When the property schema + /// declares an integer type, the value is canonicalized to its decimal string form + /// (e.g. a body value of 42.0 is emitted as "42") per SEP-2243. + /// + private static string? ConvertArgumentToHeaderValue(JsonElement propertySchema, string propertyName, JsonElement argValue) + { + if (argValue.ValueKind == JsonValueKind.Number && SchemaTypeIsInteger(propertySchema)) + { + if (!TryGetCanonicalSafeInteger(argValue, out long canonical)) + { + throw new McpException( + $"The value '{argValue.GetRawText()}' for parameter '{propertyName}' annotated with x-mcp-header " + + $"is not a whole number within the JavaScript safe integer range (-{MaxSafeInteger} to {MaxSafeInteger})."); + } + + return McpHeaderEncoder.EncodeValue(canonical); + } + + return McpHeaderEncoder.ConvertToHeaderValue(argValue); + } + + /// + /// Determines whether the property schema's type keyword declares an integer type, + /// either directly or as a member of a JSON Schema union array (e.g. ["integer", "null"]). + /// + private static bool SchemaTypeIsInteger(JsonElement propertySchema) + { + if (!propertySchema.TryGetProperty("type", out var typeElement)) + { + return false; + } + + switch (typeElement.ValueKind) + { + case JsonValueKind.String: + return typeElement.ValueEquals("integer"); + + case JsonValueKind.Array: + foreach (var entry in typeElement.EnumerateArray()) + { + if (entry.ValueKind == JsonValueKind.String && entry.ValueEquals("integer")) + { + return true; + } + } + + return false; + + default: + return false; + } + } + + /// + /// Attempts to interpret a JSON number as a whole integer within the JavaScript safe integer + /// range. Decimal and exponent forms whose fractional part is zero (e.g. 42.0, 4.2e1) + /// are accepted; non-integers and out-of-range values are rejected. + /// + private static bool TryGetCanonicalSafeInteger(JsonElement element, out long value) + { + if (element.TryGetInt64(out value)) + { + return value >= -MaxSafeInteger && value <= MaxSafeInteger; + } + + // Handle decimal/exponent representations of whole numbers such as "42.0" or "4.2e1". + // long.TryParse inspects the actual digits (so non-integers such as "42.5" are rejected + // without rounding) and fails fast on overflow (no large-number allocation). + const NumberStyles Styles = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent; + if (long.TryParse(element.GetRawText(), Styles, CultureInfo.InvariantCulture, out long parsed) && + parsed >= -MaxSafeInteger && parsed <= MaxSafeInteger) + { + value = parsed; + return true; + } + + value = 0; + return false; + } + /// /// Validates a tool's inputSchema for valid x-mcp-header annotations. /// Returns if the tool is valid; with a reason if it should be rejected. @@ -86,12 +201,35 @@ internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason) } var headerNames = new HashSet(StringComparer.OrdinalIgnoreCase); + return ValidateProperties(tool, properties, headerNames, out rejectionReason); + } + + /// + /// Recursively validates properties at any nesting depth for valid x-mcp-header annotations. + /// + private static bool ValidateProperties(Tool tool, JsonElement properties, HashSet headerNames, out string? rejectionReason) + { + rejectionReason = null; foreach (var property in properties.EnumerateObject()) { // Skip properties whose schema is not an object (e.g., boolean `true`/`false` schemas) - if (property.Value.ValueKind != JsonValueKind.Object || - !property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement)) + if (property.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + // Recurse into nested object properties + if (property.Value.TryGetProperty("properties", out var nestedProperties) && + nestedProperties.ValueKind == JsonValueKind.Object) + { + if (!ValidateProperties(tool, nestedProperties, headerNames, out rejectionReason)) + { + return false; + } + } + + if (!property.Value.TryGetProperty(XMcpHeaderProperty, out var headerNameElement)) { continue; } @@ -112,36 +250,154 @@ internal static bool ValidateToolSchema(Tool tool, out string? rejectionReason) return false; } - // MUST contain only ASCII characters (0x21-0x7E) excluding space and colon - foreach (char c in headerName!) + // MUST match HTTP field-name token syntax (1*tchar, RFC 9110 Section 5.1) + // MUST NOT contain control characters including CR and LF + int invalidIdx = FindFirstNonTchar(headerName!); + if (invalidIdx >= 0) { - if (c < 0x21 || c > 0x7E || c == ':') - { - rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2})."; - return false; - } + char c = headerName![invalidIdx]; + rejectionReason = $"Tool '{tool.Name}': x-mcp-header '{headerName}' contains invalid character '{c}' (0x{(int)c:X2})."; + return false; } // MUST be case-insensitively unique - if (!headerNames.Add(headerName)) + if (!headerNames.Add(headerName!)) { rejectionReason = $"Tool '{tool.Name}': duplicate x-mcp-header name '{headerName}' (case-insensitive)."; return false; } - // MUST only be applied to primitive types (string, number, boolean) + // MUST only be applied to parameters with primitive types (string, integer, boolean). + // Parameters with type "number" (or any other non-primitive type) are not permitted. + // The "type" keyword may be omitted (treated as unknown, not rejected, since many valid + // schemas constrain the value via enum/const/$ref instead) or expressed as a JSON Schema + // union array such as ["string", "null"]; only an explicitly disallowed or malformed type + // causes rejection. if (property.Value.TryGetProperty("type", out var typeElement) && - typeElement.ValueKind == JsonValueKind.String) + !IsAllowedHeaderType(typeElement)) { - var typeName = typeElement.GetString(); - if (typeName is not ("string" or "number" or "integer" or "boolean")) + rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has unsupported type '{typeElement}'. Only 'string', 'integer', and 'boolean' are allowed."; + return false; + } + } + + return true; + } + + /// + /// Determines whether a JSON Schema type keyword is compatible with x-mcp-header, + /// which per SEP-2243 may only be applied to string, integer, or boolean + /// parameters. A union array (e.g., ["string", "null"]) is allowed as long as it contains + /// at least one allowed primitive; "null" is tolerated only as an additional union member. + /// Any other shape (a disallowed type name, a non-string array element, an empty array, or a + /// non-string/non-array value) is treated as incompatible. + /// + private static bool IsAllowedHeaderType(JsonElement typeElement) + { + switch (typeElement.ValueKind) + { + case JsonValueKind.String: + return IsAllowedPrimitiveTypeName(typeElement.GetString()); + + case JsonValueKind.Array: + bool hasAllowedPrimitive = false; + foreach (var entry in typeElement.EnumerateArray()) { - rejectionReason = $"Tool '{tool.Name}': x-mcp-header on property '{property.Name}' has non-primitive type '{typeName}'."; - return false; + if (entry.ValueKind != JsonValueKind.String) + { + return false; + } + + var entryName = entry.GetString(); + if (entryName == "null") + { + continue; + } + + if (!IsAllowedPrimitiveTypeName(entryName)) + { + return false; + } + + hasAllowedPrimitive = true; } + + return hasAllowedPrimitive; + + default: + // A "type" that is present but is neither a string nor an array of strings is malformed. + return false; + } + } + + private static bool IsAllowedPrimitiveTypeName(string? typeName) => + typeName is "string" or "integer" or "boolean"; + + // Valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2: + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + private const string TcharChars = "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +#if NET + private static readonly SearchValues s_tcharValues = SearchValues.Create(TcharChars); + + internal static int FindFirstNonTchar(string value) => + value.AsSpan().IndexOfAnyExcept(s_tcharValues); +#else + // Bitmap for O(1) tchar lookup. All valid chars are in 0x21-0x7E range, + // so two ulongs (128 bits) cover the entire ASCII range. + // _tcharBitmapLo covers chars 0-63, _tcharBitmapHi covers chars 64-127. + private static readonly ulong s_tcharBitmapLo = ComputeBitmapLo(); + private static readonly ulong s_tcharBitmapHi = ComputeBitmapHi(); + + private static ulong ComputeBitmapLo() + { + ulong bitmap = 0; + foreach (char c in TcharChars) + { + if (c < 64) + { + bitmap |= 1UL << c; } } + return bitmap; + } - return true; + private static ulong ComputeBitmapHi() + { + ulong bitmap = 0; + foreach (char c in TcharChars) + { + if (c >= 64) + { + bitmap |= 1UL << (c - 64); + } + } + return bitmap; + } + + private static bool IsTchar(char c) + { + if (c >= 128) + { + return false; + } + + return c < 64 + ? (s_tcharBitmapLo & (1UL << c)) != 0 + : (s_tcharBitmapHi & (1UL << (c - 64))) != 0; + } + + internal static int FindFirstNonTchar(string value) + { + for (int i = 0; i < value.Length; i++) + { + if (!IsTchar(value[i])) + { + return i; + } + } + return -1; } +#endif } diff --git a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs index c31e93388..9366063e4 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs +++ b/src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs @@ -14,12 +14,18 @@ namespace ModelContextProtocol.Protocol; /// including Base64 encoding for values that cannot be safely transmitted as plain text. /// /// +/// Per SEP-2243 only primitive parameter types are supported: string, integer, and +/// boolean. The JSON Schema number type is not permitted, and integer values must be +/// within the JavaScript safe integer range (−2^53+1 to 2^53−1). +/// +/// /// Encoding rules: /// /// Plain ASCII values (0x20-0x7E): sent as-is /// Values with leading/trailing whitespace: Base64 encoded with =?base64?{value}?= wrapper /// Non-ASCII characters: Base64 encoded /// Control characters: Base64 encoded +/// Plain ASCII values that themselves match the =?base64?...?= sentinel pattern: Base64 encoded to avoid ambiguity /// /// /// @@ -28,6 +34,10 @@ public static class McpHeaderEncoder private const string Base64Prefix = "=?base64?"; private const string Base64Suffix = "?="; + // Strict UTF-8 decoder that throws on invalid byte sequences rather than silently substituting + // U+FFFD replacement characters, so a malformed Base64-wrapped header value is rejected. + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + /// /// Encodes a string parameter value for use in an HTTP header. /// @@ -58,26 +68,19 @@ public static class McpHeaderEncoder public static string EncodeValue(bool value) => value ? "true" : "false"; /// - /// Encodes a numeric parameter value for use in an HTTP header. + /// Encodes an integer parameter value for use in an HTTP header. /// - /// The numeric value to encode. + /// The integer value to encode. /// The decimal string representation of the value. public static string EncodeValue(long value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); - /// - /// Encodes a numeric parameter value for use in an HTTP header. - /// - /// The numeric value to encode. - /// The decimal string representation of the value. - public static string EncodeValue(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); - /// /// Encodes a parameter value for use in an HTTP header. /// - /// The value to encode. Supported types are string, numeric types, and boolean. + /// The value to encode. Supported types are string, integer, and boolean. /// /// The encoded header value, or if the value is - /// or is not a supported type (string, numeric, or boolean). + /// or is not a supported type (string, integer, or boolean). /// public static string? EncodeValue(object? value) { @@ -121,9 +124,9 @@ public static class McpHeaderEncoder return headerValue; } - // Check for Base64 wrapper. The spec defines the prefix as lowercase "=?base64?" - // but we match case-insensitively for robustness against non-conforming senders. - if (headerValue.StartsWith(Base64Prefix, StringComparison.OrdinalIgnoreCase) && + // 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) && headerValue.EndsWith(Base64Suffix, StringComparison.Ordinal)) { var base64Content = headerValue.Substring( @@ -133,12 +136,16 @@ public static class McpHeaderEncoder try { var bytes = Convert.FromBase64String(base64Content); - return Encoding.UTF8.GetString(bytes); + return s_strictUtf8.GetString(bytes); } catch (FormatException) { return null; } + catch (DecoderFallbackException) + { + return null; + } } return headerValue; @@ -201,9 +208,6 @@ public static class McpHeaderEncoder uint n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), long n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), ulong n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), - float n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), - double n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), - decimal n => n.ToString(System.Globalization.CultureInfo.InvariantCulture), _ => null }; } @@ -221,6 +225,14 @@ private static bool RequiresBase64Encoding(string value) return true; } + // Avoid sentinel collision: if the value matches the base64 wrapper pattern, + // it must be encoded to prevent ambiguity during decoding. + if (value.StartsWith(Base64Prefix, StringComparison.Ordinal) && + value.EndsWith(Base64Suffix, StringComparison.Ordinal)) + { + return true; + } + foreach (char c in value) { // Valid HTTP header field value characters per SEP: visible ASCII (0x21-0x7E) and space (0x20). diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 961344c2c..2b39beefe 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -630,8 +630,8 @@ private static JsonElement AddMcpHeaderExtensions(JsonElement inputSchema, Metho if (!IsPrimitiveHeaderType(paramType)) { throw new InvalidOperationException( - $"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a primitive type. " + - "Only string, numeric, and boolean types may be annotated with [McpHeader]."); + $"Parameter '{param.Name}' on method '{method.Name}' has [McpHeader] but is not a supported type. " + + "Only string, integer, and boolean types may be annotated with [McpHeader]."); } // Validate case-insensitive uniqueness @@ -673,6 +673,12 @@ private static JsonElement AddMcpHeaderExtensions(JsonElement inputSchema, Metho private static bool IsPrimitiveHeaderType(Type type) { + // Per SEP-2243, x-mcp-header may only be applied to integer, string, or boolean parameters, + // and integer values must stay within the JavaScript safe integer range (-2^53+1 to 2^53-1). + // ulong is excluded because its upper range (above long.MaxValue) cannot be represented as a + // signed integer and the bulk of its domain falls outside the safe range. Remaining integer + // types are allowed here; long values are additionally range-checked per value when emitted + // (client) and validated (server). return type == typeof(string) || type == typeof(bool) || type == typeof(byte) || @@ -681,10 +687,6 @@ private static bool IsPrimitiveHeaderType(Type type) type == typeof(ushort) || type == typeof(int) || type == typeof(uint) || - type == typeof(long) || - type == typeof(ulong) || - type == typeof(float) || - type == typeof(double) || - type == typeof(decimal); + type == typeof(long); } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs b/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs index 516b73580..d81523a56 100644 --- a/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs @@ -1,3 +1,5 @@ +using ModelContextProtocol.Client; + namespace ModelContextProtocol.Server; /// @@ -10,8 +12,8 @@ namespace ModelContextProtocol.Server; /// HTTP header named Mcp-Param-{Name}. /// /// -/// Only parameters with primitive types (string, number, boolean) may use this attribute. -/// The header name must contain only ASCII characters (0x21-0x7E, excluding space and colon) +/// Only parameters with primitive types (integer, string, boolean) may use this attribute. +/// The header name must match HTTP field-name token syntax (tchar per RFC 9110 Section 5.6.2) /// and must be case-insensitively unique within the tool's input schema. /// /// @@ -38,7 +40,7 @@ public sealed class McpHeaderAttribute : Attribute /// /// /// The name portion of the header. The full header name will be Mcp-Param-{name}. - /// Must contain only ASCII characters (0x21-0x7E, excluding space and colon). + /// Must match HTTP field-name token syntax (tchar per RFC 9110 Section 5.6.2). /// /// /// The name is null, empty, or contains invalid characters. @@ -59,23 +61,20 @@ public McpHeaderAttribute(string name) public string Name { get; } /// - /// Validates that a header name contains only valid characters. + /// Validates that a header name contains only valid HTTP token characters (tchar) per RFC 9110 Section 5.6.2. /// /// The header name to validate. /// The name contains invalid characters. internal static void ValidateHeaderName(string name) { - foreach (char c in name) + int idx = McpHeaderExtractor.FindFirstNonTchar(name); + if (idx >= 0) { - // Valid token characters per RFC 9110: visible ASCII (0x21-0x7E) excluding delimiters. - // Space (0x20) and colon (':') are explicitly prohibited. - if (c < 0x21 || c > 0x7E || c == ':') - { - throw new ArgumentException( - $"Header name contains invalid character '{c}' (0x{(int)c:X2}). " + - "Only ASCII characters (0x21-0x7E) excluding colon are allowed.", - nameof(name)); - } + char c = name[idx]; + throw new ArgumentException( + $"Header name contains invalid character '{c}' (0x{(int)c:X2}). " + + "Only HTTP token characters (tchar per RFC 9110 Section 5.6.2) are allowed.", + nameof(name)); } } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs index 7ab98f38e..852fb122e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -169,6 +169,78 @@ public async Task AddKnownTools_ThenCallTool_SendsMcpParamHeaders_WithoutListToo Assert.Equal("42", headers["Mcp-Param-Priority"]); } + [Theory] + [InlineData("42.0", "42")] // decimal body form canonicalized + [InlineData("-7.00", "-7")] // trailing zeros canonicalized + [InlineData("-0.0", "0")] // negative zero canonicalized + [InlineData("4.2e1", "42")] // exponent body form canonicalized + [InlineData("9007199254740991", "9007199254740991")] // max safe integer preserved exactly + [InlineData("-9007199254740991", "-9007199254740991")] // min safe integer preserved exactly + public async Task CallTool_EmitsCanonicalIntegerHeader(string bodyValue, string expectedHeader) + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + client.AddKnownTools([CreateToolWithHeaders()]); + + // Pass the raw JSON number so the body retains the exact form under test. + var result = await client.CallToolAsync( + "my_tool", + new Dictionary + { + ["region"] = "us-west-2", + ["priority"] = JsonDocument.Parse(bodyValue).RootElement, + }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var headers = _capturedHeaders.Values.First(); + Assert.Equal(expectedHeader, headers["Mcp-Param-Priority"]); + } + + [Theory] + [InlineData("9007199254740993")] // 2^53 + 1, above the safe range + [InlineData("-9007199254740993")] // -(2^53 + 1), below the safe range + [InlineData("42.5")] // not a whole number + [InlineData("12e-1")] // 1.2 in exponent form, not a whole number + [InlineData("42.0000000000000000000000000001")] // high-precision fraction (decimal would round this to 42) + public async Task CallTool_ThrowsForInvalidIntegerHeaderValue(string bodyValue) + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + client.AddKnownTools([CreateToolWithHeaders()]); + + // Values outside the JavaScript safe integer range (or non-integral) must be rejected + // before the request is sent. + await Assert.ThrowsAsync(async () => await client.CallToolAsync( + "my_tool", + new Dictionary + { + ["region"] = "us-west-2", + ["priority"] = JsonDocument.Parse(bodyValue).RootElement, + }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Empty(_capturedHeaders); + } + [Fact] public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index c3232b56a..b950553f5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -54,7 +54,7 @@ public async ValueTask DisposeAsync() // Create a tool with x-mcp-header annotations in the schema. // We set InputSchema directly because TransformSchemaNode doesn't provide // property-level path context for lambda-based tool creation. - private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool()]; + private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool(), CreateUnionHeaderTestTool()]; private static readonly JsonSerializerOptions s_reflectionOptions = new() { @@ -65,7 +65,7 @@ private static McpServerTool CreateHeaderTestTool() { var tool = McpServerTool.Create( [McpServerTool(Name = "header_test")] - static (string region, int priority, bool verbose, string emptyVal) => + static (string region, long priority, bool verbose, string emptyVal) => $"region={region},priority={priority},verbose={verbose},empty={emptyVal}", new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions }); @@ -86,8 +86,93 @@ private static McpServerTool CreateHeaderTestTool() return tool; } + // A tool whose integer header parameter uses a JSON Schema union type (["integer", "null"]). + private static McpServerTool CreateUnionHeaderTestTool() + { + var tool = McpServerTool.Create( + [McpServerTool(Name = "union_test")] + static (long priority) => $"priority={priority}", + new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions }); + + using var doc = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "priority": { "type": ["integer", "null"], "x-mcp-header": "Priority" } + }, + "required": ["priority"] + } + """); + tool.ProtocolTool.InputSchema = doc.RootElement.Clone(); + + return tool; + } + #region Server-side validation tests + [Fact] + public async Task Server_AcceptsUnionIntegerCanonicalForm() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body + // carries the decimal form 42.0. The server must treat the union type as integer and match. + var callJson = CallTool("union_test", """{"priority":42.0}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "union_test"); + request.Headers.Add("Mcp-Param-Priority", "42"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Server_RejectsUnionIntegerOutsideSafeRange() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + var callJson = CallTool("union_test", """{"priority":9007199254740993}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "union_test"); + request.Headers.Add("Mcp-Param-Priority", "9007199254740993"); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100". + var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "test"); + request.Headers.Add("Mcp-Param-Priority", "100"); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() { @@ -209,15 +294,15 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() } [Fact] - public async Task Server_AcceptsLargeIntegerWithFullPrecision() + public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() { await StartAsync(); await InitializeWithDraftVersionAsync(); - // Use a large integer that would lose precision if converted through double - // 2^53 + 1 = 9007199254740993 (cannot be represented exactly as double) - const long largeInt = 9007199254740993L; - var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{largeInt}},"verbose":false,"emptyVal":""}"""); + // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without + // losing precision through a double conversion. + const long maxSafeInt = 9007199254740991L; + var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{maxSafeInt}},"verbose":false,"emptyVal":""}"""); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); @@ -225,7 +310,7 @@ public async Task Server_AcceptsLargeIntegerWithFullPrecision() request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); - request.Headers.Add("Mcp-Param-Priority", largeInt.ToString()); + request.Headers.Add("Mcp-Param-Priority", maxSafeInt.ToString()); request.Headers.Add("Mcp-Param-Verbose", "false"); request.Headers.Add("Mcp-Param-EmptyVal", ""); @@ -234,15 +319,47 @@ public async Task Server_AcceptsLargeIntegerWithFullPrecision() } [Theory] - [InlineData("42", 42)] // "42" header vs 42 body → exact integer match - [InlineData("42.0", 42)] // "42.0" header vs 42 body → numeric equivalence - [InlineData("42", 42.0)] // "42" header vs 42.0 body → numeric equivalence - public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, double bodyValue) + [InlineData("9007199254740993")] // 2^53 + 1, just outside the safe range + [InlineData("-9007199254740993")] // -(2^53 + 1), just outside the safe range + [InlineData("100000000000000000000000000000000000000")] // far beyond decimal range + [InlineData("1e100")] // exponent form far beyond the safe range + public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // Per SEP-2243 integer values MUST be within the JavaScript safe integer range. + // A matching header and body that are both outside the range must still be rejected. + var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{outOfRangeValue}},"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "test"); + request.Headers.Add("Mcp-Param-Priority", outOfRangeValue); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData("42", "42")] // "42" header vs 42 body -> exact integer match + [InlineData("42.0", "42")] // "42.0" header vs 42 body -> numeric equivalence + [InlineData("42", "42.0")] // "42" header vs 42.0 body (decimal form from another SDK) -> numeric equivalence + [InlineData("42", "4.2e1")] // "42" header vs 4.2e1 body (exponent form) -> numeric equivalence + [InlineData("420e-1", "42")] // "420e-1" header vs 42 body -> numeric equivalence + public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue) { await StartAsync(); await InitializeWithDraftVersionAsync(); - var callJson = CallTool("header_test", $$$"""{"region":"test","priority":{{{bodyValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"verbose":false,"emptyVal":""}"""); + // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and + // "4.2e1" are preserved in the body exactly as another SDK might serialize them. + var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{bodyValue}},"verbose":false,"emptyVal":""}"""); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); @@ -258,6 +375,34 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + [Theory] + [InlineData("42.5")] // fractional value for an integer parameter + [InlineData("12e-1")] // 1.2 in exponent form + [InlineData("42.0000000000000000000000000001")] // high-precision fraction that decimal would round to 42 + public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue) + { + await StartAsync(); + await InitializeWithDraftVersionAsync(); + + // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected + // even when the header and body strings are byte-for-byte identical (it must not slip through + // the ordinal comparison). + var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{nonIntegerValue}},"verbose":false,"emptyVal":""}"""); + + using var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); + request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "header_test"); + request.Headers.Add("Mcp-Param-Region", "test"); + request.Headers.Add("Mcp-Param-Priority", nonIntegerValue); + request.Headers.Add("Mcp-Param-Verbose", "false"); + request.Headers.Add("Mcp-Param-EmptyVal", ""); + + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() { diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs index 51db214ea..26de71b45 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs @@ -59,10 +59,9 @@ public void EncodeValue_Boolean_ConvertsToLowercase(bool input, string expected) [Theory] [InlineData(42, "42")] - [InlineData(3.14, "3.14")] [InlineData(0, "0")] [InlineData(-1, "-1")] - public void EncodeValue_Number_ConvertsToString(object input, string expected) + public void EncodeValue_Integer_ConvertsToString(object input, string expected) { var result = McpHeaderEncoder.EncodeValue(input); Assert.Equal(expected, result); @@ -106,10 +105,12 @@ public void DecodeValue_ValidBase64_Decodes() } [Fact] - public void DecodeValue_CaseInsensitivePrefix_Decodes() + public void DecodeValue_CaseSensitivePrefix_ReturnsLiteralValue() { + // Per SEP-2243: sentinel markers are case-sensitive and MUST appear exactly as shown (lowercase). + // An uppercase prefix should NOT be decoded as base64. var result = McpHeaderEncoder.DecodeValue("=?BASE64?SGVsbG8=?="); - Assert.Equal("Hello", result); + Assert.Equal("=?BASE64?SGVsbG8=?=", result); } [Fact] @@ -119,6 +120,15 @@ public void DecodeValue_InvalidBase64_ReturnsNull() Assert.Null(result); } + [Fact] + public void DecodeValue_ValidBase64ButInvalidUtf8_ReturnsNull() + { + // "//4=" is valid Base64 that decodes to the bytes 0xFF 0xFE, which are not valid UTF-8. + // A strict decoder must reject this rather than substituting U+FFFD replacement characters. + var result = McpHeaderEncoder.DecodeValue("=?base64?//4=?="); + Assert.Null(result); + } + [Fact] public void DecodeValue_MissingPrefix_ReturnsLiteralValue() { @@ -160,4 +170,34 @@ public void EncodeValue_EmbeddedTab_Base64Encodes() var decoded = McpHeaderEncoder.DecodeValue(result); Assert.Equal("col1\tcol2", decoded); } + + [Theory] + [InlineData("=?base64?literal?=")] + [InlineData("=?base64?SGVsbG8=?=")] + [InlineData("=?base64??=")] + public void EncodeValue_SentinelCollision_Base64Encodes(string input) + { + var result = McpHeaderEncoder.EncodeValue(input); + Assert.NotNull(result); + Assert.StartsWith("=?base64?", result); + Assert.EndsWith("?=", result); + + // The encoded value must be different from the input to avoid ambiguity + Assert.NotEqual(input, result); + + // Verify round-trip: decode must recover the original literal value + var decoded = McpHeaderEncoder.DecodeValue(result); + Assert.Equal(input, decoded); + } + + [Theory] + [InlineData("=?BASE64?literal?=")] // Case-sensitive: uppercase prefix does not match sentinel + [InlineData("=?base64?start")] // Missing suffix: no sentinel match + [InlineData("end?=")] // Missing prefix: no sentinel match + [InlineData("plain-text")] // No sentinel pattern + public void EncodeValue_NonSentinelPattern_NotBase64Encoded(string input) + { + var result = McpHeaderEncoder.EncodeValue(input); + Assert.Equal(input, result); + } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs new file mode 100644 index 000000000..ff3916d2a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpHeaderExtractorValidationTests.cs @@ -0,0 +1,218 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for SEP-2243 x-mcp-header validation changes: +/// - RFC 9110 tchar validation for header names +/// - "number" type rejection (only integer/string/boolean allowed) +/// - Nested property support for x-mcp-header annotations +/// +public class McpHeaderExtractorValidationTests : ClientServerTestBase +{ + public McpHeaderExtractorValidationTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // Valid baseline tool + mcpServerBuilder.WithTools([McpServerTool.Create( + (string input) => $"echo {input}", + new() { Name = "ValidTool" })]); + + // Tool with "number" type (should be rejected per updated SEP-2243) + var numberTool = McpServerTool.Create((string x) => x, new() { Name = "NumberTypeTool" }); + numberTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "value": { "type": "number", "x-mcp-header": "Value" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([numberTool]); + + // Tool with "integer" type (should be accepted) + var integerTool = McpServerTool.Create((string x) => x, new() { Name = "IntegerTypeTool" }); + integerTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "count": { "type": "integer", "x-mcp-header": "Count" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([integerTool]); + + // Tool with non-tchar header name (should be rejected) + var nonTcharTool = McpServerTool.Create((string x) => x, new() { Name = "BadTcharTool" }); + nonTcharTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Region(1)" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([nonTcharTool]); + + // Tool with valid nested x-mcp-header + var nestedValidTool = McpServerTool.Create((string x) => x, new() { Name = "NestedValidTool" }); + nestedValidTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "config": { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Region" } } } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([nestedValidTool]); + + // Tool with invalid nested x-mcp-header (colon in header name) + var nestedInvalidTool = McpServerTool.Create((string x) => x, new() { Name = "NestedInvalidTool" }); + nestedInvalidTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "config": { "type": "object", "properties": { "region": { "type": "string", "x-mcp-header": "Invalid:Header" } } } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([nestedInvalidTool]); + + // Tool with duplicate header names across nesting levels + var duplicateTool = McpServerTool.Create((string x) => x, new() { Name = "DuplicateHeaderTool" }); + duplicateTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "topRegion": { "type": "string", "x-mcp-header": "Region" }, "nested": { "type": "object", "properties": { "innerRegion": { "type": "string", "x-mcp-header": "region" } } } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([duplicateTool]); + + // Tool with nested "number" type (should be rejected) + var nestedNumberTool = McpServerTool.Create((string x) => x, new() { Name = "NestedNumberTool" }); + nestedNumberTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "config": { "type": "object", "properties": { "threshold": { "type": "number", "x-mcp-header": "Threshold" } } } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([nestedNumberTool]); + + // Tool with a nullable union type ["string", "null"] (should be accepted) + var nullableUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NullableUnionTool" }); + nullableUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "region": { "type": ["string", "null"], "x-mcp-header": "Region" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([nullableUnionTool]); + + // Tool with a union type containing a disallowed type ["number", "null"] (should be rejected) + var numberUnionTool = McpServerTool.Create((string x) => x, new() { Name = "NumberUnionTool" }); + numberUnionTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "value": { "type": ["number", "null"], "x-mcp-header": "Value" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([numberUnionTool]); + + // Tool with a "null"-only union type (should be rejected: no allowed primitive present) + var nullOnlyTool = McpServerTool.Create((string x) => x, new() { Name = "NullOnlyTool" }); + nullOnlyTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "value": { "type": ["null"], "x-mcp-header": "Value" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([nullOnlyTool]); + + // Tool whose annotated property omits "type" (should be accepted: type is unknown, not invalid) + var missingTypeTool = McpServerTool.Create((string x) => x, new() { Name = "MissingTypeTool" }); + missingTypeTool.ProtocolTool.InputSchema = JsonDocument.Parse(""" + { "type": "object", "properties": { "region": { "x-mcp-header": "Region" } } } + """).RootElement.Clone(); + mcpServerBuilder.WithTools([missingTypeTool]); + } + + [Fact] + public async Task ListToolsAsync_NumberType_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "NumberTypeTool"); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.LogLevel == LogLevel.Warning && + log.Message.Contains("NumberTypeTool") && + log.Message.Contains("excluded")); + } + + [Fact] + public async Task ListToolsAsync_IntegerType_AcceptsTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "IntegerTypeTool"); + } + + [Fact] + public async Task ListToolsAsync_NonTcharHeaderName_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "BadTcharTool"); + } + + [Fact] + public async Task ListToolsAsync_NestedValidHeader_AcceptsTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "NestedValidTool"); + } + + [Fact] + public async Task ListToolsAsync_NestedInvalidHeader_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "NestedInvalidTool"); + } + + [Fact] + public async Task ListToolsAsync_NestedDuplicateHeaders_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "DuplicateHeaderTool"); + } + + [Fact] + public async Task ListToolsAsync_NestedNumberType_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "NestedNumberTool"); + } + + [Fact] + public async Task ListToolsAsync_NullableUnionType_AcceptsTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "NullableUnionTool"); + } + + [Fact] + public async Task ListToolsAsync_NumberUnionType_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "NumberUnionTool"); + } + + [Fact] + public async Task ListToolsAsync_NullOnlyUnionType_ExcludesTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "ValidTool"); + Assert.DoesNotContain(tools, t => t.Name == "NullOnlyTool"); + } + + [Fact] + public async Task ListToolsAsync_MissingType_AcceptsTool() + { + await using var client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == "MissingTypeTool"); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs index 694869af9..85374a589 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpHeaderAttributeTests.cs @@ -9,6 +9,9 @@ public class McpHeaderAttributeTests [InlineData("TenantId")] [InlineData("Priority")] [InlineData("X-Custom")] + [InlineData("x!header")] + [InlineData("x#header")] + [InlineData("x~header")] public void Constructor_ValidHeaderName_Succeeds(string name) { var attr = new McpHeaderAttribute(name); @@ -27,6 +30,23 @@ public void Constructor_NameWithColon_Throws() Assert.Throws(() => new McpHeaderAttribute("Region:Primary")); } + [Theory] + [InlineData("Region(1)")] + [InlineData("path/to")] + [InlineData("key=value")] + [InlineData("name@host")] + [InlineData("with,comma")] + [InlineData("with;semi")] + [InlineData("with[bracket")] + [InlineData("with{brace")] + [InlineData("with\"quote")] + [InlineData("with\\backslash")] + [InlineData("with?question")] + public void Constructor_NonTcharCharacter_Throws(string name) + { + Assert.Throws(() => new McpHeaderAttribute(name)); + } + [Fact] public void Constructor_NullName_Throws() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index a283bf18c..200722276 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1221,6 +1221,15 @@ public void Create_WithMcpHeaderOnNonPrimitiveType_ThrowsInvalidOperationExcepti McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNonPrimitiveHeader))!)); } + [Fact] + public void Create_WithMcpHeaderOnUInt64Type_ThrowsInvalidOperationException() + { + // ulong is excluded per SEP-2243 because its domain extends beyond the JavaScript safe + // integer range (and beyond long), so it cannot be represented as a signed integer header. + Assert.Throws(() => + McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithUInt64Header))!)); + } + [Fact] public void Create_WithMcpHeaderOnNumericType_AddsExtension() { @@ -1308,5 +1317,10 @@ public static string ToolWithNullableHeader( [McpServerTool] public static string ToolWithoutHeaders(string region, string query) => "result"; + + [McpServerTool] + public static string ToolWithUInt64Header( + [McpHeader("Count")] ulong count) + => "result"; } } From a4157f310fd0c9c7270bcb515ad253d022e92177 Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Fri, 5 Jun 2026 18:24:34 -0500 Subject: [PATCH 17/50] perf(server): skip IdleTrackingBackgroundService timer in stateless mode (#1531) --- .../IdleTrackingBackgroundService.cs | 11 ++++ .../HttpMcpServerBuilderExtensionsTests.cs | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index d68f83e5d..645253d6f 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -31,6 +31,17 @@ public IdleTrackingBackgroundService( _logger = logger; } + 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) + { + return Task.CompletedTask; + } + + return base.StartAsync(cancellationToken); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs index cc6ff0b13..ef385ed70 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Protocol; @@ -184,6 +185,55 @@ public void SessionMigrationHandler_RemainsNull_WhenNothingIsRegistered() Assert.Null(options.SessionMigrationHandler); } + [Fact] + public async Task IdleTrackingBackgroundService_DoesNotStartTimer_WhenStateless() + { + Builder.Services + .AddMcpServer() + .WithHttpTransport(options => options.Stateless = true); + + using var app = Builder.Build(); + + var idleTrackingService = GetIdleTrackingService(app.Services); + Assert.NotNull(idleTrackingService); + + await idleTrackingService.StartAsync(TestContext.Current.CancellationToken); + + // BackgroundService.ExecuteTask is only set when ExecuteAsync has been kicked off via base.StartAsync. + // In stateless mode we early-return, so ExecuteTask should remain null. + Assert.Null(idleTrackingService.ExecuteTask); + + await idleTrackingService.StopAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() + { + Builder.Services + .AddMcpServer() + .WithHttpTransport(); + + using var app = Builder.Build(); + + var idleTrackingService = GetIdleTrackingService(app.Services); + Assert.NotNull(idleTrackingService); + + await idleTrackingService.StartAsync(TestContext.Current.CancellationToken); + + // In the default (stateful) mode the timer loop must start, so ExecuteTask should be set. + Assert.NotNull(idleTrackingService.ExecuteTask); + + await idleTrackingService.StopAsync(TestContext.Current.CancellationToken); + } + + private static BackgroundService? GetIdleTrackingService(IServiceProvider services) + { + // IdleTrackingBackgroundService is internal, so look it up by type name from the registered IHostedService instances. + return services.GetServices() + .OfType() + .FirstOrDefault(s => s.GetType().Name == "IdleTrackingBackgroundService"); + } + private sealed class StubSessionMigrationHandler : ISessionMigrationHandler { public ValueTask AllowSessionMigrationAsync( From 01dd43217ef31c0c5bcb9dc3ac4b61b39ac77255 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:35:00 -0700 Subject: [PATCH 18/50] Fix flaky test: add sync point in ReadEventsAsync_InStreamingMode_YieldsNewlyWrittenEvents (#1491) --- .../Server/DistributedCacheEventStreamStoreTests.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs index 0983e6ad9..f306064f8 100644 --- a/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DistributedCacheEventStreamStoreTests.cs @@ -904,10 +904,16 @@ public async Task ReadEventsAsync_InStreamingMode_YieldsNewlyWrittenEvents() using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); var events = new List>(); + + // Use a TCS as a sync point: set when the reader has confirmed receipt of the first event. + // This guarantees the streaming enumerator is definitely active before we write events 2 and 3. + var readerStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readTask = Task.Run(async () => { await foreach (var evt in reader.ReadEventsAsync(cts.Token)) { + readerStarted.TrySetResult(true); events.Add(evt); if (events.Count >= 3) { @@ -916,8 +922,13 @@ public async Task ReadEventsAsync_InStreamingMode_YieldsNewlyWrittenEvents() } }, CancellationToken); - // Write 3 new events - the reader should pick them up since it's in streaming mode + // Write the first event and wait for the reader to confirm it has been received. + // This establishes a synchronization point: once readerStarted is signalled, we know + // the streaming loop is running and will reliably observe any subsequently written events. var event1 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); + await readerStarted.Task.WaitAsync(cts.Token); + + // Write the remaining 2 events now that the reader is confirmed active var event2 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); var event3 = await writer.WriteEventAsync(new SseItem(null), CancellationToken); From 480979b1b592a4c9a768c1918f994f025f72fd36 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:37:23 -0700 Subject: [PATCH 19/50] Fix flaky DiagnosticTests.Session_TracksActivities by waiting for full server activity predicate (#1495) --- .../DiagnosticTests.cs | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index bbe7d153f..03e00af8d 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -18,6 +18,18 @@ public async Task Session_TracksActivities() var activities = new List(); var clientToServerLog = new List(); + // Predicate for the expected server tool-call activity, including all required tags. + // Defined here so it can be reused for both the wait and the assertion below. + Func isExpectedServerToolCall = a => + a.DisplayName == "tools/call DoubleValue" && + a.Kind == ActivityKind.Server && + a.Status == ActivityStatusCode.Unset && + a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue") && + a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") && + a.Tags.Any(t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool") && + a.Tags.Any(t => t.Key == "mcp.protocol.version" && !string.IsNullOrEmpty(t.Value)) && + a.Tags.Any(t => t.Key == "mcp.session.id" && !string.IsNullOrEmpty(t.Value)); + using (var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource("Experimental.ModelContextProtocol") .AddInMemoryExporter(activities) @@ -36,9 +48,11 @@ await RunConnected(async (client, server) => // Wait for server-side activities to be exported. The server processes messages // via fire-and-forget tasks, so activities may not be immediately available // after the client operation completes. Wait for the specific activity we need - // rather than a count, as other server activities may be exported first. - await WaitForAsync(() => activities.Any(a => - a.DisplayName == "tools/call DoubleValue" && a.Kind == ActivityKind.Server)); + // (including required tags) rather than just the display name, so that we don't + // assert before all tags have been populated. + await WaitForAsync( + () => activities.Any(isExpectedServerToolCall), + failureMessage: "Timed out waiting for the expected server tool-call activity (tools/call DoubleValue) to be exported with required tags."); } Assert.NotEmpty(activities); @@ -54,13 +68,7 @@ await WaitForAsync(() => activities.Any(a => // Per semantic conventions: mcp.protocol.version should be present after initialization Assert.Contains(clientToolCall.Tags, t => t.Key == "mcp.protocol.version" && !string.IsNullOrEmpty(t.Value)); - var serverToolCall = Assert.Single(activities, a => - a.Tags.Any(t => t.Key == "gen_ai.tool.name" && t.Value == "DoubleValue") && - a.Tags.Any(t => t.Key == "mcp.method.name" && t.Value == "tools/call") && - a.Tags.Any(t => t.Key == "gen_ai.operation.name" && t.Value == "execute_tool") && - a.DisplayName == "tools/call DoubleValue" && - a.Kind == ActivityKind.Server && - a.Status == ActivityStatusCode.Unset); + var serverToolCall = Assert.Single(activities, a => isExpectedServerToolCall(a)); // Per semantic conventions: mcp.protocol.version should be present after initialization Assert.Contains(serverToolCall.Tags, t => t.Key == "mcp.protocol.version" && !string.IsNullOrEmpty(t.Value)); @@ -245,12 +253,19 @@ private static async Task RunConnected(Func action, await serverTask; } - private static async Task WaitForAsync(Func condition, int timeoutMs = 10_000) + private static async Task WaitForAsync(Func condition, int timeoutMs = 10_000, string? failureMessage = null) { using var cts = new CancellationTokenSource(timeoutMs); - while (!condition()) + try + { + while (!condition()) + { + await Task.Delay(10, cts.Token); + } + } + catch (TaskCanceledException) { - await Task.Delay(10, cts.Token); + throw new Xunit.Sdk.XunitException(failureMessage ?? $"Condition was not met within {timeoutMs}ms."); } } } From 20ddd2fb108b21dbf2601285fc7bf52f235a01e2 Mon Sep 17 00:00:00 2001 From: Weinong Wang Date: Fri, 5 Jun 2026 16:38:12 -0700 Subject: [PATCH 20/50] Fix $ref pointer resolution after output schema wrapping (#1435) --- .../Server/AIFunctionMcpServerTool.cs | 51 +++++ .../Server/McpServerToolTests.cs | 204 ++++++++++++++++++ 2 files changed, 255 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 2b39beefe..5951d7e41 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -543,6 +543,11 @@ typeProperty.ValueKind is not JsonValueKind.String || ["required"] = new JsonArray { (JsonNode)"result" } }; + // After wrapping, any internal $ref pointers that used absolute JSON Pointer + // paths (e.g., "#/items/..." or "#") are now invalid because the original schema + // has moved under "#/properties/result". Rewrite them to account for the new location. + RewriteRefPointers(schemaNode["properties"]!["result"]); + structuredOutputRequiresWrapping = true; } @@ -552,6 +557,52 @@ typeProperty.ValueKind is not JsonValueKind.String || return outputSchema; } + /// + /// Recursively rewrites all $ref JSON Pointer values in the given node + /// to account for the schema having been wrapped under properties.result. + /// + /// + /// System.Text.Json's uses absolute + /// JSON Pointer paths (e.g., #/items/properties/foo) to deduplicate types that appear at + /// multiple locations in the schema. When the original schema is moved under + /// #/properties/result by the wrapping logic above, these pointers become unresolvable. + /// This method prepends /properties/result to every $ref that starts with #/, + /// and rewrites bare # (root self-references from recursive types) to #/properties/result, + /// so the pointers remain valid after wrapping. + /// + private static void RewriteRefPointers(JsonNode? node) + { + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && + refNode?.GetValue() is string refValue) + { + if (refValue == "#") + { + obj["$ref"] = "#/properties/result"; + } + else if (refValue.StartsWith("#/", StringComparison.Ordinal)) + { + obj["$ref"] = "#/properties/result" + refValue.Substring(1); + } + } + + // Safe to iterate without snapshot: the $ref assignment above completes before + // this enumerator is created, and recursive calls only mutate descendant objects. + foreach (var property in obj) + { + RewriteRefPointers(property.Value); + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + RewriteRefPointers(item); + } + } + } + private JsonElement? CreateStructuredResponse(object? aiFunctionResult) { if (ProtocolTool.OutputSchema is null) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 200722276..77b5be42d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -794,6 +794,186 @@ public async Task ToolWithNullableParameters_ReturnsExpectedSchema(JsonNumberHan Assert.True(JsonElement.DeepEquals(expectedSchema, tool.ProtocolTool.InputSchema)); } + [Fact] + public async Task StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers() + { + // When a non-object return type contains the same type at multiple locations, + // System.Text.Json's schema exporter emits $ref pointers for deduplication. + // After wrapping the schema under properties.result, those $ref pointers must + // be rewritten to remain valid. This test verifies that fix. + var data = new List + { + new() + { + WorkPhones = [new() { Number = "555-0100", Type = "work" }], + HomePhones = [new() { Number = "555-0200", Type = "home" }], + } + }; + + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create(() => data, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); + var mockServer = new Mock(); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new() { Name = "tool" }), + TestContext.Current.CancellationToken); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.NotNull(result.StructuredContent); + + // Verify $ref pointers in the schema point to valid locations after wrapping. + // Without the fix, $ref values like "#/items/..." would be unresolvable because + // the original schema was moved under "#/properties/result". + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); + + // Also verify that any $ref in the schema starts with #/properties/result + // (confirming the rewrite happened). + string schemaJson = tool.ProtocolTool.OutputSchema.Value.GetRawText(); + var schemaNode = JsonNode.Parse(schemaJson)!; + int refCount = AssertAllRefsStartWith(schemaNode, "#/properties/result"); + Assert.True(refCount > 0, "Expected at least one $ref in the schema to validate the rewrite, but none were found."); + int resolvableCount = AssertAllRefsResolvable(schemaNode, schemaNode); + Assert.True(resolvableCount > 0, "Expected at least one resolvable $ref in the schema, but none were found."); + } + + [Fact] + public async Task StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers() + { + // When a non-object return type contains a recursive type, System.Text.Json's + // schema exporter emits $ref pointers (including potentially bare "#") for the + // recursive reference. After wrapping, these must be rewritten. For List, + // Children's items emit "$ref": "#/items" which must become "#/properties/result/items". + var data = new List + { + new() + { + Name = "root", + Children = [new() { Name = "child" }], + } + }; + + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create(() => data, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); + var mockServer = new Mock(); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new() { Name = "tool" }), + TestContext.Current.CancellationToken); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.NotNull(result.StructuredContent); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); + + string schemaJson = tool.ProtocolTool.OutputSchema.Value.GetRawText(); + var schemaNode = JsonNode.Parse(schemaJson)!; + int refCount = AssertAllRefsStartWith(schemaNode, "#/properties/result"); + Assert.True(refCount > 0, "Expected at least one $ref in the schema to validate the rewrite, but none were found."); + int resolvableCount = AssertAllRefsResolvable(schemaNode, schemaNode); + Assert.True(resolvableCount > 0, "Expected at least one resolvable $ref in the schema, but none were found."); + } + + private static int AssertAllRefsStartWith(JsonNode? node, string expectedPrefix) + { + int count = 0; + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && + refNode?.GetValue() is string refValue) + { + Assert.StartsWith(expectedPrefix, refValue); + count++; + } + + foreach (var property in obj) + { + count += AssertAllRefsStartWith(property.Value, expectedPrefix); + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + count += AssertAllRefsStartWith(item, expectedPrefix); + } + } + + return count; + } + + /// + /// Walks the JSON tree and verifies that every $ref pointer resolves to a valid node. + /// + private static int AssertAllRefsResolvable(JsonNode root, JsonNode? node) + { + int count = 0; + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && + refNode?.GetValue() is string refValue && + refValue.StartsWith("#", StringComparison.Ordinal)) + { + var resolved = ResolveJsonPointer(root, refValue); + Assert.True(resolved is not null, $"$ref \"{refValue}\" does not resolve to a valid node in the schema."); + count++; + } + + foreach (var property in obj) + { + count += AssertAllRefsResolvable(root, property.Value); + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + count += AssertAllRefsResolvable(root, item); + } + } + + return count; + } + + /// + /// Resolves a JSON Pointer (e.g., #/properties/result/items) against a root node. + /// Returns null if the pointer cannot be resolved. + /// + private static JsonNode? ResolveJsonPointer(JsonNode root, string pointer) + { + if (pointer == "#") + { + return root; + } + + if (!pointer.StartsWith("#/", StringComparison.Ordinal)) + { + return null; + } + + JsonNode? current = root; + string[] segments = pointer.Substring(2).Split('/'); + foreach (string segment in segments) + { + if (current is JsonObject obj) + { + if (!obj.TryGetPropertyValue(segment, out current)) + { + return null; + } + } + else if (current is JsonArray arr && int.TryParse(segment, out int index) && index >= 0 && index < arr.Count) + { + current = arr[index]; + } + else + { + return null; + } + } + + return current; + } + public static IEnumerable StructuredOutput_ReturnsExpectedSchema_Inputs() { yield return new object[] { "string" }; @@ -926,6 +1106,30 @@ private static JsonSerializerOptions CreateSerializerOptionsWithPerson() return options; } + // Types used by StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers. + // ContactInfo has two properties of the same type (PhoneNumber) which causes + // System.Text.Json's schema exporter to emit $ref pointers for deduplication. + private sealed class PhoneNumber + { + public string? Number { get; set; } + public string? Type { get; set; } + } + + private sealed class ContactInfo + { + public List? WorkPhones { get; set; } + public List? HomePhones { get; set; } + } + + // Recursive type used by StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers. + // When List is the return type, Children's items emit "$ref": "#/items" + // pointing back to the first TreeNode definition, which must be rewritten after wrapping. + private sealed class TreeNode + { + public string? Name { get; set; } + public List? Children { get; set; } + } + [Fact] public void SupportsIconsInCreateOptions() { From af6fcff595138891b00ae560afbcea9d21370802 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:52:31 -0700 Subject: [PATCH 21/50] Add McpClientOptions.InitializeMeta to set _meta on the initialize request (#1599) --- .../Client/McpClientImpl.cs | 1 + .../Client/McpClientOptions.cs | 16 +++++++ .../Client/McpClientMetaTests.cs | 44 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index e6ab3aae4..bc58794ad 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -679,6 +679,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) ProtocolVersion = requestProtocol, Capabilities = _options.Capabilities ?? new ClientCapabilities(), ClientInfo = _options.ClientInfo ?? DefaultImplementation, + Meta = _options.InitializeMeta, }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, McpJsonUtilities.JsonContext.Default.InitializeResult, diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 6d91f5b03..f84b84826 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -1,5 +1,6 @@ using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Client; @@ -31,6 +32,21 @@ public sealed class McpClientOptions /// public ClientCapabilities? Capabilities { get; set; } + /// + /// Gets or sets the metadata to include in the _meta field of the request. + /// + /// + /// + /// When set, this value is sent as on the during the initialization handshake. + /// This allows passing implementation-specific data to the server alongside the standard initialize parameters, + /// such as authentication context a server validates before completing the handshake. + /// + /// + /// When , no _meta field is sent. + /// + /// + public JsonObject? InitializeMeta { get; set; } + /// /// Gets or sets the protocol version to request from the server, using a date-based versioning scheme. /// diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 863d8e671..7e67eb44c 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -8,6 +8,8 @@ namespace ModelContextProtocol.Tests.Client; public class McpClientMetaTests : ClientServerTestBase { + private readonly TaskCompletionSource _initializeMeta = new(); + public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) { @@ -28,6 +30,48 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer o.ResourceCollection = new (); o.PromptCollection = new (); }); + + // Capture the _meta the server receives on the initialize request so tests can + // assert that McpClientOptions.InitializeMeta is threaded through the handshake. + mcpServerBuilder.WithMessageFilters(filters => + filters.AddIncomingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request) + { + _initializeMeta.TrySetResult(request.Params?["_meta"]); + } + + await next(context, cancellationToken); + })); + } + + [Fact] + public async Task InitializeMeta_IsSentToServer_WhenSet() + { + var clientOptions = new McpClientOptions + { + InitializeMeta = new JsonObject + { + { "foo", "bar baz" } + } + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(meta); + Assert.Equal("bar baz", meta["foo"]?.ToString()); + } + + [Fact] + public async Task InitializeMeta_IsOmitted_WhenNotSet() + { + await using McpClient client = await CreateMcpClientForServer(); + + var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken); + + Assert.Null(meta); } [Fact] From a04c3e4f5025630803577eed00231aa1bc7f37f5 Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:53:07 -0400 Subject: [PATCH 22/50] Fix: Remove `charset=utf-8` from `Content-Type: application/json` in HTTP transport (#1528) --- .../Client/McpHttpClient.cs | 4 +- .../Transport/HttpClientTransportTests.cs | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs index 7caf50143..f4df789c7 100644 --- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs @@ -12,7 +12,7 @@ namespace ModelContextProtocol.Client; internal class McpHttpClient(HttpClient httpClient) { - internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json") { CharSet = "utf-8" }; + internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json"); internal virtual async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) { @@ -32,7 +32,7 @@ internal virtual async Task SendAsync(HttpRequestMessage re } #if NET - return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); + return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage, s_applicationJsonContentType); #else var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage); var content = new ByteArrayContent(bytes); diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs index 60384d3c2..2627bc504 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs @@ -248,6 +248,57 @@ public async Task DisposeAsync_Should_Dispose_Resources() Assert.False(transportBase.IsConnected); } + // Strict server mock used in Content-Type tests below. + // Returns 200 only for bare "application/json", otherwise 415. + private static Func> StrictJsonContentTypeHandler => + (request) => + { + if (request.Method == HttpMethod.Post) + { + var contentType = request.Content?.Headers.ContentType; + if (contentType?.CharSet is not null) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.UnsupportedMediaType, + Content = new StringContent("Content-Type must be 'application/json'"), + }); + } + + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"Test","version":"1.0"}}}""", + Encoding.UTF8, + "application/json"), + }); + } + + throw new IOException("Abort"); + }; + + [Fact] + public async Task SendMessageAsync_StrictServer_Returns200_WhenContentTypeIsApplicationJson() + { + // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1527 + // SDK must send bare "application/json" — no charset parameter. + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + mockHttpHandler.RequestHandler = StrictJsonContentTypeHandler; + + // Succeeds only if the SDK sends Content-Type: application/json (no charset) + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + Assert.NotNull(session); + } + [Fact] public async Task StreamableHttp_InitialGetSseConnection_DoesNotCountAgainstMaxReconnectionAttempts() { From df0c102d75ecd615ec6a4a479a5cd64ab3ace252 Mon Sep 17 00:00:00 2001 From: DragonFSKY <38503900+DragonFSKY@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:54:07 -0700 Subject: [PATCH 23/50] Require Tool inputSchema during deserialization (#1600) --- src/ModelContextProtocol.Core/Protocol/Tool.cs | 1 + tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 8abbfd88c..ce17d9f7d 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -64,6 +64,7 @@ public sealed class Tool : IBaseMetadata /// /// [JsonPropertyName("inputSchema")] + [JsonRequired] public JsonElement InputSchema { get => field; diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs index 5b2160571..d8335c055 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs @@ -105,6 +105,14 @@ public static void ToolInputSchema_HasValidDefaultSchema() Assert.Equal("object", typeElement.GetString()); } + [Fact] + public static void ToolInputSchema_DeserializationRejectsMissingInputSchema() + { + const string json = """{"name":"test"}"""; + + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + [Theory] [InlineData("null")] [InlineData("false")] From acb4cc9fd1697f27a339e29f161823f0e0f9e13a Mon Sep 17 00:00:00 2001 From: Joel Forsyth <64228401+joelmforsyth@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:14:49 -0400 Subject: [PATCH 24/50] Release SSE response stream reference when GET request ends (#1519) --- .../Server/StreamableHttpServerTransport.cs | 81 +++++++++++------ .../StreamableHttpServerTransportTests.cs | 89 +++++++++++++++++++ 2 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Transport/StreamableHttpServerTransportTests.cs diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 71c366e83..cd39b2613 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -44,7 +44,7 @@ public sealed partial class StreamableHttpServerTransport : ITransport private TaskCompletionSource? _httpResponseTcs; private string? _negotiatedProtocolVersion; private bool _getHttpRequestStarted; - private bool _getHttpResponseCompleted; + private bool _disposed; /// /// Initializes a new instance of the class. @@ -137,33 +137,53 @@ public async Task HandleGetRequestAsync(Stream sseResponseStream, CancellationTo throw new InvalidOperationException("GET requests are not supported in stateless mode."); } - using (await _unsolicitedMessageLock.LockAsync(cancellationToken).ConfigureAwait(false)) + try { - if (_getHttpRequestStarted) + using (await _unsolicitedMessageLock.LockAsync(cancellationToken).ConfigureAwait(false)) { - throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session."); - } + if (_getHttpRequestStarted) + { + throw new InvalidOperationException("Session resumption is not yet supported. Please start a new session."); + } - _getHttpRequestStarted = true; - _httpSseWriter = new SseEventWriter(sseResponseStream); - _httpResponseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _storeSseWriter = await TryCreateEventStreamAsync(streamId: UnsolicitedMessageStreamId, cancellationToken).ConfigureAwait(false); - if (_storeSseWriter is not null) - { - var primingItem = await _storeSseWriter.WriteEventAsync(SseItem.Prime(), cancellationToken).ConfigureAwait(false); - await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false); + _getHttpRequestStarted = true; + _httpSseWriter = new SseEventWriter(sseResponseStream); + _httpResponseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _storeSseWriter = await TryCreateEventStreamAsync(streamId: UnsolicitedMessageStreamId, cancellationToken).ConfigureAwait(false); + if (_storeSseWriter is not null) + { + var primingItem = await _storeSseWriter.WriteEventAsync(SseItem.Prime(), cancellationToken).ConfigureAwait(false); + await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false); + } + else + { + // If there's no priming write, flush the stream to ensure HTTP response headers are + // sent to the client now that the transport is ready to accept messages via SendMessageAsync. + await sseResponseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } } - else + + // 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 + { + // Release the SseEventWriter's reference to the response stream promptly when the GET + // request ends, regardless of how it exits. Otherwise the response stream (and the + // underlying Kestrel connection and associated memory pool buffers) remains pinned + // in memory until the session itself is disposed (via explicit DELETE or idle timeout). + // Clients that disconnect without sending DELETE — common with long-lived SSE — would + // otherwise accumulate significant unmanaged memory per session during that interval. + using (await _unsolicitedMessageLock.LockAsync(CancellationToken.None).ConfigureAwait(false)) { - // If there's no priming write, flush the stream to ensure HTTP response headers are - // sent to the client now that the transport is ready to accept messages via SendMessageAsync. - await sseResponseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + if (_httpSseWriter is { } writer) + { + _httpSseWriter = null; + writer.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); } /// @@ -219,23 +239,22 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can return; } - Debug.Assert(_httpSseWriter is not null); Debug.Assert(_httpResponseTcs is not null); var item = SseItem.Message(message); if (_storeSseWriter is not null) { + // Always record the message in the event store (if configured) — even when the GET + // response stream is gone — so a reconnecting client can replay it via Last-Event-ID. item = await _storeSseWriter.WriteEventAsync(item, cancellationToken).ConfigureAwait(false); } - if (!_getHttpResponseCompleted) + if (_httpSseWriter is { } writer) { - // Only write the message to the response if the response has not completed. - try { - await _httpSseWriter!.WriteAsync(item, cancellationToken).ConfigureAwait(false); + await writer.WriteAsync(item, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { @@ -249,12 +268,12 @@ public async ValueTask DisposeAsync() { using var _ = await _unsolicitedMessageLock.LockAsync().ConfigureAwait(false); - if (_getHttpResponseCompleted) + if (_disposed) { return; } - _getHttpResponseCompleted = true; + _disposed = true; try { @@ -266,7 +285,11 @@ public async ValueTask DisposeAsync() try { _httpResponseTcs?.TrySetResult(true); - _httpSseWriter?.Dispose(); + if (_httpSseWriter is { } writer) + { + _httpSseWriter = null; + writer.Dispose(); + } if (_storeSseWriter is not null) { diff --git a/tests/ModelContextProtocol.Tests/Transport/StreamableHttpServerTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StreamableHttpServerTransportTests.cs new file mode 100644 index 000000000..ce2147e27 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Transport/StreamableHttpServerTransportTests.cs @@ -0,0 +1,89 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Transport; + +public class StreamableHttpServerTransportTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + [Fact] + public async Task SendMessageAsync_AfterGetRequestEnds_DoesNotWriteToResponseStream() + { + // Regression test for the SSE response stream being retained after the GET request + // handler returns. Without releasing the stream reference, the Kestrel connection + // and its associated memory pool buffers (~20MiB per SSE session) stay pinned in + // unmanaged memory until the session is eventually disposed (via explicit DELETE or + // idle timeout), causing steady memory growth for servers whose clients disconnect + // without sending DELETE. After the GET handler returns, SendMessageAsync must not + // attempt to write to the (now released) response stream. + + await using var transport = new StreamableHttpServerTransport() + { + SessionId = "test-session", + }; + + var responseStream = new RecordingStream(); + + using var cts = new CancellationTokenSource(); + var getTask = transport.HandleGetRequestAsync(responseStream, cts.Token); + + // Wait until the GET handler has finished initialization (signaled by the initial + // flush that sends HTTP response headers) so we know _httpSseWriter is set. + await responseStream.FirstActivity.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + var writeCountBeforeCancel = responseStream.WriteCount; + + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => getTask); + + await transport.SendMessageAsync( + new JsonRpcNotification { Method = "test" }, + TestContext.Current.CancellationToken); + + Assert.Equal(writeCountBeforeCancel, responseStream.WriteCount); + } + + private sealed class RecordingStream : Stream + { + private readonly TaskCompletionSource _firstActivity = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _writeCount; + + public Task FirstActivity => _firstActivity.Task; + public int WriteCount => Volatile.Read(ref _writeCount); + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _firstActivity.TrySetResult(true); + + public override Task FlushAsync(CancellationToken cancellationToken) + { + _firstActivity.TrySetResult(true); + return Task.CompletedTask; + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + Interlocked.Increment(ref _writeCount); + _firstActivity.TrySetResult(true); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _writeCount); + _firstActivity.TrySetResult(true); + return Task.CompletedTask; + } + } +} From ed07fdb6f41a71101414868d65f305904f79e57a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 8 Jun 2026 14:44:10 -0700 Subject: [PATCH 25/50] Add diagnostics for messages dropped on the GET SSE stream (#1609) --- .../Server/McpServer.Methods.cs | 32 ++++ .../Server/StreamableHttpServerTransport.cs | 74 ++++++++- .../StreamableHttpServerConformanceTests.cs | 153 +++++++++++++++++- 3 files changed, 255 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 3caaca5a6..fe99794c8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -54,9 +54,18 @@ public static McpServer Create( /// The client does not support sampling. /// The request failed or the client returned an error response. /// + /// + /// When the server is using the Streamable HTTP transport, prefer calling this method on the + /// instance available via RequestContext from inside a tool, prompt, + /// or resource handler. That routes the request through the originating POST response stream via + /// , which is always open for the duration of + /// the request, rather than relying on the optional standalone GET SSE stream. + /// + /// /// When called during task-augmented tool execution, this method automatically updates the task /// status to while waiting for the client response, /// then returns to when the response is received. + /// /// public async ValueTask SampleAsync( CreateMessageRequestParams requestParams, @@ -252,6 +261,13 @@ public async Task SampleAsync( /// The to use for serialization. If , is used. /// The that can be used to issue sampling requests to the client. /// The client does not support sampling. + /// + /// When the server is using the Streamable HTTP transport, prefer obtaining this chat client from the + /// instance available via RequestContext from inside a tool, prompt, + /// or resource handler. That routes sampling requests through the originating POST response stream via + /// , which is always open for the duration of + /// the request, rather than relying on the optional standalone GET SSE stream. + /// public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions = null) { ThrowIfSamplingUnsupported(); @@ -273,6 +289,13 @@ public ILoggerProvider AsClientLoggerProvider() => /// is . /// The client does not support roots. /// The request failed or the client returned an error response. + /// + /// When the server is using the Streamable HTTP transport, prefer calling this method on the + /// instance available via RequestContext from inside a tool, prompt, + /// or resource handler. That routes the request through the originating POST response stream via + /// , which is always open for the duration of + /// the request, rather than relying on the optional standalone GET SSE stream. + /// public ValueTask RequestRootsAsync( ListRootsRequestParams requestParams, CancellationToken cancellationToken = default) @@ -298,9 +321,18 @@ public ValueTask RequestRootsAsync( /// The client does not support elicitation. /// The request failed or the client returned an error response. /// + /// + /// When the server is using the Streamable HTTP transport, prefer calling this method on the + /// instance available via RequestContext from inside a tool, prompt, + /// or resource handler. That routes the request through the originating POST response stream via + /// , which is always open for the duration of + /// the request, rather than relying on the optional standalone GET SSE stream. + /// + /// /// When called during task-augmented tool execution, this method automatically updates the task /// status to while waiting for user input, /// then returns to when the response is received. + /// /// public async ValueTask ElicitAsync( ElicitRequestParams requestParams, diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index cd39b2613..87d353426 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -221,6 +221,34 @@ public async Task HandlePostRequestAsync(JsonRpcMessage message, Stream re } /// + /// + /// + /// This method sends server-to-client messages via the standalone SSE stream opened by an + /// optional HTTP GET request (see ). + /// + /// + /// This is generally the wrong channel for server-to-client requests. Requests + /// sent via the GET stream depend on the client keeping a long-lived GET open, have no per-request + /// correlation to a caller, and race with GET startup and teardown. When called from inside a + /// tool, prompt, or resource handler, use the instance available via + /// RequestContext instead — it routes through the originating POST response stream via + /// , which is always open for the duration of + /// the request. A diagnostic is emitted whenever a + /// is sent through this method. + /// + /// + /// If no GET SSE stream has yet been opened on this session, behavior depends on the message kind: + /// messages throw because the + /// awaiting caller has no way to receive a response; messages are + /// dropped (notifications are best-effort and the spec does not require clients to issue a GET) + /// and a diagnostic is logged; other messages are dropped and a + /// diagnostic is logged. + /// + /// + /// + /// is , or is a + /// and no GET SSE stream has been opened on this session. + /// public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { Throw.IfNull(message); @@ -234,9 +262,32 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can if (!_getHttpRequestStarted) { - // Clients are not required to make a GET request for unsolicited messages. - // If no GET request has been made, drop the message. - return; + switch (message) + { + case JsonRpcRequest request: + throw new InvalidOperationException( + $"Cannot send server-to-client JSON-RPC request '{request.Method}' because no GET SSE stream has been opened on this session " + + $"(SessionId: '{SessionId}'). " + + "Inside a tool, prompt, or resource handler, use the IMcpServer instance from RequestContext (or any IMcpServer obtained via DI from a request-scoped service provider) so the request is routed through the originating POST response stream via JsonRpcMessageContext.RelatedTransport. " + + "The standalone GET SSE stream is optional for clients and is not a reliable channel for server-to-client requests."); + + case JsonRpcNotification notification: + // Clients are not required to make a GET request for unsolicited messages. + // If no GET request has been made, drop the notification (best-effort). + LogNotificationDroppedNoGetStream(notification.Method, SessionId ?? string.Empty); + return; + + default: + // JsonRpcResponse / JsonRpcError generally flow through the originating POST response + // stream, so receiving one here without a GET is unexpected. Log loudly and drop. + LogMessageDroppedNoGetStream(message.GetType().Name, GetMessageId(message), SessionId ?? string.Empty); + return; + } + } + + if (message is JsonRpcRequest openRequest) + { + LogServerRequestOverGetStream(openRequest.Method, SessionId ?? string.Empty); } Debug.Assert(_httpResponseTcs is not null); @@ -263,6 +314,9 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can } } + private static string GetMessageId(JsonRpcMessage message) => + message is JsonRpcMessageWithId withId ? withId.Id.ToString() : string.Empty; + /// public async ValueTask DisposeAsync() { @@ -323,4 +377,18 @@ public async ValueTask DisposeAsync() return sseEventStreamWriter; } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Sending server-to-client JSON-RPC request '{Method}' over the standalone GET SSE stream (SessionId: '{SessionId}'). " + + "Consider using the IMcpServer instance from RequestContext inside a tool, prompt, or resource handler so the request is routed through the originating POST response stream via JsonRpcMessageContext.RelatedTransport, which is more reliable than the optional GET SSE stream.")] + private partial void LogServerRequestOverGetStream(string method, string sessionId); + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Dropping server-to-client JSON-RPC notification '{Method}' because no GET SSE stream has been opened on this session (SessionId: '{SessionId}').")] + private partial void LogNotificationDroppedNoGetStream(string method, string sessionId); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Dropping unexpected server-to-client {MessageType} (Id: '{MessageId}') because no GET SSE stream has been opened on this session (SessionId: '{SessionId}'). " + + "Responses normally flow through the originating POST response stream via JsonRpcMessageContext.RelatedTransport.")] + private partial void LogMessageDroppedNoGetStream(string messageType, string messageId, string sessionId); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 38b1ca696..7b282f26d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -384,7 +384,8 @@ async Task GetFirstNotificationAsync() public async Task SendNotificationAsync_DoesNotThrow_WhenNoGetRequestHasBeenMade() { // Clients are not required to make a GET request for unsolicited messages. - // If no GET request has been made, the messages should be dropped rather than throwing. + // If no GET request has been made, the messages should be dropped rather than throwing, + // and the drop should be visible as a Debug-level log so it can be diagnosed. McpServer? server = null; Builder.Services.AddMcpServer() @@ -409,6 +410,156 @@ public async Task SendNotificationAsync_DoesNotThrow_WhenNoGetRequestHasBeenMade var exception = await Record.ExceptionAsync(() => server.SendNotificationAsync("test-method", TestContext.Current.CancellationToken)); Assert.Null(exception); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Category == typeof(StreamableHttpServerTransport).FullName && + log.LogLevel == LogLevel.Debug && + log.Message.Contains("test-method") && + log.Message.Contains("no GET SSE stream")); + } + + [Fact] + public async Task SendRequestAsync_Throws_WhenNoGetRequestHasBeenMade() + { + // A server-to-client request sent before any GET SSE stream is opened can never + // receive a response, so the transport should fail fast with InvalidOperationException + // instead of silently dropping the message and leaving the caller hanging on the TCS + // registered by SendRequestAsync. + McpServer? server = null; + + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental + options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => + { + server = mcpServer; + return mcpServer.RunAsync(cancellationToken); + }; +#pragma warning restore MCPEXP002 + }); + + await StartAsync(); + + await CallInitializeAndValidateAsync(); + Assert.NotNull(server); + + var request = new JsonRpcRequest + { + Method = "roots/list", + Id = new RequestId(42), + }; + + var ex = await Assert.ThrowsAsync(() => + server.SendRequestAsync(request, TestContext.Current.CancellationToken)); + + Assert.Contains("roots/list", ex.Message); + Assert.Contains("no GET SSE stream", ex.Message); + Assert.Contains("RequestContext", ex.Message); + Assert.Contains("RelatedTransport", ex.Message); + } + + [Fact] + public async Task SendMessageAsync_LogsWarning_OnUnexpectedResponse_WhenNoGetRequestHasBeenMade() + { + // Responses normally ride the originating POST response stream via RelatedTransport, so + // receiving one through the GET path without an open GET is unexpected. The message is + // dropped (preserving best-effort semantics) but a warning is logged so the situation is + // visible. + McpServer? server = null; + + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental + options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => + { + server = mcpServer; + return mcpServer.RunAsync(cancellationToken); + }; +#pragma warning restore MCPEXP002 + }); + + await StartAsync(); + + await CallInitializeAndValidateAsync(); + Assert.NotNull(server); + + var response = new JsonRpcResponse + { + Id = new RequestId(7), + Result = new JsonObject(), + }; + + var exception = await Record.ExceptionAsync(() => + server.SendMessageAsync(response, TestContext.Current.CancellationToken)); + Assert.Null(exception); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Category == typeof(StreamableHttpServerTransport).FullName && + log.LogLevel == LogLevel.Warning && + log.Message.Contains(nameof(JsonRpcResponse)) && + log.Message.Contains("no GET SSE stream")); + } + + [Fact] + public async Task SendRequestAsync_LogsWarning_WhenGetRequestIsOpen() + { + // Even when the GET SSE stream is open and the request is delivered, server-to-client + // requests sent via the GET path are fragile (no per-request correlation, depend on a + // long-lived GET, race with startup/teardown). A warning is logged to direct callers at + // the RequestContext.RelatedTransport channel instead, without changing behavior. + McpServer? server = null; + + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { +#pragma warning disable MCPEXP002 // RunSessionHandler is experimental + options.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => + { + server = mcpServer; + return mcpServer.RunAsync(cancellationToken); + }; +#pragma warning restore MCPEXP002 + }); + + await StartAsync(); + + await CallInitializeAndValidateAsync(); + Assert.NotNull(server); + + using var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + // Send a request via the GET stream and assert it lands on the wire (proving behavior is unchanged). + // SendRequestAsync awaits a response that the test never produces, so use a CTS to cancel after + // confirming wire delivery. + var request = new JsonRpcRequest + { + Method = "roots/list", + Id = new RequestId(99), + }; + + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var sendTask = server.SendRequestAsync(request, requestCts.Token); + + await foreach (var sseEvent in ReadSseAsync(getResponse.Content)) + { + var received = JsonSerializer.Deserialize(sseEvent, GetJsonTypeInfo()); + Assert.NotNull(received); + Assert.Equal("roots/list", received.Method); + break; + } + + // Cancel the awaited response so SendRequestAsync completes — the wire delivery has already happened. + requestCts.Cancel(); + await Assert.ThrowsAnyAsync(() => sendTask); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Category == typeof(StreamableHttpServerTransport).FullName && + log.LogLevel == LogLevel.Warning && + log.Message.Contains("roots/list") && + log.Message.Contains("RequestContext")); } [Fact] From dbb7a201760ce03e9644f45be63053852478f82f Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Thu, 11 Jun 2026 13:10:45 -0700 Subject: [PATCH 26/50] Implement SEP-2663 Tasks Extension (#1579) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + ModelContextProtocol.slnx | 2 +- docs/concepts/stateless/stateless.md | 8 +- docs/concepts/tasks/tasks.md | 809 +++--- docs/experimental.md | 5 +- docs/list-of-diagnostics.md | 2 +- docs/roadmap.md | 2 +- .../LongRunningTasks/FileBasedMcpTaskStore.cs | 393 --- .../LongRunningTasks/LongRunningTasks.csproj | 14 - samples/LongRunningTasks/Program.cs | 34 - .../Properties/launchSettings.json | 12 - samples/LongRunningTasks/README.md | 3 - samples/LongRunningTasks/Tools/TaskTools.cs | 31 - samples/LongRunningTasks/appsettings.json | 9 - samples/TasksExtension/Program.cs | 109 + samples/TasksExtension/README.md | 50 + samples/TasksExtension/TasksExtension.csproj | 15 + src/Common/Experimentals.cs | 25 +- .../Client/McpClient.Methods.cs | 572 ++--- .../Client/McpClient.cs | 21 + .../Client/McpClientHandlers.cs | 21 - .../Client/McpClientImpl.cs | 434 +--- .../Client/McpClientOptions.cs | 52 +- .../CompatibilitySuppressions.xml | 2244 +++++++++++++++++ .../McpJsonUtilities.cs | 34 +- .../McpTaskCancellationTokenProvider.cs | 127 - .../ModelContextProtocol.Core.csproj | 3 +- .../Protocol/CallToolRequestParams.cs | 20 - .../Protocol/CallToolResult.cs | 21 - .../Protocol/CancelMcpTaskRequestParams.cs | 84 - .../Protocol/CancelTaskRequestParams.cs | 29 + .../Protocol/CancelTaskResult.cs | 22 + .../Protocol/ClientCapabilities.cs | 26 - .../Protocol/CreateMessageRequestParams.cs | 20 - .../Protocol/CreateTaskResult.cs | 62 +- .../Protocol/ElicitRequestParams.cs | 20 - .../Protocol/GetTaskPayloadRequestParams.cs | 27 - .../Protocol/GetTaskRequestParams.cs | 73 +- .../Protocol/GetTaskResult.cs | 450 ++++ .../Protocol/ListTasksRequestParams.cs | 34 - .../Protocol/McpExtensions.cs | 18 + .../Protocol/McpTask.cs | 104 - .../Protocol/McpTaskMetadata.cs | 41 - .../Protocol/McpTaskStatus.cs | 58 +- .../McpTaskStatusNotificationParams.cs | 67 - .../Protocol/McpTasksCapability.cs | 160 -- .../Protocol/NotificationMethods.cs | 37 +- .../Protocol/RequestMethods.cs | 25 +- .../Protocol/Result.cs | 2 + .../Protocol/ResultOrCreatedTask.cs | 76 + .../Protocol/ServerCapabilities.cs | 26 - .../Protocol/TaskStatusNotificationParams.cs | 393 +++ .../Protocol/TimeSpanMillisecondsConverter.cs | 41 - .../Protocol/Tool.cs | 20 - .../Protocol/ToolExecution.cs | 85 - .../Protocol/UpdateTaskRequestParams.cs | 29 + .../Protocol/UpdateTaskResult.cs | 21 + .../RequestHandlers.cs | 45 + .../Server/AIFunctionMcpServerTool.cs | 51 +- .../Server/IMcpTaskStore.cs | 206 +- .../Server/InMemoryMcpTaskStore.cs | 567 +---- .../Server/InputResponseReceivedEventArgs.cs | 26 + .../Server/McpRequestFilters.cs | 31 + .../Server/McpServer.Methods.cs | 680 ++--- .../Server/McpServerHandlers.cs | 115 +- .../Server/McpServerImpl.cs | 769 +++--- .../Server/McpServerOptions.cs | 49 +- .../Server/McpServerToolAttribute.cs | 26 - .../Server/McpServerToolCreateOptions.cs | 18 - .../Server/McpTaskExecutionContext.cs | 24 + .../Server/McpTaskInfo.cs | 28 + .../Server/TaskExecutionContext.cs | 47 - .../McpServerOptionsSetup.cs | 6 +- .../ModelContextProtocol.csproj | 2 +- tests/Common/Utils/TestServerTransport.cs | 158 +- tests/Directory.Build.props | 2 +- .../HttpTaskIntegrationTests.cs | 342 --- .../Program.cs | 34 - .../Program.cs | 34 - .../Client/McpClientTaskMethodsTests.cs | 271 +- .../McpClientTaskSamplingElicitationTests.cs | 867 ------- ...rverBuilderExtensionsMessageFilterTests.cs | 15 + .../McpServerOptionsSetupTests.cs | 53 - .../ExperimentalPropertySerializationTests.cs | 62 +- .../ModelContextProtocol.Tests.csproj | 4 - .../Protocol/CallToolRequestParamsTests.cs | 4 - .../Protocol/CallToolResultTests.cs | 10 - .../CancelMcpTaskRequestParamsTests.cs | 25 - .../Protocol/CancelMcpTaskResultTests.cs | 33 - .../Protocol/ClientCapabilitiesTests.cs | 3 - .../Protocol/CreateTaskResultTests.cs | 41 - .../Protocol/ElicitRequestParamsTests.cs | 5 - .../GetTaskPayloadRequestParamsTests.cs | 25 - .../Protocol/GetTaskRequestParamsTests.cs | 25 - .../Protocol/GetTaskResultTests.cs | 37 - .../Protocol/ListTasksRequestParamsTests.cs | 25 - .../Protocol/ListTasksResultTests.cs | 46 - .../Protocol/McpTaskMetadataTests.cs | 53 - .../McpTaskStatusNotificationParamsTests.cs | 37 - .../Protocol/McpTaskTests.cs | 160 -- .../Protocol/McpTasksCapabilityTests.cs | 91 - .../RequestMcpTasksCapabilityTests.cs | 108 - .../Protocol/ServerCapabilitiesTests.cs | 3 - .../Protocol/TaskSerializationTests.cs | 517 ++++ .../AutomaticInputRequiredStatusTests.cs | 478 ---- .../Server/InMemoryMcpTaskStoreTests.cs | 1374 +++------- .../McpServerTaskAugmentedValidationTests.cs | 1012 -------- .../Server/McpServerTaskMethodsTests.cs | 762 ------ .../Server/McpServerTaskNotificationTests.cs | 152 -- .../Server/McpServerTaskTests.cs | 655 +++++ .../Server/McpServerTasksNoStoreTests.cs | 70 + .../Server/McpServerTests.cs | 7 +- .../Server/McpServerToolTests.cs | 76 - .../Server/McpTaskStoreTests.cs | 742 ++++++ .../TaskCancellationIntegrationTests.cs | 365 +-- ...TaskHandlerConfigurationValidationTests.cs | 59 + .../Server/TaskPollStuckDetectorTests.cs | 131 + .../Server/TaskStoreOrphanedTaskTests.cs | 82 + .../Server/ToolTaskSupportTests.cs | 727 ------ 119 files changed, 8190 insertions(+), 11405 deletions(-) delete mode 100644 samples/LongRunningTasks/FileBasedMcpTaskStore.cs delete mode 100644 samples/LongRunningTasks/LongRunningTasks.csproj delete mode 100644 samples/LongRunningTasks/Program.cs delete mode 100644 samples/LongRunningTasks/Properties/launchSettings.json delete mode 100644 samples/LongRunningTasks/README.md delete mode 100644 samples/LongRunningTasks/Tools/TaskTools.cs delete mode 100644 samples/LongRunningTasks/appsettings.json create mode 100644 samples/TasksExtension/Program.cs create mode 100644 samples/TasksExtension/README.md create mode 100644 samples/TasksExtension/TasksExtension.csproj create mode 100644 src/ModelContextProtocol.Core/CompatibilitySuppressions.xml delete mode 100644 src/ModelContextProtocol.Core/McpTaskCancellationTokenProvider.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/CancelMcpTaskRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/GetTaskPayloadRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/ListTasksRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/McpExtensions.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/McpTask.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/McpTaskMetadata.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/McpTaskStatusNotificationParams.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/McpTasksCapability.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs delete mode 100644 src/ModelContextProtocol.Core/Protocol/ToolExecution.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs create mode 100644 src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs create mode 100644 src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs create mode 100644 src/ModelContextProtocol.Core/Server/McpTaskInfo.cs delete mode 100644 src/ModelContextProtocol.Core/Server/TaskExecutionContext.cs delete mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerTaskNotificationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4caf048c6..498e2a75c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 6d096658d..2eebe7225 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -45,11 +45,11 @@ - + diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 2732c9dcd..68ce52f33 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -568,7 +568,7 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ ## Tasks and session modes -[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. +[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. ### Stateless mode @@ -578,9 +578,9 @@ In stateless mode, there is no `SessionId`, so the task store does not apply ses ### Stateful mode -In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. +In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. -Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Fault-tolerant task implementations](xref:tasks#fault-tolerant-task-implementations) for guidance. +Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. ### Task cancellation vs request cancellation @@ -632,7 +632,7 @@ The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expira ### With tasks (experimental) -[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. +[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. This means: diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 91f86db1a..16e3a6dc2 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -1,604 +1,375 @@ --- title: Tasks -author: eiriktsarpalis -description: MCP Tasks for Long-Running Operations +description: Run long-running tool invocations asynchronously with status polling and input requests. uid: tasks --- -# MCP Tasks +## Tasks - -> [!WARNING] -> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. +Tasks let an MCP server run a request asynchronously and report its result to the client later. The +primary use case today is long-running tool invocations: the tool is offloaded to a background task, +and the client polls for status, optionally exchanging additional input along the way. -The Model Context Protocol (MCP) supports [task-based execution] for long-running operations. Tasks enable a "call-now, fetch-later" pattern where clients can initiate operations that may take significant time to complete, then poll for status and retrieve results when ready. +> **Status**: Experimental — diagnostic ID `MCPEXP001`. The implementation tracks +> [SEP-2663 (Tasks Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md). +> See [Experimental APIs](xref:experimental) for how to opt in. -[task-based execution]: https://modelcontextprotocol.io/seps/1686-tasks +### Overview -## Overview +A client opts into tasks on a per-request basis by including the `io.modelcontextprotocol/tasks` +extension key in the request's `_meta`. When that opt-in is present, the server **may** respond +with a instead of the standard result +(e.g., ). The client then polls `tasks/get` +until the task reaches a terminal state. -Tasks are useful when operations may take a long time to complete, such as: +Per the SEP, the server **must not** return `CreateTaskResult` for a request that did not include +the extension opt-in. The SDK enforces this on the server side. -- Large dataset processing or analysis -- Complex report generation -- Code migration or refactoring operations -- Machine learning inference or training -- Batch data transformations +#### Task lifecycle -Without tasks, clients must keep connections open for the entire duration of long-running operations. Tasks allow clients to: - -1. Initiate an operation and receive a task ID immediately -2. Disconnect and reconnect later -3. Poll for status updates -4. Retrieve results when complete -5. Cancel operations if needed - -## Task Lifecycle - -Tasks follow a defined lifecycle through these status values: +```text + ┌─────────────────────────┐ + ▼ │ + (start) → Working ──→ InputRequired │ + │ │ │ + │ └──────────────┘ (client responds via tasks/update) + │ + ├──→ Completed (terminal — includes tool results with isError: true) + ├──→ Cancelled (terminal) + └──→ Failed (terminal — JSON-RPC errors only) +``` -| Status | Description | -|--------|-------------| -| `working` | Task is actively being processed | -| `input_required` | Task is waiting for additional input (e.g., elicitation) | -| `completed` | Task finished successfully; results are available | -| `failed` | Task encountered an error | -| `cancelled` | Task was cancelled by the client | + wire values are serialized in snake_case: +`working`, `input_required`, `completed`, `cancelled`, `failed`. -Tasks begin in the `working` status and transition to one of the terminal states (`completed`, `failed`, or `cancelled`). Once in a terminal state, the status cannot change. +The discriminator field +on the response payload is `"task"` for +and `"complete"` for ordinary results. -## Server Implementation +### Server configuration -### Configuring Task Support +#### Using the task store -To enable task support on a server, configure a task store when setting up the MCP server: +The easiest way to enable tasks is to set an +on . +The SDK ships for development and tests: ```csharp -var builder = WebApplication.CreateBuilder(args); - -// Create a task store for managing task state -var taskStore = new InMemoryMcpTaskStore(); +#pragma warning disable MCPEXP001 builder.Services.AddMcpServer(options => { - // Enable tasks by providing a task store - options.TaskStore = taskStore; + options.TaskStore = new InMemoryMcpTaskStore(); }) -.WithHttpTransport(o => o.Stateless = true) .WithTools(); ``` -The is a reference implementation suitable for development and single-server deployments. For production multi-server scenarios, implement with a persistent backing store (database, Redis, etc.). +When a `TaskStore` is configured the SDK automatically: -### Task Store Configuration +- Wires `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. Explicit + handlers in still take precedence + for any slot they fill. +- Advertises the `io.modelcontextprotocol/tasks` extension in + . +- Wraps each `[McpServerTool]` invocation so that, when the client opts in to the extension, + the tool is offloaded to a background task tracked by the store. +- Establishes a task scope so that , + , and + called from inside the tool + surface as entries in the task's `inputRequests` instead of as direct JSON-RPC requests. +- Plumbs a `CancellationToken` through to the tool that fires when the client invokes + `tasks/cancel`, so cancellation propagates cooperatively. -The `InMemoryMcpTaskStore` constructor accepts several optional parameters: +For production scenarios that need durability, session isolation, multi-process routing, or +TTL-based cleanup, implement yourself +(see [Implementing a custom task store](#implementing-a-custom-task-store) below). -```csharp -var taskStore = new InMemoryMcpTaskStore( - defaultTtl: TimeSpan.FromHours(1), // Default task retention time - maxTtl: TimeSpan.FromHours(24), // Maximum allowed TTL - pollInterval: TimeSpan.FromSeconds(1), // Suggested client poll interval - cleanupInterval: TimeSpan.FromMinutes(5), // Background cleanup frequency - pageSize: 100, // Tasks per page for listing - maxTasks: 1000, // Maximum total tasks allowed - maxTasksPerSession: 100 // Maximum tasks per session -); -``` - -### Tool Task Support +#### Custom task handlers -Tools automatically advertise task support when they return `Task`, `ValueTask`, `Task`, or `ValueTask`: +For full control without a store, set the handlers directly. Each handler is an + that receives an + with typed parameters: ```csharp -[McpServerToolType] -public class MyTools +options.Handlers.GetTaskHandler = (context, ct) => { - // This tool automatically supports task-augmented calls - // because it returns Task (async method) - [McpServerTool, Description("Processes a large dataset")] - public static async Task ProcessDataset( - int recordCount, - CancellationToken cancellationToken) - { - // Long-running operation - await Task.Delay(5000, cancellationToken); - return $"Processed {recordCount} records"; - } + var taskId = context.Params!.TaskId; + // … look up state and return one of the GetTaskResult subtypes. + return new ValueTask(new WorkingTaskResult { TaskId = taskId, /* … */ }); +}; - // Synchronous tools don't support task augmentation by default - [McpServerTool, Description("Quick operation")] - public static string QuickOperation(string input) => $"Result: {input}"; -} +options.Handlers.UpdateTaskHandler = (context, ct) => /* return ValueTask */; +options.Handlers.CancelTaskHandler = (context, ct) => /* return ValueTask */; ``` -You can explicitly control task support using : - -```csharp -// In Program.cs or configuration -builder.Services.AddMcpServer() - .WithTools([ - McpServerTool.Create( - (int count, CancellationToken ct) => ProcessAsync(count, ct), - new McpServerToolCreateOptions - { - Name = "requiredTaskTool", - Execution = new ToolExecution - { - // Require clients to use task augmentation - TaskSupport = ToolTaskSupport.Required - } - }) - ]); -``` - -Task support levels: -- `Forbidden` (default for sync methods): Tool cannot be called with task augmentation -- `Optional` (default for async methods): Tool can be called with or without task augmentation -- `Required`: Tool must be called with task augmentation - -### Explicit Task Creation with `IMcpTaskStore` - -For more control over task lifecycle, tools can directly interact with and return an `McpTask`. This approach allows you to: +> **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting +> into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get` +> handler is wired, the server throws `InvalidOperationException` at request time so misconfigured +> deployments fail loudly instead of shipping unpollable tasks. -- Create a task and return immediately while work continues in the background -- Control exactly when and how task status and results are updated -- Integrate with external systems for task execution +#### Returning a task from a tool handler -Here's a simple example using `Task.Run` to schedule background work: + +returns , so each invocation can choose +between an immediate result and a background task: ```csharp -[McpServerToolType] -public class MyTools(IMcpTaskStore taskStore) +options.Handlers.CallToolWithTaskHandler = async (context, ct) => { - [McpServerTool] - [Description("Starts a background job and returns a task for polling.")] - public async Task StartBackgroundJob( - [Description("Number of items to process")] int itemCount, - RequestContext context, - CancellationToken cancellationToken) + if (ShouldRunInline(context.Params!)) { - // Create a task in the store - this records the task metadata - var task = await taskStore.CreateTaskAsync( - new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(30) }, - context.JsonRpcRequest.Id!, - context.JsonRpcRequest, - context.Server.SessionId, - cancellationToken); - - // Schedule work to run in the background (fire-and-forget) - _ = Task.Run(async () => - { - try - { - // Simulate long-running work - await Task.Delay(TimeSpan.FromSeconds(10)); - var result = $"Processed {itemCount} items successfully"; - - // Store the completed result - await taskStore.StoreTaskResultAsync( - task.TaskId, - McpTaskStatus.Completed, - JsonSerializer.SerializeToElement(new CallToolResult - { - Content = [new TextContentBlock { Text = result }] - }), - context.Server.SessionId); - } - catch (Exception ex) - { - // Mark task as failed on error - await taskStore.StoreTaskResultAsync( - task.TaskId, - McpTaskStatus.Failed, - JsonSerializer.SerializeToElement(new CallToolResult - { - Content = [new TextContentBlock { Text = ex.Message }], - IsError = true - }), - context.Server.SessionId); - } - }, CancellationToken.None); - - // Return immediately - client will poll for completion - return task; + return new CallToolResult { Content = [/* … */] }; } -} -``` - -When a tool returns `McpTask`, the SDK bypasses automatic task wrapping and returns the task directly to the client. - - -> [!IMPORTANT] -> **No Fault Tolerance Guarantees**: Both `InMemoryMcpTaskStore` and the automatic task support for `Task`-returning tool methods do **not** provide fault tolerance. Task state and execution are bounded by the memory of the server process. If the server crashes or restarts: -> - All in-memory task metadata is lost -> - Any in-flight task execution is terminated -> - Clients will receive errors when polling for previously created tasks -> -> For fault-tolerant task execution, see the [Fault-Tolerant Task Implementations](#fault-tolerant-task-implementations) section. - -### Task Status Notifications - -When `SendTaskStatusNotifications` is enabled, the server automatically sends status updates to connected clients: - -```csharp -builder.Services.AddMcpServer(options => -{ - options.TaskStore = taskStore; - options.SendTaskStatusNotifications = true; // Enable notifications -}); -``` - -Clients receive `notifications/tasks/status` messages when task status changes. - -## Client Implementation - -### Calling Tools as Tasks - -To execute a tool as a task, include the `Task` property in the request: -```csharp -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; - -var client = await McpClient.CreateAsync(transport); - -// Call tool with task augmentation -var result = await client.CallToolAsync( - new CallToolRequestParams + var taskId = await StartBackgroundWorkAsync(context.Params!, ct); + return new CreateTaskResult { - Name = "processDataset", - Arguments = new Dictionary - { - ["recordCount"] = JsonSerializer.SerializeToElement(1000) - }, - Task = new McpTaskMetadata - { - TimeToLive = TimeSpan.FromHours(2) // Request 2-hour retention - } - }, - cancellationToken); - -// Check if a task was created -if (result.Task != null) -{ - Console.WriteLine($"Task created: {result.Task.TaskId}"); - Console.WriteLine($"Status: {result.Task.Status}"); -} + TaskId = taskId, + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 1000, + }; +}; ``` -### Polling for Task Status - -Use to check task status: - -```csharp -var task = await client.GetTaskAsync(taskId, cancellationToken: cancellationToken); -Console.WriteLine($"Status: {task.Status}"); -Console.WriteLine($"Last Updated: {task.LastUpdatedAt}"); - -if (task.StatusMessage != null) -{ - Console.WriteLine($"Message: {task.StatusMessage}"); -} -``` +> +> and +> are mutually exclusive. Setting one while the other is already non-null throws +> `InvalidOperationException` at the property setter. -### Waiting for Completion +#### Task scope for server-initiated requests -The SDK provides helper methods for polling until a task completes: +When you start background work from a custom +(rather than the SDK's auto-wrapping), use +to route elicitation, sampling, and `roots/list` calls through the task store as input requests +instead of direct JSON-RPC messages: ```csharp -// Poll until task reaches terminal state -var completedTask = await client.PollTaskUntilCompleteAsync( - taskId, - cancellationToken: cancellationToken); - -if (completedTask.Status == McpTaskStatus.Completed) -{ - // Get the result as raw JSON - var resultJson = await client.GetTaskResultAsync( - taskId, - cancellationToken: cancellationToken); - - // Deserialize to the expected type - var result = resultJson.Deserialize(McpJsonUtilities.DefaultOptions); - - foreach (var content in result?.Content ?? []) - { - if (content is TextContentBlock text) - { - Console.WriteLine(text.Text); - } - } -} -else if (completedTask.Status == McpTaskStatus.Failed) +using (server.CreateMcpTaskScope(taskId, taskStore)) { - Console.WriteLine($"Task failed: {completedTask.StatusMessage}"); + // ElicitAsync/SampleAsync/RequestRootsAsync calls in here are surfaced as + // entries in the task's inputRequests, then await client responses via tasks/update. + var elicit = await server.ElicitAsync(elicitParams, ct); } ``` -### Listing Tasks +`CreateMcpTaskScope` returns an `IDisposable` that restores the prior ambient context on +`Dispose`. The scope is established automatically for `[McpServerTool]` methods that run via +`McpServerOptions.TaskStore`, so this API is only needed for custom handlers. -List all tasks for the current session: +### Client usage -```csharp -var tasks = await client.ListTasksAsync(cancellationToken: cancellationToken); - -foreach (var task in tasks) -{ - Console.WriteLine($"{task.TaskId}: {task.Status}"); -} -``` +#### Automatic polling -### Cancelling Tasks + +handles the full task lifecycle automatically: -Cancel a running task: +- Injects the `io.modelcontextprotocol/tasks` extension capability into the request's `_meta`. +- Polls `tasks/get` at the cadence the server suggests via `pollIntervalMs`. +- Dispatches input requests through the client's registered handlers + ( and + ). +- Deduplicates already-resolved input request keys across polls so each request is handled at + most once. +- Returns the final when the task completes, + or throws on `Failed`/`Cancelled`. ```csharp -var cancelledTask = await client.CancelTaskAsync( - taskId, - cancellationToken: cancellationToken); - -Console.WriteLine($"Task status: {cancelledTask.Status}"); // Cancelled -``` - -### Handling Status Notifications - -Register a handler to receive real-time status updates: - -```csharp -var options = new McpClientOptions -{ - Handlers = new McpClientHandlers - { - TaskStatusHandler = (task, cancellationToken) => - { - Console.WriteLine($"Task {task.TaskId} status changed to {task.Status}"); - return ValueTask.CompletedTask; - } - } -}; - -var client = await McpClient.CreateAsync(transport, options); +var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments }, + cancellationToken); ``` - -> [!NOTE] -> Clients should not rely on receiving status notifications. Notifications are optional and may not be sent in all scenarios. Always use polling as the primary mechanism for tracking task status. - -## Implementing a Custom Task Store +#### Manual control -For production deployments, implement with a persistent backing store: +Use to receive the raw + without auto-polling, then drive the +lifecycle yourself using , +, and +: ```csharp -public class DatabaseTaskStore : IMcpTaskStore +var raw = await client.CallToolRawAsync(requestParams, cancellationToken); +if (raw.IsTask) { - private readonly IDbConnection _db; - - public DatabaseTaskStore(IDbConnection db) => _db = db; - - public async Task CreateTaskAsync( - McpTaskMetadata taskMetadata, - RequestId requestId, - JsonRpcRequest request, - string? sessionId, - CancellationToken cancellationToken) - { - var task = new McpTask - { - TaskId = Guid.NewGuid().ToString(), - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = taskMetadata.TimeToLive ?? TimeSpan.FromHours(1) - }; - - // Store in database - await _db.ExecuteAsync( - "INSERT INTO Tasks (TaskId, SessionId, Status, ...) VALUES (@TaskId, @SessionId, @Status, ...)", - new { task.TaskId, sessionId, task.Status, ... }); - - return task; - } - - public async Task GetTaskAsync( - string taskId, - string? sessionId, - CancellationToken cancellationToken) + var taskId = raw.TaskCreated!.TaskId; + while (true) { - // Retrieve from database with session isolation - return await _db.QuerySingleOrDefaultAsync( - "SELECT * FROM Tasks WHERE TaskId = @TaskId AND SessionId = @SessionId", - new { taskId, sessionId }); + await Task.Delay(TimeSpan.FromMilliseconds(raw.TaskCreated.PollIntervalMs ?? 1000), cancellationToken); + var state = await client.GetTaskAsync(taskId, cancellationToken); + // Handle InputRequiredTaskResult by calling UpdateTaskAsync, + // CompletedTaskResult by deserializing its Result property, etc. } - - // Implement other interface methods... } ``` -### Task Store Best Practices - -1. **Session Isolation**: Always filter tasks by session ID to prevent cross-session access -2. **TTL Enforcement**: Implement background cleanup of expired tasks -3. **Thread Safety**: Ensure all operations are thread-safe for concurrent access -4. **Atomic Updates**: Use database transactions for status transitions -5. **Optimistic Concurrency**: Prevent lost updates with version checking or row locks - -## Error Handling - -Task operations may throw with these error codes: - -| Error Code | Scenario | -|------------|----------| -| `InvalidParams` | Invalid or nonexistent task ID or invalid cursor | -| `InvalidParams` | Tool with `taskSupport: forbidden` called with task metadata, or tool with `taskSupport: required` called without task metadata | -| `InternalError` | Task execution failure or result unavailable | - -Example error handling: +#### Stuck-task detector + +`CallToolAsync` includes a safety net for misbehaving servers: if the task stays in + across many consecutive polls +without exposing any new input request keys (i.e. every previously requested input has already +been resolved by the client and yet the server keeps returning `InputRequired`), the client +gives up, issues a best-effort `tasks/cancel`, and throws +. This guards against a server that never transitions +out of `InputRequired` and prevents an unbounded poll loop. + +The threshold defaults to `60` consecutive stuck polls and is configurable via +. The effective +wall-clock timeout is roughly `MaxConsecutiveStuckPolls * pollIntervalMs`, so tune the option +with the server-side poll cadence in mind. Setting it too low risks false positives for servers +that are slow to surface follow-up input requests; setting it too high can mask misbehaving +servers. + +### Input requests (multi-round-trip) + +When a task needs additional input from the client, the server transitions it to + and returns the outstanding +requests in . Each +entry is an arbitrary key paired with a `{ method, params }` envelope representing an +equivalent standalone server-to-client request. The client provides answers via +, keyed by the same identifiers. + +Supported input request methods: + +| Method | Dispatched to the client handler | +| --- | --- | +| `elicitation/create` | | +| `sampling/createMessage` | | + +Per SEP-2663: + +- Each input request key **must** be unique over the lifetime of the task. +- Clients **should** deduplicate keys across polls so a request is only presented to the user + or model once. `CallToolAsync` does this automatically. +- Servers **should** ignore `inputResponses` entries whose key does not currently correspond to + an outstanding request, including responses for terminal-state tasks. + follows this rule. + +### Implementing a custom task store + +Implement for production scenarios. Key +requirements drawn from the SEP and the SDK contract: + +1. **Thread safety** — every method may be called concurrently. +2. **Idempotent terminal transitions** — + , + , and + must be no-ops on a task + that is already in a terminal state so a late cancellation cannot overwrite a result. +3. **`InputResponseReceived` event** — after persisting an input response inside + , raise + + for each resolved entry. This is the only mechanism that wakes a pending + `server.ElicitAsync`/`server.SampleAsync` call waiting inside a task scope. In distributed + deployments where a different server instance receives the `tasks/update`, the event must + be propagated to the originating server (for example via Redis pub/sub, SignalR, or a custom + transport). +4. **Strong-consistency on `CreateTaskAsync`** — + must not return until the + task is durably persisted, so that a subsequent + with the returned task ID + resolves immediately — even from a different process or node. Stores backed by + eventually-consistent storage must wait for the write to become visible (quorum + acknowledgement, write-through, etc.) before returning. Required by SEP-2663 §306. +5. **Singleton under stateless HTTP** — when the server runs in stateless mode (each request + spins up a fresh server instance), the same `IMcpTaskStore` instance must be shared across + requests — either by registering it as a singleton in DI, or by backing it with external + storage that every instance can reach. Otherwise `tasks/get` polls from subsequent requests + will see an empty in-memory store and never find the task. ```csharp -try -{ - var task = await client.GetTaskAsync(taskId, cancellationToken: ct); -} -catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.InvalidParams) +public sealed class MyTaskStore : IMcpTaskStore { - Console.WriteLine($"Task not found: {taskId}"); -} -``` - -## Complete Example - - - -See the [LongRunningTasks sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/LongRunningTasks) for a complete working example demonstrating: - - -- Server setup with a file-based `IMcpTaskStore` for durability -- Explicit task creation via `IMcpTaskStore` in tools returning `McpTask` -- Task polling and result retrieval across server restarts -- Cancellation support - -## Fault-Tolerant Task Implementations - -The default `InMemoryMcpTaskStore` and automatic task support for async tools are convenient for development, but they provide no durability or fault tolerance. When the server process terminates—whether due to a crash, deployment, or scaling event—all task state and in-flight computations are lost. - -### Why Fault Tolerance Requires External Systems - -True fault tolerance for long-running tasks requires two key capabilities that cannot be provided by an in-process solution: - -1. **Durable Task State**: Task metadata (ID, status, results) must survive process termination. This requires an external persistent store such as a database, Redis, or distributed cache. + public event Action? InputResponseReceived; -2. **Resumable Compute**: The actual work being performed must be executed by an external system that can continue running independently of the MCP server process—such as a job queue (Azure Service Bus, RabbitMQ), workflow engine (Temporal, Azure Durable Functions), or batch processing system (Azure Batch, Kubernetes Jobs). - -### Explicit Task Creation with `IMcpTaskStore` - -To implement fault-tolerant tasks, tools can directly interact with `IMcpTaskStore` and return an `McpTask` instead of relying on automatic task wrapping. This approach gives you full control over task lifecycle and enables integration with external compute fabrics: - -```csharp -[McpServerToolType] -public class FaultTolerantTools(IMcpTaskStore taskStore, IJobQueue jobQueue) -{ - [McpServerTool] - [Description("Submits a long-running job with fault-tolerant execution.")] - public async Task SubmitJob( - [Description("The job parameters")] string jobInput, - RequestContext context, - CancellationToken cancellationToken) + public async Task ResolveInputRequestsAsync( + string taskId, + IDictionary inputResponses, + CancellationToken cancellationToken = default) { - // 1. Create a task in the durable store - var task = await taskStore.CreateTaskAsync( - new McpTaskMetadata { TimeToLive = TimeSpan.FromHours(24) }, - context.JsonRpcRequest.Id!, - context.JsonRpcRequest, - context.Server.SessionId, - cancellationToken); - - // 2. Submit work to an external compute fabric - // The job queue handles execution independently of this process - await jobQueue.EnqueueAsync(new JobMessage - { - TaskId = task.TaskId, - SessionId = context.Server.SessionId, - Input = jobInput - }, cancellationToken); - - // 3. Return the task immediately - client will poll for completion - return task; - } -} -``` - -The external job processor updates the task store when work completes: + // 1. Atomically persist the resolved requests, ignoring keys that are no longer + // outstanding or that target a terminal task. + await PersistResolvedResponsesAsync(taskId, inputResponses, cancellationToken); -```csharp -// In a separate worker process or Azure Function -public class JobProcessor(IMcpTaskStore taskStore) -{ - public async Task ProcessJobAsync(JobMessage job, CancellationToken cancellationToken) - { - try - { - // Perform the actual long-running work - var result = await DoExpensiveWorkAsync(job.Input, cancellationToken); - - // Store the result in the durable task store - await taskStore.StoreTaskResultAsync( - job.TaskId, - McpTaskStatus.Completed, - JsonSerializer.SerializeToElement(new CallToolResult - { - Content = [new TextContentBlock { Text = result }] - }), - job.SessionId, - cancellationToken); - } - catch (Exception ex) + // 2. Then notify subscribers so any awaiting server.ElicitAsync/SampleAsync resumes. + foreach (var kvp in inputResponses) { - // Mark task as failed - await taskStore.StoreTaskResultAsync( - job.TaskId, - McpTaskStatus.Failed, - JsonSerializer.SerializeToElement(new CallToolResult - { - Content = [new TextContentBlock { Text = ex.Message }], - IsError = true - }), - job.SessionId, - cancellationToken); + InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs + { + TaskId = taskId, + RequestId = kvp.Key, + Response = kvp.Value, + }); } } -} -``` - -### Simplified Example: File-Based Task Store - - - -The [LongRunningTasks sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/LongRunningTasks) demonstrates a simplified fault-tolerant approach using the file system. The `FileBasedMcpTaskStore` persists task state to disk, allowing tasks to survive server restarts: - - -```csharp -// Use a file-based task store for durability -var taskStorePath = Path.Combine(Path.GetTempPath(), "mcp-tasks"); -var taskStore = new FileBasedMcpTaskStore(taskStorePath); - -builder.Services.AddMcpServer(options => -{ - options.TaskStore = taskStore; -}) -.WithHttpTransport(o => o.Stateless = true) -.WithTools(); -``` - -The sample's tool returns an `McpTask` directly by calling `CreateTaskAsync`: -```csharp -[McpServerToolType] -public class TaskTools(IMcpTaskStore taskStore) -{ - [McpServerTool] - [Description("Submits a job and returns a task that can be polled for completion.")] - public async Task SubmitJob( - [Description("A label for the job")] string jobName, - RequestContext context, - CancellationToken cancellationToken) - { - return await taskStore.CreateTaskAsync( - new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }, - context.JsonRpcRequest.Id!, - context.JsonRpcRequest, - context.Server.SessionId, - cancellationToken); - } + // … other IMcpTaskStore members } ``` -While this file-based approach demonstrates the pattern, production systems should use proper distributed storage and compute infrastructure for true fault tolerance and scalability. - -## See Also - -- -- -- -- -- [MCP Tasks Specification](https://modelcontextprotocol.io/seps/1686-tasks) +### Status semantics + + is the terminal status whenever the +underlying request produced its standard result, *including a + with `IsError = true`*. Per SEP-2663, +tool-level error results are not promoted to `Failed`. + + is reserved for JSON-RPC protocol-level +errors during execution — for example, a malformed request, or an unhandled exception in a custom +handler that the SDK converts to a JSON-RPC error. Use + for +domain-level errors the model should see. + +### Cancellation semantics + +Per SEP-2663, `tasks/cancel` is **eventually consistent and cooperative**: the server acknowledges +the request immediately, but is not required to actually stop the work or to transition to +`Cancelled`. The notifications-cancelled mechanism (used for plain JSON-RPC requests) is not used +for task cancellation; clients must use `tasks/cancel`. + +In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore`: + +1. The store's `SetCancelledAsync` transitions the task to `Cancelled` (a no-op if the task is + already terminal). +2. The associated `CancellationTokenSource` is signaled, propagating cancellation to the tool's + `CancellationToken` so cooperative cleanup can run. +3. Whichever side (the cancel handler or the background runner's `finally` block) wins + `TryRemove` on the cancellation source owns disposal, avoiding `ObjectDisposedException`. + +### Architecture notes + +#### Immutable store design + + uses immutable record snapshots with +compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are +exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state. + +#### Capability bypass inside a task scope + +When `server.ElicitAsync`/`server.SampleAsync`/`server.RequestRootsAsync` execute inside a task +scope, the SDK intentionally skips the normal client-capability negotiation checks +(`ThrowIfElicitationUnsupported`, etc.). The tasks extension itself is the negotiated capability: +the client opted in by including the extension marker in the originating request, so it is +responsible for handling — or rejecting — the input requests surfaced through `tasks/get`. + +### Known limitations + +- **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on + polling exclusively. +- **Lazy task creation**: when a tool runs through `TaskStore`, the store's + is invoked eagerly before + the inner handler runs, so tools that complete inline still incur a store write. There is + currently no built-in deferral. +- **Mid-execution promotion to task**: an `[McpServerTool]` method cannot start executing + synchronously and then transition its remaining work to a background task. Use a custom + + if you need that pattern. +- **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the + task channel when called from inside a task scope, but the client SDK does not currently + dispatch a handler for that method. Avoid calling `server.RequestRootsAsync` from within a + task scope until client-side support is added. +- **`ServerCapabilities.Extensions` round-trip**: the dictionary is typed as + `IDictionary` so its values cannot be deserialized by the source generator. + The negotiated extension surfaces correctly at the wire level, but round-tripping arbitrary + extension payloads in-process is not supported. diff --git a/docs/experimental.md b/docs/experimental.md index 1ad75a9b4..59a1d7579 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -26,8 +26,8 @@ Add the diagnostic ID to `` in your project file: Use `#pragma warning disable` around specific call sites: ```csharp -#pragma warning disable MCPEXP001 // The Tasks feature is experimental per the MCP specification and is subject to change. -tool.Execution = new ToolExecution { ... }; +#pragma warning disable MCPEXP001 // The Extensions feature is part of a future MCP specification version that has not yet been ratified and is subject to change. +capabilities.Extensions = new Dictionary { ... }; #pragma warning restore MCPEXP001 ``` @@ -67,4 +67,3 @@ By placing the SDK's resolver first, MCP types are serialized using the SDK's co - [Versioning](versioning.md) - [List of diagnostics](list-of-diagnostics.md#experimental-apis) -- [Tasks](concepts/tasks/tasks.md) (an experimental feature) diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 0ea6d746f..26a44bd78 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -23,7 +23,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | Diagnostic ID | Description | | :------------ | :---------- | -| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/seps/1686-tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | +| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | | `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). | ## Obsolete APIs diff --git a/docs/roadmap.md b/docs/roadmap.md index 81955a710..105a039d0 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 already has experimental support for [Tasks](concepts/tasks/tasks.md) (experimental in the specification), which will be updated as the specification is revised. +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). ### Feedback and End-to-End Scenarios diff --git a/samples/LongRunningTasks/FileBasedMcpTaskStore.cs b/samples/LongRunningTasks/FileBasedMcpTaskStore.cs deleted file mode 100644 index 55a6e77d5..000000000 --- a/samples/LongRunningTasks/FileBasedMcpTaskStore.cs +++ /dev/null @@ -1,393 +0,0 @@ -using ModelContextProtocol; -using ModelContextProtocol.Protocol; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace LongRunningTasks; - -/// -/// A minimal file-based implementation of that demonstrates -/// durable, fault-tolerant task storage using simple time-based completion. -/// -/// -/// -/// This implementation stores task data to disk: task ID, creation timestamp, execution duration, -/// session ID, TTL, and optional result. Task completion is determined by: -/// -/// Explicit completion or failure via -/// Explicit cancellation via -/// Time-based auto-completion when execution time has elapsed -/// -/// -/// -/// The file-based approach enables durability across process restarts - if the server -/// crashes and restarts, tasks can still be queried and will complete based on elapsed time. -/// -/// -public sealed partial class FileBasedMcpTaskStore : IMcpTaskStore -{ - private readonly string _storePath; - private readonly TimeSpan _executionTime; - - /// - /// Initializes a new instance of the class. - /// - /// The directory path where task files will be stored. - /// - /// The fixed execution time for all tasks. Tasks are reported as completed once this - /// duration has elapsed since creation. Defaults to 5 seconds. - /// - public FileBasedMcpTaskStore(string storePath, TimeSpan? executionTime = null) - { - _storePath = storePath ?? throw new ArgumentNullException(nameof(storePath)); - _executionTime = executionTime ?? TimeSpan.FromSeconds(5); - Directory.CreateDirectory(_storePath); - } - - /// - public async Task CreateTaskAsync( - McpTaskMetadata taskParams, - RequestId requestId, - JsonRpcRequest request, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - var taskId = Guid.NewGuid().ToString("N"); - var now = DateTimeOffset.UtcNow; - - var entry = new TaskFileEntry - { - TaskId = taskId, - SessionId = sessionId, - Status = McpTaskStatus.Working, - CreatedAt = now, - ExecutionTime = _executionTime, - TimeToLive = taskParams.TimeToLive, - Result = JsonSerializer.SerializeToElement(request.Params, JsonContext.Default.JsonNode) - }; - - await WriteTaskEntryAsync(GetTaskFilePath(taskId), entry); - - return ToMcpTask(entry); - } - - /// - public async Task GetTaskAsync( - string taskId, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - var entry = await ReadTaskEntryAsync(taskId); - if (entry is null) - { - return null; - } - - // Session isolation - if (sessionId is not null && entry.SessionId != sessionId) - { - return null; - } - - // Skip if TTL has expired - if (IsExpired(entry)) - { - return null; - } - - return ToMcpTask(entry); - } - - /// - public async Task StoreTaskResultAsync( - string taskId, - McpTaskStatus status, - JsonElement result, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - if (status is not (McpTaskStatus.Completed or McpTaskStatus.Failed)) - { - throw new ArgumentException( - $"Status must be {nameof(McpTaskStatus.Completed)} or {nameof(McpTaskStatus.Failed)}.", - nameof(status)); - } - - var updatedEntry = await UpdateTaskEntryAsync(taskId, sessionId, entry => - { - var effectiveStatus = GetEffectiveStatus(entry); - if (IsTerminalStatus(effectiveStatus)) - { - throw new InvalidOperationException( - $"Cannot store result for task in terminal state: {effectiveStatus}"); - } - - return entry with - { - Status = status, - Result = result - }; - }); - - return ToMcpTask(updatedEntry); - } - - /// - public async Task GetTaskResultAsync( - string taskId, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - var entry = await ReadTaskEntryAsync(taskId) - ?? throw new InvalidOperationException($"Task not found: {taskId}"); - - if (sessionId is not null && entry.SessionId != sessionId) - { - throw new InvalidOperationException($"Task not found: {taskId}"); - } - - var effectiveStatus = GetEffectiveStatus(entry); - if (!IsTerminalStatus(effectiveStatus)) - { - throw new InvalidOperationException($"Task not yet completed: {taskId}"); - } - - // Return stored result - return entry.Result ?? default; - } - - /// - public async Task UpdateTaskStatusAsync( - string taskId, - McpTaskStatus status, - string? statusMessage, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - var updatedEntry = await UpdateTaskEntryAsync(taskId, sessionId, entry => - entry with - { - Status = status, - StatusMessage = statusMessage - }); - - return ToMcpTask(updatedEntry); - } - - /// - public async Task ListTasksAsync( - string? cursor = null, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - var tasks = new List(); - - foreach (var file in Directory.EnumerateFiles(_storePath, "*.json")) - { - try - { - var entry = await ReadTaskEntryFromFileAsync(file); - if (entry is not null) - { - // Session isolation - if (sessionId is not null && entry.SessionId != sessionId) - { - continue; - } - - // Skip expired tasks - if (IsExpired(entry)) - { - continue; - } - - tasks.Add(ToMcpTask(entry)); - } - } - catch - { - // Skip corrupted or inaccessible files - } - } - - tasks.Sort((a, b) => a.CreatedAt.CompareTo(b.CreatedAt)); - - return new ListTasksResult { Tasks = [.. tasks] }; - } - - /// - public async Task CancelTaskAsync( - string taskId, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - var updatedEntry = await UpdateTaskEntryAsync(taskId, sessionId, entry => - { - var effectiveStatus = GetEffectiveStatus(entry); - if (IsTerminalStatus(effectiveStatus)) - { - // Already terminal, return unchanged - return entry; - } - - return entry with { Status = McpTaskStatus.Cancelled }; - }); - - return ToMcpTask(updatedEntry); - } - - private string GetTaskFilePath(string taskId) => Path.Combine(_storePath, $"{taskId}.json"); - - /// - /// Reads, transforms, and writes a task entry while holding an exclusive file lock. - /// - /// The task ID to update. - /// Optional session ID for access control. - /// A function that transforms the entry. May throw to abort the update. - /// The updated task entry. - private async Task UpdateTaskEntryAsync( - string taskId, - string? sessionId, - Func updateFunc) - { - var filePath = GetTaskFilePath(taskId); - - // Acquire exclusive lock on the file for the entire read-modify-write cycle - using var stream = await AcquireFileStreamAsync(filePath, FileMode.Open, FileAccess.ReadWrite); - - var entry = await JsonSerializer.DeserializeAsync(stream, JsonContext.Default.TaskFileEntry) - ?? throw new InvalidOperationException($"Task not found: {taskId}"); - - // Enforce session isolation - if (sessionId is not null && entry.SessionId != sessionId) - { - throw new InvalidOperationException($"Task not found: {taskId}"); - } - - // Apply the transformation (may throw to abort) - var updatedEntry = updateFunc(entry); - - // Write back to the same stream - stream.SetLength(0); - stream.Position = 0; - await JsonSerializer.SerializeAsync(stream, updatedEntry, JsonContext.Default.TaskFileEntry); - - return updatedEntry; - } - - private async Task ReadTaskEntryAsync(string taskId) - { - var filePath = GetTaskFilePath(taskId); - return File.Exists(filePath) ? await ReadTaskEntryFromFileAsync(filePath) : null; - } - - private static async Task ReadTaskEntryFromFileAsync(string filePath) - { - try - { - using var stream = await AcquireFileStreamAsync(filePath, FileMode.Open, FileAccess.Read); - return await JsonSerializer.DeserializeAsync(stream, JsonContext.Default.TaskFileEntry); - } - catch - { - return null; - } - } - - private static async Task WriteTaskEntryAsync(string filePath, TaskFileEntry entry) - { - using var stream = await AcquireFileStreamAsync(filePath, FileMode.Create, FileAccess.Write); - await JsonSerializer.SerializeAsync(stream, entry, JsonContext.Default.TaskFileEntry); - } - - private static async Task AcquireFileStreamAsync(string filePath, FileMode fileMode, FileAccess fileAccess) - { - const int MaxRetries = 10; - const int RetryDelayMs = 50; - - for (int attempt = 0; ; attempt++) - { - try - { - return new FileStream(filePath, fileMode, fileAccess, FileShare.None); - } - catch (IOException) when (attempt < MaxRetries) - { - await Task.Delay(RetryDelayMs); // File is locked by another process, wait and retry - } - } - } - - private McpTask ToMcpTask(TaskFileEntry entry) - { - var now = DateTimeOffset.UtcNow; - return new McpTask - { - TaskId = entry.TaskId, - Status = GetEffectiveStatus(entry), - StatusMessage = entry.StatusMessage, - CreatedAt = entry.CreatedAt, - LastUpdatedAt = now, - TimeToLive = entry.TimeToLive - }; - } - - private static McpTaskStatus GetEffectiveStatus(TaskFileEntry entry) - { - // If already in a terminal state, return it - if (IsTerminalStatus(entry.Status)) - { - return entry.Status; - } - - // Check if execution time has elapsed - auto-complete - if (DateTimeOffset.UtcNow - entry.CreatedAt >= entry.ExecutionTime) - { - return McpTaskStatus.Completed; - } - - return entry.Status; - } - - private static bool IsTerminalStatus(McpTaskStatus status) => - status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; - - private static bool IsExpired(TaskFileEntry entry) => - entry.TimeToLive.HasValue && DateTimeOffset.UtcNow - entry.CreatedAt > entry.TimeToLive.Value; - - /// - /// Represents the data stored for each task. - /// - private sealed record TaskFileEntry - { - /// The unique task identifier. - public required string TaskId { get; init; } - - /// The session that created this task. - public string? SessionId { get; init; } - - /// The current task status. - public required McpTaskStatus Status { get; init; } - - /// Optional status message describing the current state. - public string? StatusMessage { get; init; } - - /// When the task was created. - public required DateTimeOffset CreatedAt { get; init; } - - /// How long until the task is considered complete (if not explicitly completed). - public required TimeSpan ExecutionTime { get; init; } - - /// Time to live - task is filtered out after this duration from creation. - public TimeSpan? TimeToLive { get; init; } - - /// The task result - initialized with request params, updated via StoreTaskResultAsync. - public JsonElement? Result { get; init; } - } - - [JsonSourceGenerationOptions(WriteIndented = true)] - [JsonSerializable(typeof(TaskFileEntry))] - [JsonSerializable(typeof(JsonNode))] - private sealed partial class JsonContext : JsonSerializerContext; -} diff --git a/samples/LongRunningTasks/LongRunningTasks.csproj b/samples/LongRunningTasks/LongRunningTasks.csproj deleted file mode 100644 index ffe1fc716..000000000 --- a/samples/LongRunningTasks/LongRunningTasks.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net9.0 - enable - enable - $(NoWarn);MCPEXP001 - - - - - - - diff --git a/samples/LongRunningTasks/Program.cs b/samples/LongRunningTasks/Program.cs deleted file mode 100644 index ee9174554..000000000 --- a/samples/LongRunningTasks/Program.cs +++ /dev/null @@ -1,34 +0,0 @@ -// This sample demonstrates using a custom IMcpTaskStore implementation for -// durable task storage. The FileBasedMcpTaskStore persists tasks to disk, -// allowing them to survive server restarts. -// -// To test: -// 1. Start the server and call the SubmitJob tool -// 2. Poll the returned task using tasks/get -// 3. Optionally restart the server - the task will still be queryable - -using LongRunningTasks; -using LongRunningTasks.Tools; - -var builder = WebApplication.CreateBuilder(args); - -// Use a file-based task store for persistence across server restarts. -// Tasks survive server restarts and can be resumed or queried after a crash. -var taskStorePath = Path.Combine(Path.GetTempPath(), "mcp-tasks"); -var taskStore = new FileBasedMcpTaskStore(taskStorePath); - -builder.Services.AddMcpServer(options => -{ - options.TaskStore = taskStore; - options.ServerInfo = new() - { - Name = "LongRunningTasksServer", - Version = "1.0.0" - }; -}) -.WithHttpTransport(o => o.Stateless = true) -.WithTools(); - -var app = builder.Build(); -app.MapMcp(); -app.Run(); diff --git a/samples/LongRunningTasks/Properties/launchSettings.json b/samples/LongRunningTasks/Properties/launchSettings.json deleted file mode 100644 index 9a7c84f4b..000000000 --- a/samples/LongRunningTasks/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "LongRunningTasks": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:60964;http://localhost:60965" - } - } -} \ No newline at end of file diff --git a/samples/LongRunningTasks/README.md b/samples/LongRunningTasks/README.md deleted file mode 100644 index 71130e44a..000000000 --- a/samples/LongRunningTasks/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Long-Running Tasks Sample - -This sample demonstrates **explicit task handling** in MCP servers using the `IMcpTaskStore` interface directly. Unlike implicit task handling (where the server framework manages tasks automatically), this approach gives you full control over task lifecycle. \ No newline at end of file diff --git a/samples/LongRunningTasks/Tools/TaskTools.cs b/samples/LongRunningTasks/Tools/TaskTools.cs deleted file mode 100644 index 30eb43335..000000000 --- a/samples/LongRunningTasks/Tools/TaskTools.cs +++ /dev/null @@ -1,31 +0,0 @@ -using ModelContextProtocol; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.ComponentModel; - -namespace LongRunningTasks.Tools; - -/// -/// Demonstrates creating and returning tasks via . -/// -[McpServerToolType] -public class TaskTools(IMcpTaskStore taskStore) -{ - /// - /// Submits a job to the task store and returns a task handle for polling. - /// - [McpServerTool] - [Description("Submits a job and returns a task that can be polled for completion.")] - public Task SubmitJob( - [Description("A label for the job")] string jobName, - RequestContext context, - CancellationToken cancellationToken) - { - return taskStore.CreateTaskAsync( - new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }, - context.JsonRpcRequest.Id!, - context.JsonRpcRequest, - context.Server.SessionId, - cancellationToken); - } -} diff --git a/samples/LongRunningTasks/appsettings.json b/samples/LongRunningTasks/appsettings.json deleted file mode 100644 index 757d8426e..000000000 --- a/samples/LongRunningTasks/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "localhost;127.0.0.1;[::1]" -} diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs new file mode 100644 index 000000000..fff353307 --- /dev/null +++ b/samples/TasksExtension/Program.cs @@ -0,0 +1,109 @@ +// Demonstrates the MCP tasks extension (SEP-2663): +// +// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation +// becomes a background task when the client opts in via the per-request _meta marker. +// - The client invokes the same tool two ways: +// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final +// CallToolResult, just like a synchronous call. +// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls, +// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream +// status updates rather than block on a single await. +// +// Both server and client are wired together in-process over an in-memory pipe so the sample +// is self-contained — no separate server process or HTTP transport required. + +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.IO.Pipelines; +using System.Text.Json; + +Pipe clientToServerPipe = new(), serverToClientPipe = new(); + +await using McpServer server = McpServer.Create( + new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), + new McpServerOptions + { + // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be + // automatically wrapped as background tasks when the client opts in. + TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }, + ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], + }); +_ = server.RunAsync(); + +await using McpClient client = await McpClient.CreateAsync( + new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); + +Console.WriteLine("=== CallToolAsync (auto-poll) ==="); +var auto = await client.CallToolAsync( + new CallToolRequestParams { Name = "run-report" }); +Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}"); +Console.WriteLine(); + +Console.WriteLine("=== CallToolRawAsync (manual poll) ==="); +var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" }); +if (!raw.IsTask) +{ + // Either the server doesn't advertise the tasks extension or it chose to run the call + // synchronously despite the client opt-in. Surface the inline result and stop. + Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}"); + return; +} + +CreateTaskResult created = raw.TaskCreated!; +Console.WriteLine($" task created: id={created.TaskId} status={created.Status} pollIntervalMs={created.PollIntervalMs}"); + +long pollIntervalMs = created.PollIntervalMs ?? 1000; +int pollCount = 0; +while (true) +{ + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs)); + pollCount++; + + var state = await client.GetTaskAsync(created.TaskId); + if (state.PollIntervalMs is { } updated) + { + pollIntervalMs = updated; + } + + switch (state) + { + case CompletedTaskResult completed: + // The Result property carries the wrapped CallToolResult as a raw JsonElement. + var callToolResult = completed.Result.Deserialize()!; + Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}"); + return; + + case FailedTaskResult failed: + Console.WriteLine($" task failed: {failed.Error}"); + return; + + case CancelledTaskResult: + Console.WriteLine(" task was cancelled"); + return; + + case WorkingTaskResult: + Console.WriteLine($" poll {pollCount}: still working …"); + continue; + + case InputRequiredTaskResult inputRequired: + // The auto-poll path (CallToolAsync above) routes these through the registered + // ElicitationHandler/SamplingHandler automatically. The manual path needs to call + // UpdateTaskAsync with responses for each outstanding key. + Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))"); + continue; + } +} + +internal static class SlowTools +{ + [Description("Runs a short simulated report and returns when it's done.")] + public static async Task RunReport(CancellationToken cancellationToken) + { + // Real-world workloads would do meaningful work here; we just sleep so the polling + // path is observable in the console output. + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + return "report ready"; + } +} diff --git a/samples/TasksExtension/README.md b/samples/TasksExtension/README.md new file mode 100644 index 000000000..f32506113 --- /dev/null +++ b/samples/TasksExtension/README.md @@ -0,0 +1,50 @@ +# Tasks Extension Sample + +Demonstrates the MCP tasks extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)) end-to-end in a single process. + +The server is configured with an in-memory `IMcpTaskStore`, which is sufficient to make any +`[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. + +Both ends of the conversation are connected in-process over an in-memory `Pipe`, so no separate +server process or HTTP transport is required. + +## Run + +```bash +dotnet run --project samples/TasksExtension/TasksExtension.csproj +``` + +Expected output: + +``` +=== CallToolAsync (auto-poll) === + result: report ready + +=== CallToolRawAsync (manual poll) === + task created: id=… status=Working pollIntervalMs=250 + poll 1: still working … + … + task completed after N poll(s): report ready +``` + +## Notes + +- The `MCPEXP001` warning is suppressed because the tasks extension is still experimental. The + project's `` already includes it; if you copy this pattern into your own project, + either suppress the diagnostic or wrap the experimental APIs in + `#pragma warning disable MCPEXP001`. +- For production deployments — especially stateless HTTP servers — implement + `IMcpTaskStore` against durable storage and register it as a singleton (see + [docs/concepts/tasks/tasks.md](../../docs/concepts/tasks/tasks.md) for the contract). diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj new file mode 100644 index 000000000..2f66badd7 --- /dev/null +++ b/samples/TasksExtension/TasksExtension.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + $(NoWarn);MCPEXP001 + + + + + + + diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index e356480ed..411acd989 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol; /// /// /// MCPEXP001 covers APIs related to experimental features in the MCP specification itself, -/// such as Tasks and Extensions. These APIs may change as the specification evolves. +/// such as Extensions. These APIs may change as the specification evolves. /// /// /// MCPEXP002 covers experimental SDK APIs that are unrelated to the MCP specification, @@ -35,30 +35,9 @@ namespace ModelContextProtocol; /// internal static class Experimentals { - /// - /// Diagnostic ID for the experimental MCP Tasks feature. - /// - public const string Tasks_DiagnosticId = "MCPEXP001"; - - /// - /// Message for the experimental MCP Tasks feature. - /// - public const string Tasks_Message = "The Tasks feature is experimental per the MCP specification and is subject to change."; - - /// - /// URL for the experimental MCP Tasks feature. - /// - public const string Tasks_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001"; - /// /// Diagnostic ID for the experimental MCP Extensions feature. /// - /// - /// This uses the same diagnostic ID as because both - /// Tasks and Extensions are covered by the same MCPEXP001 diagnostic for experimental - /// MCP features. Having separate constants improves code clarity while maintaining a - /// single diagnostic suppression point. - /// public const string Extensions_DiagnosticId = "MCPEXP001"; /// @@ -115,7 +94,7 @@ internal static class Experimentals /// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature. /// /// - /// This uses the same diagnostic ID as because MRTR + /// This uses the same diagnostic ID as because MRTR /// is an experimental feature in the MCP specification (SEP-2322). /// public const string Mrtr_DiagnosticId = "MCPEXP001"; diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 79d4b0a02..6ad56bcef 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Diagnostics; @@ -887,6 +887,13 @@ public Task UnsubscribeFromResourceAsync( /// The from the tool execution. /// is . /// The request failed or the server returned an error response. + /// + /// This overload supports the tasks extension transparently. If the server responds with a + /// task handle rather than an immediate result, this method polls tasks/get until the + /// task completes, dispatching any entries through + /// the client's registered sampling and elicitation handlers along the way. Use + /// to disable automatic polling. + /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, @@ -957,394 +964,368 @@ async ValueTask SendRequestWithProgressAsync( /// The result of the request. /// is . /// The request failed or the server returned an error response. - public ValueTask CallToolAsync( + /// + /// This method automatically includes the io.modelcontextprotocol/tasks extension capability + /// in the request metadata. If the server returns a task handle instead of an immediate result, + /// this method transparently polls tasks/get until the task completes, fails, or is cancelled. + /// Use + /// to receive the raw without automatic polling. + /// + public async ValueTask CallToolAsync( CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - return SendRequestAsync( - RequestMethods.ToolsCall, - requestParams, - McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult, - cancellationToken: cancellationToken); + var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); + + if (!augmented.IsTask) + { + return augmented.Result!; + } + + return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false); } /// - /// Invokes a tool on the server as a task for long-running operations. + /// Polls a task until it reaches a terminal state and returns the final . /// - /// The name of the tool to call on the server. - /// An optional dictionary of arguments to pass to the tool. - /// Metadata for task augmentation, including optional TTL. If , an empty metadata is used. - /// An optional progress reporter for server notifications. - /// Optional request options including metadata, serialization settings, and progress tracking. - /// The to monitor for cancellation requests. The default is . - /// - /// An representing the created task. Use to poll for status updates - /// and to retrieve the final result. - /// - /// is . - /// The request failed or the server returned an error response. - /// - /// - /// Task-augmented tool calls allow long-running operations to be executed asynchronously. Instead of blocking - /// until the tool completes, the server immediately returns a task identifier that can be used to poll for - /// status updates and retrieve the final result. - /// - /// - /// The server must advertise task support via capabilities.tasks.requests.tools.call and the tool - /// must have execution.taskSupport set to "optional" or "required". - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public ValueTask CallToolAsTaskAsync( - string toolName, - IReadOnlyDictionary? arguments = null, - McpTaskMetadata? taskMetadata = null, - IProgress? progress = null, - RequestOptions? options = null, - CancellationToken cancellationToken = default) + private async ValueTask PollTaskToCompletionAsync( + CreateTaskResult taskCreated, + CancellationToken cancellationToken) { - Throw.IfNull(toolName); - - var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; - serializerOptions.MakeReadOnly(); - - if (progress is null) + // If the server claims InputRequired but never publishes new input requests after we have + // already responded to everything it asked for, treat that as a stuck task. The client + // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll + // loop when the server is misbehaving. The threshold is configurable via + // McpClientOptions.MaxConsecutiveStuckPolls. + int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls; + + string taskId = taskCreated.TaskId; + long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; + HashSet? resolvedRequestKeys = null; + bool isFirstPoll = true; + int consecutiveStuckPolls = 0; + + while (true) { - return SendTaskAugmentedCallToolRequestAsync(toolName, arguments, taskMetadata, options?.GetMetaForRequest(), serializerOptions, cancellationToken); - } + // Skip the delay before the first poll: many tasks complete almost immediately and we + // don't want to pay the poll interval as gratuitous latency. + if (!isFirstPoll) + { + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); + } + isFirstPoll = false; - return SendTaskAugmentedCallToolRequestWithProgressAsync(toolName, arguments, taskMetadata, progress, options?.GetMetaForRequest(), serializerOptions, cancellationToken); + var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - async ValueTask SendTaskAugmentedCallToolRequestAsync( - string toolName, - IReadOnlyDictionary? arguments, - McpTaskMetadata? taskMetadata, - JsonObject? meta, - JsonSerializerOptions serializerOptions, - CancellationToken cancellationToken) - { - var result = await SendRequestAsync( - RequestMethods.ToolsCall, - new CallToolRequestParams - { - Name = toolName, - Arguments = ToArgumentsDictionary(arguments, serializerOptions), - Meta = meta, - Task = taskMetadata ?? new McpTaskMetadata(), - }, - McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CreateTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); + // Update poll interval if the server changed it. + if (taskResult.PollIntervalMs is { } newInterval) + { + pollIntervalMs = newInterval; + } - return result.Task; - } + switch (taskResult) + { + case CompletedTaskResult completed: + return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) + ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); - async ValueTask SendTaskAugmentedCallToolRequestWithProgressAsync( - string toolName, - IReadOnlyDictionary? arguments, - McpTaskMetadata? taskMetadata, - IProgress progress, - JsonObject? meta, - JsonSerializerOptions serializerOptions, - CancellationToken cancellationToken) - { - ProgressToken progressToken = new(Guid.NewGuid().ToString("N")); + case FailedTaskResult failed: + throw new McpException($"Task '{taskId}' failed: {failed.Error}"); - await using var _ = RegisterNotificationHandler(NotificationMethods.ProgressNotification, - (notification, cancellationToken) => - { - if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams) is { } pn && - pn.ProgressToken == progressToken) + case CancelledTaskResult: + throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); + + case InputRequiredTaskResult inputRequired: + // Dedup: only resolve input requests we haven't already responded to. + var newRequests = new Dictionary(); + if (inputRequired.InputRequests is { } incomingRequests) { - progress.Report(pn.Progress); + foreach (var kvp in incomingRequests) + { + if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) + { + newRequests[kvp.Key] = kvp.Value; + } + } } - return default; - }).ConfigureAwait(false); + if (newRequests.Count > 0) + { + consecutiveStuckPolls = 0; + + IDictionary inputResponses; + try + { + inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // The input handler failed (e.g., ElicitationHandler threw or no handler was registered). + // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired + // until TTL expires. + try + { + await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Swallow secondary failures; we're already propagating the original exception. + } + + throw; + } + + await UpdateTaskAsync(new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, cancellationToken).ConfigureAwait(false); + + resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); + foreach (var key in inputResponses.Keys) + { + resolvedRequestKeys.Add(key); + } + } + else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) + { + // Best-effort cancel of the server-side task so it doesn't leak until TTL expires. + try + { + await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Swallow secondary failures; we're already propagating an exception. + } + + throw new McpException( + $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + + "without publishing new input requests after all previously requested inputs were resolved."); + } - JsonObject metaWithProgress = meta is not null ? (JsonObject)meta.DeepClone() : []; - metaWithProgress["progressToken"] = progressToken.ToString(); + break; - var result = await SendRequestAsync( - RequestMethods.ToolsCall, - new CallToolRequestParams - { - Name = toolName, - Arguments = ToArgumentsDictionary(arguments, serializerOptions), - Meta = metaWithProgress, - Task = taskMetadata ?? new McpTaskMetadata(), - }, - McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CreateTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); + case WorkingTaskResult: + // Continue polling. + consecutiveStuckPolls = 0; + break; - return result.Task; + default: + throw new McpException( + $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); + } } } /// - /// Retrieves the current state of a specific task from the server. + /// Invokes a tool on the server with task extension support, returning the raw response + /// without automatic polling. The caller is responsible for handling task lifecycle. /// - /// The unique identifier of the task to retrieve. - /// Optional request options including metadata, serialization settings, and progress tracking. + /// The request parameters to send. The tasks extension capability will be injected into the request metadata. /// The to monitor for cancellation requests. The default is . - /// The current state of the task. - /// is . - /// is empty or composed entirely of whitespace. + /// A that is either an immediate result or a task handle. + /// is . /// The request failed or the server returned an error response. - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask GetTaskAsync( - string taskId, - RequestOptions? options = null, + /// + /// + /// Unlike , this method does not + /// automatically poll for task completion. If the server returns a , + /// the caller must manage polling via . + /// + /// + public async ValueTask> CallToolRawAsync( + CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { - Throw.IfNullOrWhiteSpace(taskId); + Throw.IfNull(requestParams); - var result = await SendRequestAsync( - RequestMethods.TasksGet, - new GetTaskRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() }, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); + var paramsWithMeta = new CallToolRequestParams + { + Name = requestParams.Name, + Arguments = requestParams.Arguments, + Meta = GetMetaWithTaskCapability(requestParams.Meta), + }; - // Convert GetTaskResult to McpTask - return new McpTask + JsonRpcRequest jsonRpcRequest = new() { - TaskId = result.TaskId, - Status = result.Status, - StatusMessage = result.StatusMessage, - CreatedAt = result.CreatedAt, - LastUpdatedAt = result.LastUpdatedAt, - TimeToLive = result.TimeToLive, - PollInterval = result.PollInterval + Method = RequestMethods.ToolsCall, + Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams), }; + + JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + // Discriminate based on resultType field. + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() == "task") + { + var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult) + ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); + return new ResultOrCreatedTask(taskCreated); + } + + var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) + ?? throw new JsonException("Failed to deserialize CallToolResult from response."); + return new ResultOrCreatedTask(callToolResult); } /// - /// Retrieves the result of a completed task, blocking until the task reaches a terminal state. + /// Sets the logging level for the server to control which log messages are sent to the client. /// - /// The unique identifier of the task whose result to retrieve. + /// The minimum severity level of log messages to receive from the server. /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// The raw JSON result of the task. - /// is . - /// is empty or composed entirely of whitespace. + /// A task representing the asynchronous operation. /// The request failed or the server returned an error response. - /// - /// This method sends a tasks/result request to the server, which will block until the task completes if it hasn't already. - /// The server handles all polling logic internally. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public ValueTask GetTaskResultAsync( - string taskId, - RequestOptions? options = null, - CancellationToken cancellationToken = default) - { - Throw.IfNullOrWhiteSpace(taskId); - - return SendRequestAsync( - RequestMethods.TasksResult, - new GetTaskPayloadRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() }, - McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams, - McpJsonUtilities.JsonContext.Default.JsonElement, - cancellationToken: cancellationToken); - } + public Task SetLoggingLevelAsync(LogLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) => + SetLoggingLevelAsync(McpServerImpl.ToLoggingLevel(level), options, cancellationToken); /// - /// Retrieves a list of all tasks from the server. + /// Sets the logging level for the server to control which log messages are sent to the client. /// + /// The minimum severity level of log messages to receive from the server. /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// A list of all tasks. + /// A task representing the asynchronous operation. /// The request failed or the server returned an error response. - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask> ListTasksAsync( - RequestOptions? options = null, - CancellationToken cancellationToken = default) + public Task SetLoggingLevelAsync(LoggingLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) { - ListTasksRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() }; - List tasks = new(); - do - { - var taskResults = await ListTasksAsync(requestParams, cancellationToken).ConfigureAwait(false); - tasks.AddRange(taskResults.Tasks); - requestParams.Cursor = taskResults.NextCursor; - } - while (requestParams.Cursor is not null); - - return tasks; + return SetLoggingLevelAsync( + new SetLevelRequestParams + { + Level = level, + Meta = options?.GetMetaForRequest() + }, + cancellationToken); } /// - /// Retrieves a list of tasks from the server. + /// Sets the logging level for the server to control which log messages are sent to the client. /// /// The request parameters to send in the request. /// The to monitor for cancellation requests. The default is . - /// The result of the request as provided by the server. + /// The result of the request. /// is . /// The request failed or the server returned an error response. - /// - /// The overload retrieves all tasks by automatically handling pagination. - /// This overload works with the lower-level and , returning the raw result from the server. - /// Any pagination needs to be managed by the caller. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public ValueTask ListTasksAsync( - ListTasksRequestParams requestParams, + public Task SetLoggingLevelAsync( + SetLevelRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); return SendRequestAsync( - RequestMethods.TasksList, + RequestMethods.LoggingSetLevel, requestParams, - McpJsonUtilities.JsonContext.Default.ListTasksRequestParams, - McpJsonUtilities.JsonContext.Default.ListTasksResult, - cancellationToken: cancellationToken); + McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, + McpJsonUtilities.JsonContext.Default.EmptyResult, + cancellationToken: cancellationToken).AsTask(); } /// - /// Cancels a running task on the server. + /// Retrieves the current state of a task from the server. /// - /// The unique identifier of the task to cancel. - /// Optional request options including metadata, serialization settings, and progress tracking. + /// The stable identifier of the task to retrieve. /// The to monitor for cancellation requests. The default is . - /// The updated state of the task after cancellation. + /// A subtype representing the current task state. /// is . - /// is empty or composed entirely of whitespace. /// The request failed or the server returned an error response. - /// - /// Cancelling a task requests that the server stop execution. The server may not immediately cancel the task, - /// and may choose to allow the task to complete if it's close to finishing. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask CancelTaskAsync( + public ValueTask GetTaskAsync( string taskId, - RequestOptions? options = null, CancellationToken cancellationToken = default) { - Throw.IfNullOrWhiteSpace(taskId); - - var result = await SendRequestAsync( - RequestMethods.TasksCancel, - new CancelMcpTaskRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() }, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); + Throw.IfNull(taskId); - // Convert CancelMcpTaskResult to McpTask - return new McpTask - { - TaskId = result.TaskId, - Status = result.Status, - StatusMessage = result.StatusMessage, - CreatedAt = result.CreatedAt, - LastUpdatedAt = result.LastUpdatedAt, - TimeToLive = result.TimeToLive, - PollInterval = result.PollInterval - }; + return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); } /// - /// Polls a task until it reaches a terminal status (completed, failed, or cancelled). + /// Retrieves the current state of a task from the server. /// - /// The unique identifier of the task to poll. - /// Optional request options including metadata, serialization settings, and progress tracking. + /// The request parameters to send in the request. /// The to monitor for cancellation requests. The default is . - /// The task in its terminal state. - /// is . - /// is empty or composed entirely of whitespace. - /// - /// - /// This method repeatedly calls until the task reaches a terminal status. - /// It respects the returned by the server to determine how long - /// to wait between polling attempts. - /// - /// - /// For retrieving the actual result of a completed task, use . - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask PollTaskUntilCompleteAsync( - string taskId, - RequestOptions? options = null, + /// A subtype representing the current task state. + /// is . + /// The request failed or the server returned an error response. + public ValueTask GetTaskAsync( + GetTaskRequestParams requestParams, CancellationToken cancellationToken = default) { - Throw.IfNullOrWhiteSpace(taskId); - - McpTask task; - do - { - task = await GetTaskAsync(taskId, options, cancellationToken).ConfigureAwait(false); - - // If task is in a terminal state, we're done - if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) - { - break; - } - - // Wait for the poll interval before checking again (default to 1 second) - var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1); - await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false); - } - while (true); + Throw.IfNull(requestParams); - return task; + return SendRequestAsync( + RequestMethods.TasksGet, + requestParams, + McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, + McpJsonUtilities.JsonContext.Default.GetTaskResult, + cancellationToken: cancellationToken); } /// - /// Sets the logging level for the server to control which log messages are sent to the client. + /// Provides input responses to a task that is in the state. /// - /// The minimum severity level of log messages to receive from the server. - /// Optional request options including metadata, serialization settings, and progress tracking. + /// The request parameters containing the task ID and input responses. /// The to monitor for cancellation requests. The default is . - /// A task representing the asynchronous operation. + /// The result acknowledging the update. + /// is . /// The request failed or the server returned an error response. - public Task SetLoggingLevelAsync(LogLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) => - SetLoggingLevelAsync(McpServerImpl.ToLoggingLevel(level), options, cancellationToken); + public ValueTask UpdateTaskAsync( + UpdateTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { + Throw.IfNull(requestParams); + + return SendRequestAsync( + RequestMethods.TasksUpdate, + requestParams, + McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, + McpJsonUtilities.JsonContext.Default.UpdateTaskResult, + cancellationToken: cancellationToken); + } /// - /// Sets the logging level for the server to control which log messages are sent to the client. + /// Requests cancellation of an in-progress task on the server. /// - /// The minimum severity level of log messages to receive from the server. - /// Optional request options including metadata, serialization settings, and progress tracking. + /// The stable identifier of the task to cancel. /// The to monitor for cancellation requests. The default is . - /// A task representing the asynchronous operation. + /// The result acknowledging the cancellation request. + /// is . /// The request failed or the server returned an error response. - public Task SetLoggingLevelAsync(LoggingLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) + public ValueTask CancelTaskAsync( + string taskId, + CancellationToken cancellationToken = default) { - return SetLoggingLevelAsync( - new SetLevelRequestParams - { - Level = level, - Meta = options?.GetMetaForRequest() - }, - cancellationToken); + Throw.IfNull(taskId); + + return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); } /// - /// Sets the logging level for the server to control which log messages are sent to the client. + /// Requests cancellation of an in-progress task on the server. /// /// The request parameters to send in the request. /// The to monitor for cancellation requests. The default is . - /// The result of the request. + /// The result acknowledging the cancellation request. /// is . /// The request failed or the server returned an error response. - public Task SetLoggingLevelAsync( - SetLevelRequestParams requestParams, + public ValueTask CancelTaskAsync( + CancelTaskRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); return SendRequestAsync( - RequestMethods.LoggingSetLevel, + RequestMethods.TasksCancel, requestParams, - McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, - McpJsonUtilities.JsonContext.Default.EmptyResult, - cancellationToken: cancellationToken).AsTask(); + McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, + McpJsonUtilities.JsonContext.Default.CancelTaskResult, + cancellationToken: cancellationToken); } /// Converts a dictionary with values to a dictionary with values. @@ -1365,4 +1346,33 @@ public Task SetLoggingLevelAsync( return result; } + + // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: + // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} + // TODO: replace the literal with a shared NotificationMethods.ClientCapabilitiesMetaKey once + // the SEP-2575 plumbing lands and drop the local consts. + private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + private const string ExtensionsKey = "extensions"; + + private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) + { + JsonObject meta = existingMeta is not null + ? (JsonObject)existingMeta.DeepClone() + : []; + + if (meta[ClientCapabilitiesMetaKey] is not JsonObject capsRoot) + { + capsRoot = []; + meta[ClientCapabilitiesMetaKey] = capsRoot; + } + + if (capsRoot[ExtensionsKey] is not JsonObject extensionsRoot) + { + extensionsRoot = []; + capsRoot[ExtensionsKey] = extensionsRoot; + } + + extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); + return meta; + } } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index 96960db04..b238c59c3 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Client; @@ -71,6 +72,26 @@ protected McpClient() /// public abstract Task Completion { get; } + /// + /// Resolves input requests embedded in an by dispatching + /// each request to the appropriate registered handler. + /// + /// + /// The input requests from the task, keyed by request identifier. Each value is an + /// wrapping the server-to-client request payload. + /// + /// The to monitor for cancellation requests. + /// A dictionary of responses keyed by the same identifiers as the input requests. + private protected abstract ValueTask> ResolveInputRequestsAsync( + IDictionary inputRequests, CancellationToken cancellationToken); + + /// + /// Gets the maximum number of consecutive stuck-in- polls + /// allowed by before the client cancels and throws. + /// Sourced from . + /// + private protected abstract int MaxConsecutiveStuckPolls { get; } + /// /// Registers one or more tool definitions in the client's tool cache, enabling the transport /// to send Mcp-Param-* headers for those tools without requiring a prior call. diff --git a/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs b/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs index 2109555bc..0866e4aef 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs @@ -86,25 +86,4 @@ public sealed class McpClientHandlers /// /// public Func, CancellationToken, ValueTask>? SamplingHandler { get; set; } - - /// - /// Gets or sets the handler for processing notifications. - /// - /// - /// - /// This handler is called when the server sends a task status notification to inform the client - /// about changes to a task's state. These notifications are optional and clients MUST NOT rely - /// on receiving them. - /// - /// - /// The handler receives the updated object containing the current task state, - /// including its status, status message, and timestamps. - /// - /// - /// This handler is typically used to update UI or trigger actions based on task progress - /// without requiring explicit polling. - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public Func? TaskStatusHandler { get; set; } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index bc58794ad..894ca6945 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -23,7 +23,6 @@ internal sealed partial class McpClientImpl : McpClient private readonly McpClientOptions _options; private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); - private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _registeredToolNames = new(StringComparer.Ordinal); @@ -51,12 +50,6 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio _options = options; _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; - // Only allocate the cancellation token provider if a task store is configured - if (options.TaskStore is not null) - { - _taskCancellationTokenProvider = new(); - } - var notificationHandlers = new NotificationHandlers(); var requestHandlers = new RequestHandlers(); @@ -101,95 +94,26 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not var samplingHandler = handlers.SamplingHandler; var rootsHandler = handlers.RootsHandler; var elicitationHandler = handlers.ElicitationHandler; - var taskStatusHandler = handlers.TaskStatusHandler; - var taskStore = options.TaskStore; if (notificationHandlersFromOptions is not null) { notificationHandlers.RegisterRange(notificationHandlersFromOptions); } - if (taskStatusHandler is not null) - { - notificationHandlers.Register( - NotificationMethods.TaskStatusNotification, - (notification, cancellationToken) => - { - if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.McpTaskStatusNotificationParams) is { } notificationParams) - { - var task = new McpTask - { - TaskId = notificationParams.TaskId, - Status = notificationParams.Status, - StatusMessage = notificationParams.StatusMessage, - CreatedAt = notificationParams.CreatedAt, - LastUpdatedAt = notificationParams.LastUpdatedAt, - TimeToLive = notificationParams.TimeToLive, - PollInterval = notificationParams.PollInterval - }; - return taskStatusHandler(task, cancellationToken); - } - - return default; - }); - } - if (samplingHandler is not null) { - // If task store is configured, wrap the handler to support task-augmented requests - if (taskStore is not null) - { - requestHandlers.Set( - RequestMethods.SamplingCreateMessage, - async (request, jsonRpcRequest, cancellationToken) => - { - WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); - - // Check if this is a task-augmented request - if (request?.Task is { } taskMetadata) - { - // Create task in store and return immediately - return await ExecuteAsTaskAsync( - taskStore, - taskMetadata, - jsonRpcRequest, - async ct => - { - var result = await samplingHandler( - request, - request.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, - ct).ConfigureAwait(false); - return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CreateMessageResult); - }, - options.SendTaskStatusNotifications, - cancellationToken).ConfigureAwait(false); - } - - // Normal synchronous execution - serialize result to JsonElement - var samplingResult = await samplingHandler( - request, - request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, - cancellationToken).ConfigureAwait(false); - return JsonSerializer.SerializeToElement(samplingResult, McpJsonUtilities.JsonContext.Default.CreateMessageResult); - }, - McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, - McpJsonUtilities.JsonContext.Default.JsonElement); // Return JsonElement to support both CreateMessageResult and CreateTaskResult - } - else - { - requestHandlers.Set( - RequestMethods.SamplingCreateMessage, - (request, _, cancellationToken) => - { - WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); - return samplingHandler( - request, - request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, - cancellationToken); - }, - McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, - McpJsonUtilities.JsonContext.Default.CreateMessageResult); - } + requestHandlers.Set( + RequestMethods.SamplingCreateMessage, + (request, _, cancellationToken) => + { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); + return samplingHandler( + request, + request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, + cancellationToken); + }, + McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, + McpJsonUtilities.JsonContext.Default.CreateMessageResult); _options.Capabilities ??= new(); _options.Capabilities.Sampling ??= new(); @@ -213,54 +137,16 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not if (elicitationHandler is not null) { - // If task store is configured, wrap the handler to support task-augmented requests - if (taskStore is not null) - { - requestHandlers.Set( - RequestMethods.ElicitationCreate, - async (request, jsonRpcRequest, cancellationToken) => - { - WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); - - // Check if this is a task-augmented request - if (request?.Task is { } taskMetadata) - { - // Create task in store and return immediately - return await ExecuteAsTaskAsync( - taskStore, - taskMetadata, - jsonRpcRequest, - async ct => - { - var result = await elicitationHandler(request, ct).ConfigureAwait(false); - result = ElicitResult.WithDefaults(request, result); - return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult); - }, - options.SendTaskStatusNotifications, - cancellationToken).ConfigureAwait(false); - } - - // Normal synchronous execution - serialize result to JsonElement - var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false); - elicitResult = ElicitResult.WithDefaults(request, elicitResult); - return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult); - }, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.JsonElement); // Return JsonElement to support both ElicitResult and CreateTaskResult - } - else - { - requestHandlers.Set( - RequestMethods.ElicitationCreate, - async (request, _, cancellationToken) => - { - WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); - var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false); - return ElicitResult.WithDefaults(request, result); - }, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.ElicitResult); - } + requestHandlers.Set( + RequestMethods.ElicitationCreate, + async (request, _, cancellationToken) => + { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); + var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false); + return ElicitResult.WithDefaults(request, result); + }, + McpJsonUtilities.JsonContext.Default.ElicitRequestParams, + McpJsonUtilities.JsonContext.Default.ElicitResult); _options.Capabilities ??= new(); _options.Capabilities.Elicitation ??= new(); @@ -271,276 +157,6 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not _options.Capabilities.Elicitation.Form = new(); } } - - // Register task handlers if a task store is configured - if (taskStore is not null) - { - RegisterTaskHandlers(requestHandlers, taskStore); - } - } - - /// - /// Executes an operation as a task, creating the task immediately and running the operation asynchronously. - /// - private async ValueTask ExecuteAsTaskAsync( - IMcpTaskStore taskStore, - McpTaskMetadata taskMetadata, - JsonRpcRequest jsonRpcRequest, - Func> operation, - bool sendNotifications, - CancellationToken cancellationToken) - { - // Create the task in the store - var mcpTask = await taskStore.CreateTaskAsync( - taskMetadata, - jsonRpcRequest.Id, - jsonRpcRequest, - SessionId, - cancellationToken).ConfigureAwait(false); - - // Register the task for TTL-based cancellation - var taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive); - - // Execute the operation asynchronously in the background - _ = Task.Run(async () => - { - try - { - // Send notification if enabled - if (sendNotifications) - { - var workingTask = await taskStore.GetTaskAsync(mcpTask.TaskId, SessionId, CancellationToken.None).ConfigureAwait(false); - if (workingTask is not null) - { - _ = NotifyTaskStatusAsync(workingTask, CancellationToken.None); - } - } - - // Execute the operation with task-specific cancellation token - var result = await operation(taskCancellationToken).ConfigureAwait(false); - - // Store the result - var completedTask = await taskStore.StoreTaskResultAsync( - mcpTask.TaskId, - McpTaskStatus.Completed, - result, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send final notification if enabled - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(completedTask, CancellationToken.None); - } - } - catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) - { - // Task was cancelled via TTL expiration or explicit cancellation. - // For TTL expiration, the task is deleted so no status update needed. - // For explicit cancellation, the cancel handler already updates the status. - } - catch (Exception ex) - { - // Store error result using a simple string message - try - { - var errorElement = JsonSerializer.SerializeToElement(ex.Message, McpJsonUtilities.JsonContext.Default.String); - await taskStore.StoreTaskResultAsync( - mcpTask.TaskId, - McpTaskStatus.Failed, - errorElement, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Update task with error message - var failedTask = await taskStore.UpdateTaskStatusAsync( - mcpTask.TaskId, - McpTaskStatus.Failed, - ex.Message, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send failure notification if enabled - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(failedTask, CancellationToken.None); - } - } - catch - { - // If we can't store the error result, there's not much we can do - } - } - finally - { - // Clean up task cancellation tracking - _taskCancellationTokenProvider!.Complete(mcpTask.TaskId); - } - }, CancellationToken.None); - - // Return the task result immediately - var createTaskResult = new CreateTaskResult { Task = mcpTask }; - return JsonSerializer.SerializeToElement(createTaskResult, McpJsonUtilities.JsonContext.Default.CreateTaskResult); - } - - /// - /// Sends a task status notification to the connected server. - /// - private Task NotifyTaskStatusAsync(McpTask task, CancellationToken cancellationToken) - { - var notificationParams = new McpTaskStatusNotificationParams - { - TaskId = task.TaskId, - Status = task.Status, - StatusMessage = task.StatusMessage, - CreatedAt = task.CreatedAt, - LastUpdatedAt = task.LastUpdatedAt, - TimeToLive = task.TimeToLive, - PollInterval = task.PollInterval - }; - - return this.SendNotificationAsync( - NotificationMethods.TaskStatusNotification, - notificationParams, - McpJsonUtilities.JsonContext.Default.McpTaskStatusNotificationParams, - cancellationToken); - } - - /// - /// Registers handlers for task-related requests from the server. - /// - private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore taskStore) - { - // tasks/get handler - Retrieve task status - requestHandlers.Set( - RequestMethods.TasksGet, - async (request, _, cancellationToken) => - { - if (request?.TaskId is not { } taskId) - { - throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams); - } - - var task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - if (task is null) - { - throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams); - } - - return new GetTaskResult - { - TaskId = task.TaskId, - Status = task.Status, - StatusMessage = task.StatusMessage, - CreatedAt = task.CreatedAt, - LastUpdatedAt = task.LastUpdatedAt, - TimeToLive = task.TimeToLive, - PollInterval = task.PollInterval - }; - }, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult); - - // tasks/result handler - Retrieve task result (blocking until terminal status) - requestHandlers.Set( - RequestMethods.TasksResult, - async (request, _, cancellationToken) => - { - if (request?.TaskId is not { } taskId) - { - throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams); - } - - // Poll until task reaches terminal status - while (true) - { - McpTask? task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - if (task is null) - { - throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams); - } - - // If terminal, break and retrieve result - if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) - { - break; - } - - // Poll according to task's pollInterval (default 1 second) - var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1); - await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false); - } - - // Retrieve the stored result - return await taskStore.GetTaskResultAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - }, - McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams, - McpJsonUtilities.JsonContext.Default.JsonElement); - - // tasks/list handler - List tasks with pagination - requestHandlers.Set( - RequestMethods.TasksList, - async (request, _, cancellationToken) => - { - var cursor = request?.Cursor; - return await taskStore.ListTasksAsync(cursor, SessionId, cancellationToken).ConfigureAwait(false); - }, - McpJsonUtilities.JsonContext.Default.ListTasksRequestParams, - McpJsonUtilities.JsonContext.Default.ListTasksResult); - - // tasks/cancel handler - Cancel a task - requestHandlers.Set( - RequestMethods.TasksCancel, - async (request, _, cancellationToken) => - { - if (request?.TaskId is not { } taskId) - { - throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams); - } - - // Signal cancellation if task is still running - _taskCancellationTokenProvider!.Cancel(taskId); - - var task = await taskStore.CancelTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - if (task is null) - { - throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams); - } - - return new CancelMcpTaskResult - { - TaskId = task.TaskId, - Status = task.Status, - StatusMessage = task.StatusMessage, - CreatedAt = task.CreatedAt, - LastUpdatedAt = task.LastUpdatedAt, - TimeToLive = task.TimeToLive, - PollInterval = task.PollInterval - }; - }, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskResult); - - // Advertise task capabilities - _options.Capabilities ??= new(); - var tasksCapability = _options.Capabilities.Tasks ??= new McpTasksCapability(); - tasksCapability.List ??= new ListMcpTasksCapability(); - tasksCapability.Cancel ??= new CancelMcpTasksCapability(); - var requestsCapability = tasksCapability.Requests ??= new RequestMcpTasksCapability(); - - // Only advertise sampling tasks if sampling handler is present - if (_options.Handlers.SamplingHandler is not null) - { - var samplingCapability = requestsCapability.Sampling ??= new SamplingMcpTasksCapability(); - samplingCapability.CreateMessage ??= new CreateMessageMcpTasksCapability(); - } - - // Only advertise elicitation tasks if elicitation handler is present - if (_options.Handlers.ElicitationHandler is not null) - { - var elicitationCapability = requestsCapability.Elicitation ??= new ElicitationMcpTasksCapability(); - elicitationCapability.Create ??= new CreateElicitationMcpTasksCapability(); - } } /// @@ -562,7 +178,10 @@ private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore public override Task Completion => _sessionHandler.CompletionTask; /// - private async ValueTask> ResolveInputRequestsAsync( + private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; + + /// + private protected override async ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken) { @@ -929,7 +548,6 @@ public override async ValueTask DisposeAsync() _disposed = true; - _taskCancellationTokenProvider?.Dispose(); await _sessionHandler.DisposeAsync().ConfigureAwait(false); await _transport.DisposeAsync().ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index f84b84826..1e3bdc4bf 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -97,34 +97,40 @@ public McpClientHandlers Handlers } /// - /// Gets or sets the task store for managing client-side tasks. + /// Gets or sets the maximum number of consecutive task polls during which a task may report + /// without publishing any new input requests, before + /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws + /// an . /// + /// + /// The maximum number of consecutive stuck polls allowed. The default value is 60. + /// /// /// - /// When a task store is configured, the client will support task-augmented requests from the server. - /// This allows the server to request sampling or elicitation as tasks, which the client executes - /// asynchronously and allows the server to poll for status and results. + /// This guard prevents an unbounded poll loop when the server keeps a task in + /// but never publishes new input requests after the + /// client has already responded to every previously surfaced request. It only affects the + /// long-poll path used by ; + /// it does not affect direct calls. /// /// - /// If not set, task-augmented requests will not be supported, and the client will not advertise - /// task capabilities to the server. + /// Callers should size this value with the configured server-side poll interval in mind: the + /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs. + /// Setting this to a very small value can cause false positives for servers that are slow to + /// surface follow-up input requests; setting it too large can mask misbehaving servers. /// /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public IMcpTaskStore? TaskStore { get; set; } - - /// - /// Gets or sets a value indicating whether the client should send task status notifications to the server. - /// - /// - /// to send task status notifications; otherwise. - /// The default is . - /// - /// - /// When enabled and a is configured, the client will send optional - /// notifications/tasks/status notifications to inform the server of task state changes. - /// Servers MUST NOT rely on receiving these notifications and should continue polling via tasks/get. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public bool SendTaskStatusNotifications { get; set; } = true; + /// The value is less than 1. + public int MaxConsecutiveStuckPolls + { + get; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1."); + } + field = value; + } + } = 60; } diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml new file mode 100644 index 000000000..cb0cffaa8 --- /dev/null +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -0,0 +1,2244 @@ + + + + + CP0001 + T:ModelContextProtocol.IMcpTaskStore + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.InMemoryMcpTaskStore + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskResult + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksRequestParams + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksResult + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTask + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskMetadata + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.RequestMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolExecution + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolTaskSupport + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.IMcpTaskStore + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.InMemoryMcpTaskStore + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskResult + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksRequestParams + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksResult + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTask + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskMetadata + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.RequestMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolExecution + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolTaskSupport + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.IMcpTaskStore + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.InMemoryMcpTaskStore + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskResult + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksRequestParams + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksResult + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTask + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskMetadata + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.RequestMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolExecution + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolTaskSupport + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.IMcpTaskStore + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.InMemoryMcpTaskStore + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CallToolMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskRequestParams + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTaskResult + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CancelMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateElicitationMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.CreateMessageMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskPayloadRequestParams + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksRequestParams + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ListTasksResult + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTask + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskMetadata + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.RequestMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.SamplingMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolExecution + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolsMcpTasksCapability + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.ToolTaskSupport + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksList + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksResult + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.get_Task + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.#ctor + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.get_Execution + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksList + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksResult + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.get_Task + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.#ctor + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.get_Execution + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksList + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksResult + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.get_Task + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.#ctor + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.get_Execution + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.RelatedTaskMetaKey + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksList + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksResult + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CallToolAsTaskAsync(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.Object},ModelContextProtocol.Protocol.McpTaskMetadata,System.IProgress{ModelContextProtocol.ProgressNotificationValue},ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.CancelTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.GetTaskResultAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.ListTasksAsync(ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClient.PollTaskUntilCompleteAsync(System.String,ModelContextProtocol.RequestOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.get_TaskStatusHandler + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientHandlers.set_TaskStatusHandler(System.Func{ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask}) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_SendTaskStatusNotifications + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.get_TaskStore + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Client.McpClientOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.get_Task + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.get_Task + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CallToolResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.get_Tasks + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ClientCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.get_Task + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateMessageRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ElicitRequestParams.set_Task(ModelContextProtocol.Protocol.McpTaskMetadata) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.#ctor + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.ServerCapabilities.set_Tasks(ModelContextProtocol.Protocol.McpTasksCapability) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.get_Execution + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Protocol.Tool.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.CancelTaskAsync(System.String,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ElicitAsTaskAsync(ModelContextProtocol.Protocol.ElicitRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskAsync(System.String,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.GetTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(ModelContextProtocol.Protocol.ListTasksRequestParams,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.ListTasksAsync(System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.NotifyTaskStatusAsync(ModelContextProtocol.Protocol.McpTask,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.PollTaskUntilCompleteAsync(System.String,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.SampleAsTaskAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams,ModelContextProtocol.Protocol.McpTaskMetadata,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServer.WaitForTaskResultAsync``1(System.String,System.Text.Json.JsonSerializerOptions,System.Threading.CancellationToken) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_SendTaskStatusNotifications + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.get_TaskStore + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_SendTaskStatusNotifications(System.Boolean) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolAttribute.set_TaskSupport(ModelContextProtocol.Protocol.ToolTaskSupport) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.get_Execution + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerToolCreateOptions.set_Execution(ModelContextProtocol.Protocol.ToolExecution) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0011 + F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index f1d18b4f9..7006d7221 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -96,6 +96,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(JsonRpcNotification))] [JsonSerializable(typeof(JsonRpcResponse))] [JsonSerializable(typeof(JsonRpcError))] + [JsonSerializable(typeof(JsonRpcErrorDetail))] // MCP Notification Params [JsonSerializable(typeof(CancelledNotificationParams))] @@ -108,12 +109,16 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(ResourceUpdatedNotificationParams))] [JsonSerializable(typeof(RootsListChangedNotificationParams))] [JsonSerializable(typeof(ToolListChangedNotificationParams))] - [JsonSerializable(typeof(McpTaskStatusNotificationParams))] + [JsonSerializable(typeof(TaskStatusNotificationParams))] + [JsonSerializable(typeof(WorkingTaskNotificationParams))] + [JsonSerializable(typeof(CompletedTaskNotificationParams))] + [JsonSerializable(typeof(FailedTaskNotificationParams))] + [JsonSerializable(typeof(CancelledTaskNotificationParams))] + [JsonSerializable(typeof(InputRequiredTaskNotificationParams))] // MCP Request Params / Results [JsonSerializable(typeof(CallToolRequestParams))] [JsonSerializable(typeof(CallToolResult))] - [JsonSerializable(typeof(CreateTaskResult))] [JsonSerializable(typeof(CompleteRequestParams))] [JsonSerializable(typeof(CompleteResult))] [JsonSerializable(typeof(CreateMessageRequestParams))] @@ -151,21 +156,18 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] - // MCP Task Request Params / Results - [JsonSerializable(typeof(McpTask))] - [JsonSerializable(typeof(McpTaskStatus))] - [JsonSerializable(typeof(McpTaskMetadata))] [JsonSerializable(typeof(GetTaskRequestParams))] [JsonSerializable(typeof(GetTaskResult))] - [JsonSerializable(typeof(GetTaskPayloadRequestParams))] - [JsonSerializable(typeof(ListTasksRequestParams))] - [JsonSerializable(typeof(ListTasksResult))] - [JsonSerializable(typeof(CancelMcpTaskRequestParams))] - [JsonSerializable(typeof(CancelMcpTaskResult))] - [JsonSerializable(typeof(McpTasksCapability))] - [JsonSerializable(typeof(RequestMcpTasksCapability))] - [JsonSerializable(typeof(ToolExecution))] - [JsonSerializable(typeof(ToolTaskSupport))] + [JsonSerializable(typeof(WorkingTaskResult))] + [JsonSerializable(typeof(CompletedTaskResult))] + [JsonSerializable(typeof(FailedTaskResult))] + [JsonSerializable(typeof(CancelledTaskResult))] + [JsonSerializable(typeof(InputRequiredTaskResult))] + [JsonSerializable(typeof(UpdateTaskRequestParams))] + [JsonSerializable(typeof(UpdateTaskResult))] + [JsonSerializable(typeof(CancelTaskRequestParams))] + [JsonSerializable(typeof(CancelTaskResult))] + [JsonSerializable(typeof(CreateTaskResult))] // MCP Content [JsonSerializable(typeof(ContentBlock))] @@ -184,9 +186,9 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(TextResourceContents))] // Other MCP Types + [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(ProgressToken))] - [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [JsonSerializable(typeof(AuthorizationServerMetadata))] diff --git a/src/ModelContextProtocol.Core/McpTaskCancellationTokenProvider.cs b/src/ModelContextProtocol.Core/McpTaskCancellationTokenProvider.cs deleted file mode 100644 index 6ecfc4f4a..000000000 --- a/src/ModelContextProtocol.Core/McpTaskCancellationTokenProvider.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Collections.Concurrent; - -namespace ModelContextProtocol; - -/// -/// Provides cancellation tokens for running MCP tasks, enabling TTL-based -/// automatic cancellation and explicit task cancellation. -/// -/// -/// -/// This class provides lifecycle management for instances -/// associated with running tasks. Each task gets its own CTS that can be: -/// -/// -/// Automatically cancelled when the task's TTL expires -/// Explicitly cancelled via the method -/// Cleaned up when the task completes via -/// -/// -/// Both McpClient and McpServer use this class to manage task cancellation -/// independently of request cancellation tokens. -/// -/// -internal sealed class McpTaskCancellationTokenProvider : IDisposable -{ - private readonly ConcurrentDictionary _runningTasks = new(); - private bool _disposed; - - /// - /// Registers a new task and returns a cancellation token for use during execution. - /// - /// The unique identifier of the task. - /// - /// Optional TTL duration. If specified, the returned token will be automatically - /// cancelled when the TTL expires. - /// - /// - /// A that will be cancelled when the TTL expires, - /// when is called, or when this provider is disposed. - /// - /// The provider has been disposed. - /// A task with the same ID is already registered. - public CancellationToken RequestToken(string taskId, TimeSpan? timeToLive) - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(McpTaskCancellationTokenProvider)); - } - - Throw.IfNullOrWhiteSpace(taskId); - CancellationTokenSource cts = new(); - - if (timeToLive is { } ttl) - { - cts.CancelAfter(ttl); - } - - if (!_runningTasks.TryAdd(taskId, cts)) - { - cts.Dispose(); - throw new InvalidOperationException($"Task '{taskId}' is already registered."); - } - - return cts.Token; - } - - /// - /// Attempts to cancel a running task. - /// - /// The unique identifier of the task to cancel. - /// - /// This method signals cancellation but does not remove the task from tracking. - /// The task executor should call when it observes - /// the cancellation and finishes cleanup. - /// - public void Cancel(string taskId) - { - if (_runningTasks.TryGetValue(taskId, out var cts)) - { - cts.Cancel(); - } - } - - /// - /// Marks a task as complete and releases its associated resources. - /// - /// The unique identifier of the task that has completed. - /// - /// This method should be called from a finally block in the task execution - /// to ensure proper cleanup regardless of success, failure, or cancellation. - /// - public void Complete(string taskId) - { - if (_runningTasks.TryRemove(taskId, out var cts)) - { - cts.Dispose(); - } - } - - /// - /// Cancels all running tasks and releases all resources. - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - - foreach (var kvp in _runningTasks) - { - try - { - kvp.Value.Cancel(); - kvp.Value.Dispose(); - } - catch - { - // Best effort cleanup - } - } - - _runningTasks.Clear(); - } -} diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index b6423b0c8..23045b317 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -7,7 +7,7 @@ ModelContextProtocol.Core Core .NET SDK for the Model Context Protocol (MCP) README.md - + $(NoWarn);MCPEXP001 @@ -35,6 +35,7 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs index 8267cd06f..d311c6b4f 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs @@ -26,24 +26,4 @@ public sealed class CallToolRequestParams : RequestParams /// [JsonPropertyName("arguments")] public IDictionary? Arguments { get; set; } - - /// - /// Gets or sets optional task metadata to augment this request with task execution. - /// - /// - /// When present, indicates that the requestor wants this operation executed as a task. - /// The receiver must support task augmentation for this specific request type. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public McpTaskMetadata? Task - { - get => TaskCore; - set => TaskCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("task")] - internal McpTaskMetadata? TaskCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs index 35dba5b6e..b2fdb3d05 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs @@ -64,25 +64,4 @@ public sealed class CallToolResult : Result /// [JsonPropertyName("isError")] public bool? IsError { get; set; } - - /// - /// Gets or sets the task data for the newly created task. - /// - /// - /// This property is populated only for task-augmented tool calls. When present, the other properties - /// (, , ) may not be populated. - /// The actual tool result can be retrieved later via tasks/result. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public McpTask? Task - { - get => TaskCore; - set => TaskCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("task")] - internal McpTask? TaskCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/CancelMcpTaskRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CancelMcpTaskRequestParams.cs deleted file mode 100644 index c4fb540b2..000000000 --- a/src/ModelContextProtocol.Core/Protocol/CancelMcpTaskRequestParams.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents the parameters for a tasks/cancel request to explicitly cancel a task. -/// -/// -/// -/// Receivers must reject cancellation requests for tasks already in a terminal status -/// (, , or -/// ) with error code -32602 (Invalid params). -/// -/// -/// Upon receiving a valid cancellation request, receivers should attempt to stop the task -/// execution and must transition the task to status -/// before sending the response. -/// -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class CancelMcpTaskRequestParams : RequestParams -{ - /// - /// Gets or sets the unique identifier of the task to cancel. - /// - [JsonPropertyName("taskId")] - public required string TaskId { get; set; } -} - -/// -/// Represents the result of a tasks/cancel request. -/// -/// -/// The result contains the updated task state after cancellation. The task will be in -/// status if the cancellation was successful. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class CancelMcpTaskResult : Result -{ - /// - /// Gets or sets the task ID. - /// - [JsonPropertyName("taskId")] - public required string TaskId { get; set; } - - /// - /// Gets or sets the current status of the task (should be ). - /// - [JsonPropertyName("status")] - public required McpTaskStatus Status { get; set; } - - /// - /// Gets or sets an optional message describing the cancellation. - /// - [JsonPropertyName("statusMessage")] - public string? StatusMessage { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task was created. - /// - [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task status was last updated. - /// - [JsonPropertyName("lastUpdatedAt")] - public required DateTimeOffset LastUpdatedAt { get; set; } - - /// - /// Gets or sets the time to live (retention duration) from creation before the task may be deleted. - /// - [JsonPropertyName("ttl")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? TimeToLive { get; set; } - - /// - /// Gets or sets the suggested time between status checks. - /// - [JsonPropertyName("pollInterval")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? PollInterval { get; set; } -} diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs new file mode 100644 index 000000000..ee458d064 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task. +/// +/// +/// +/// Cancellation is cooperative: the request signals intent, and the server decides whether and when to honor it. +/// A server is not obligated to actually stop the work; it is only obligated to acknowledge the request. +/// Eventual transition to is not guaranteed. +/// +/// +/// The notifications/cancelled notification must not be used for task cancellation. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class CancelTaskRequestParams : RequestParams +{ + /// + /// Gets or sets the identifier of the task to cancel. + /// + [JsonPropertyName("taskId")] + public required string TaskId { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs new file mode 100644 index 000000000..4d066862b --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a tasks/cancel request. This is an empty acknowledgement. +/// +/// +/// +/// The server acknowledges the request with an empty result. Cancellation processing is +/// eventually consistent — the task's observable status may remain +/// after the ack, and may ultimately reach a terminal status other than +/// if the work finished before cancellation could take effect. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class CancelTaskResult : Result +{ +} diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index 77b2bef9f..f41f50fd8 100644 --- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs @@ -68,32 +68,6 @@ public sealed class ClientCapabilities [JsonPropertyName("elicitation")] public ElicitationCapability? Elicitation { get; set; } - /// - /// Gets or sets the client's tasks capability for supporting task-augmented requests. - /// - /// - /// - /// The tasks capability enables servers to augment their requests with tasks for long-running - /// operations. When present, servers can request that certain operations (like sampling or - /// elicitation) execute asynchronously, with the ability to poll for status and retrieve results later. - /// - /// - /// See for details on configuring which operations support tasks. - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public McpTasksCapability? Tasks - { - get => TasksCore; - set => TasksCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("tasks")] - internal McpTasksCapability? TasksCore { get; set; } - /// /// Gets or sets optional MCP extensions that the client supports. /// diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs index ef5e57d2c..bb27d70fd 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs @@ -153,24 +153,4 @@ public sealed class CreateMessageRequestParams : RequestParams /// [JsonPropertyName("toolChoice")] public ToolChoice? ToolChoice { get; set; } - - /// - /// Gets or sets optional task metadata to augment this request with task execution. - /// - /// - /// When present, indicates that the requestor wants this operation executed as a task. - /// The receiver must support task augmentation for this specific request type. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public McpTaskMetadata? Task - { - get => TaskCore; - set => TaskCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("task")] - internal McpTaskMetadata? TaskCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs index 166d05e49..2e5bc0c41 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs @@ -1,28 +1,68 @@ -using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol; /// -/// Represents the response to a task-augmented request. +/// Represents the result returned by a server when it creates a task in lieu of a standard result. /// /// /// -/// When a client sends a request with a task parameter, the server immediately returns -/// a containing the created task information instead of the -/// normal result type. The actual result can be retrieved later via tasks/result. +/// A server returns instead of the standard result shape (e.g., ) +/// to indicate that the request will be processed asynchronously. The client then uses +/// for subsequent tasks/get, tasks/update, and tasks/cancel calls. /// /// -/// This type is returned for any task-augmented request including tools/call, -/// sampling/createMessage, and elicitation/create. +/// A server must not return to a client that did not include the +/// io.modelcontextprotocol/tasks extension capability on its request. +/// +/// +/// See the SEP-2663 +/// specification for details. /// /// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public sealed class CreateTaskResult : Result { /// - /// Gets or sets the task data for the newly created task. + /// Gets or sets the stable identifier for this task. + /// + [JsonPropertyName("taskId")] + public required string TaskId { get; set; } + + /// + /// Gets or sets the current task status. + /// + [JsonPropertyName("status")] + public required McpTaskStatus Status { get; set; } + + /// + /// Gets or sets an optional message describing the current task state. + /// + [JsonPropertyName("statusMessage")] + public string? StatusMessage { get; set; } + + /// + /// Gets or sets the ISO 8601 timestamp when the task was created. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the ISO 8601 timestamp when the task was last updated. + /// + [JsonPropertyName("lastUpdatedAt")] + public required DateTimeOffset LastUpdatedAt { get; set; } + + /// + /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// + [JsonPropertyName("ttlMs")] + public long? TtlMs { get; set; } + + /// + /// Gets or sets the suggested polling interval in milliseconds. /// - [JsonPropertyName("task")] - public McpTask Task { get; set; } = null!; + [JsonPropertyName("pollIntervalMs")] + public long? PollIntervalMs { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 39a5bd358..9dc1ac903 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -92,26 +92,6 @@ public string Mode [JsonPropertyName("requestedSchema")] public RequestSchema? RequestedSchema { get; set; } - /// - /// Gets or sets optional task metadata to augment this request with task execution. - /// - /// - /// When present, indicates that the requestor wants this operation executed as a task. - /// The receiver must support task augmentation for this specific request type. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public McpTaskMetadata? Task - { - get => TaskCore; - set => TaskCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("task")] - internal McpTaskMetadata? TaskCore { get; set; } - /// Represents a request schema used in a form mode elicitation request. public sealed class RequestSchema { diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskPayloadRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/GetTaskPayloadRequestParams.cs deleted file mode 100644 index d64a8b1f9..000000000 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskPayloadRequestParams.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents the parameters for a tasks/result request to retrieve the result of a completed task. -/// -/// -/// -/// This request blocks until the task reaches a terminal status (, -/// , or ). -/// -/// -/// The result structure matches the original request type (e.g., for tools/call). -/// This is distinct from the initial response, which contains only task data. -/// -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class GetTaskPayloadRequestParams : RequestParams -{ - /// - /// Gets or sets the unique identifier of the task whose result to retrieve. - /// - [JsonPropertyName("taskId")] - public required string TaskId { get; set; } -} diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs index a8aaaea93..52b82d902 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs @@ -1,77 +1,26 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol; /// -/// Represents the parameters for a tasks/get request to retrieve task status. +/// Represents the parameters for a tasks/get request to poll for task completion. /// /// -/// Requestors poll for task completion by sending tasks/get requests. They should -/// respect the provided in responses when determining -/// polling frequency. +/// +/// Clients poll for task completion by sending tasks/get requests. +/// Clients should respect the provided in responses +/// when determining polling frequency. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// /// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public sealed class GetTaskRequestParams : RequestParams { /// - /// Gets or sets the unique identifier of the task to retrieve. + /// Gets or sets the identifier of the task to query. /// [JsonPropertyName("taskId")] public required string TaskId { get; set; } } - -/// -/// Represents the result of a tasks/get request. -/// -/// -/// The result contains the current state of the task, including its status, timestamps, -/// and any status message. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class GetTaskResult : Result -{ - /// - /// Gets or sets the task ID. - /// - [JsonPropertyName("taskId")] - public required string TaskId { get; set; } - - /// - /// Gets or sets the current status of the task. - /// - [JsonPropertyName("status")] - public required McpTaskStatus Status { get; set; } - - /// - /// Gets or sets an optional human-readable message describing the current state. - /// - [JsonPropertyName("statusMessage")] - public string? StatusMessage { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task was created. - /// - [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task status was last updated. - /// - [JsonPropertyName("lastUpdatedAt")] - public required DateTimeOffset LastUpdatedAt { get; set; } - - /// - /// Gets or sets the time to live (retention duration) from creation before the task may be deleted. - /// - [JsonPropertyName("ttl")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? TimeToLive { get; set; } - - /// - /// Gets or sets the suggested time between status checks. - /// - [JsonPropertyName("pollInterval")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? PollInterval { get; set; } -} diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs new file mode 100644 index 000000000..9366f2374 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs @@ -0,0 +1,450 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a tasks/get request, containing the full task state. +/// +/// +/// +/// This is the abstract base for status-specific task results. The concrete type returned depends on the +/// task's current : +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +[JsonConverter(typeof(Converter))] +public abstract class GetTaskResult : Result +{ + /// Prevent external derivations. + private protected GetTaskResult() + { + } + + /// + /// Gets or sets the stable identifier for this task. + /// + [JsonPropertyName("taskId")] + public required string TaskId { get; set; } + + /// + /// Gets or sets the current task status. + /// + [JsonPropertyName("status")] + public abstract McpTaskStatus Status { get; } + + /// + /// Gets or sets an optional message describing the current task state. + /// + [JsonPropertyName("statusMessage")] + public string? StatusMessage { get; set; } + + /// + /// Gets or sets the ISO 8601 timestamp when the task was created. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the ISO 8601 timestamp when the task was last updated. + /// + [JsonPropertyName("lastUpdatedAt")] + public required DateTimeOffset LastUpdatedAt { get; set; } + + /// + /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// + [JsonPropertyName("ttlMs")] + public long? TtlMs { get; set; } + + /// + /// Gets or sets the suggested polling interval in milliseconds. + /// + [JsonPropertyName("pollIntervalMs")] + public long? PollIntervalMs { get; set; } + + /// + /// JSON converter that deserializes to the appropriate concrete subtype + /// based on the status discriminator field. + /// + internal sealed class Converter : JsonConverter + { + public override GetTaskResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for GetTaskResult."); + } + + string? taskId = null; + string? statusString = null; + string? statusMessage = null; + DateTimeOffset? createdAt = null; + DateTimeOffset? lastUpdatedAt = null; + long? ttlMs = null; + long? pollIntervalMs = null; + string? resultType = null; + JsonObject? meta = null; + JsonElement? result = null; + JsonElement? error = null; + Dictionary? inputRequests = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "taskId": + taskId = reader.GetString(); + break; + case "status": + statusString = reader.GetString(); + break; + case "statusMessage": + statusMessage = reader.GetString(); + break; + case "createdAt": + createdAt = reader.GetDateTimeOffset(); + break; + case "lastUpdatedAt": + lastUpdatedAt = reader.GetDateTimeOffset(); + break; + case "ttlMs": + ttlMs = reader.GetInt64(); + break; + case "pollIntervalMs": + pollIntervalMs = reader.GetInt64(); + break; + case "resultType": + resultType = reader.GetString(); + break; + case "_meta": + meta = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + break; + case "result": + result = JsonElement.ParseValue(ref reader); + break; + case "error": + error = JsonElement.ParseValue(ref reader); + break; + case "inputRequests": + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("'inputRequests' must be a JSON object."); + } + inputRequests = new Dictionary(StringComparer.Ordinal); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name in 'inputRequests'."); + } + string requestKey = reader.GetString()!; + reader.Read(); + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); + inputRequests[requestKey] = inputRequest; + } + break; + default: + reader.Skip(); + break; + } + } + + if (taskId is null) + { + throw new JsonException("Missing required 'taskId' property on GetTaskResult."); + } + + if (statusString is null) + { + throw new JsonException("Missing required 'status' property on GetTaskResult."); + } + + if (createdAt is null) + { + throw new JsonException("Missing required 'createdAt' property on GetTaskResult."); + } + + if (lastUpdatedAt is null) + { + throw new JsonException("Missing required 'lastUpdatedAt' property on GetTaskResult."); + } + + GetTaskResult taskResult = statusString switch + { + "working" => new WorkingTaskResult + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + }, + "completed" => result is not null + ? new CompletedTaskResult + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + Result = result.Value, + } + : throw new JsonException("Completed task is missing required 'result' property."), + "failed" => error is not null + ? new FailedTaskResult + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + Error = error.Value, + } + : throw new JsonException("Failed task is missing required 'error' property."), + "cancelled" => new CancelledTaskResult + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + }, + "input_required" => inputRequests is not null + ? new InputRequiredTaskResult + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + InputRequests = inputRequests, + } + : throw new JsonException("Input-required task is missing required 'inputRequests' property."), + _ => throw new JsonException($"Unknown task status: '{statusString}'.") + }; + + taskResult.StatusMessage = statusMessage; + taskResult.TtlMs = ttlMs; + taskResult.PollIntervalMs = pollIntervalMs; + taskResult.ResultType = resultType; + taskResult.Meta = meta; + + return taskResult; + } + + public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value.ResultType is not null) + { + writer.WriteString("resultType", value.ResultType); + } + + if (value.Meta is not null) + { + writer.WritePropertyName("_meta"); + JsonSerializer.Serialize(writer, value.Meta, options.GetTypeInfo()); + } + + writer.WriteString("taskId", value.TaskId); + writer.WriteString("status", value.Status switch + { + McpTaskStatus.Working => "working", + McpTaskStatus.Completed => "completed", + McpTaskStatus.Failed => "failed", + McpTaskStatus.Cancelled => "cancelled", + McpTaskStatus.InputRequired => "input_required", + _ => throw new JsonException($"Unknown McpTaskStatus: {value.Status}") + }); + + if (value.StatusMessage is not null) + { + writer.WriteString("statusMessage", value.StatusMessage); + } + + writer.WriteString("createdAt", value.CreatedAt); + writer.WriteString("lastUpdatedAt", value.LastUpdatedAt); + + if (value.TtlMs is not null) + { + writer.WriteNumber("ttlMs", value.TtlMs.Value); + } + + if (value.PollIntervalMs is not null) + { + writer.WriteNumber("pollIntervalMs", value.PollIntervalMs.Value); + } + + switch (value) + { + case CompletedTaskResult completed: + writer.WritePropertyName("result"); + completed.Result.WriteTo(writer); + break; + case FailedTaskResult failed: + writer.WritePropertyName("error"); + failed.Error.WriteTo(writer); + break; + case InputRequiredTaskResult inputRequired: + writer.WritePropertyName("inputRequests"); + writer.WriteStartObject(); + if (inputRequired.InputRequests is { } reqs) + { + foreach (var kvp in reqs) + { + writer.WritePropertyName(kvp.Key); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + } + } + writer.WriteEndObject(); + break; + } + + writer.WriteEndObject(); + } + } +} + +/// +/// Represents a task that is currently being processed by the server. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +public sealed class WorkingTaskResult : GetTaskResult +{ + /// + [JsonPropertyName("status")] + public override McpTaskStatus Status => McpTaskStatus.Working; +} + +/// +/// Represents a task that has completed successfully, carrying the final result. +/// +/// +/// +/// The field contains the result structure matching the original request type. +/// For example, a tools/call task would contain the structure. +/// This includes tool calls that returned results with isError: true. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class CompletedTaskResult : GetTaskResult +{ + /// + [JsonPropertyName("status")] + public override McpTaskStatus Status => McpTaskStatus.Completed; + + /// + /// Gets or sets the final result of the task as raw JSON. + /// + /// + /// The structure matches the result type of the original request. + /// + [JsonPropertyName("result")] + public required JsonElement Result { get; set; } +} + +/// +/// Represents a task that failed due to a JSON-RPC error during execution. +/// +/// +/// +/// The field contains the JSON-RPC error object that caused the failure. +/// This status must not be used for non-JSON-RPC errors. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class FailedTaskResult : GetTaskResult +{ + /// + [JsonPropertyName("status")] + public override McpTaskStatus Status => McpTaskStatus.Failed; + + /// + /// Gets or sets the JSON-RPC error that caused the task to fail. + /// + [JsonPropertyName("error")] + public required JsonElement Error { get; set; } +} + +/// +/// Represents a task that was cancelled before completion. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +public sealed class CancelledTaskResult : GetTaskResult +{ + /// + [JsonPropertyName("status")] + public override McpTaskStatus Status => McpTaskStatus.Cancelled; +} + +/// +/// Represents a task that requires input from the client before it can proceed. +/// +/// +/// +/// The field contains outstanding server-to-client requests +/// that the client must fulfil. Each entry is keyed by an arbitrary identifier for matching +/// requests to responses, and each value is an wrapping the +/// server-to-client request payload. +/// +/// +/// Clients must treat each entry as they would the equivalent standalone server-to-client request. +/// Clients should deduplicate keys across consecutive polls to avoid presenting the same request +/// to the user or model more than once. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class InputRequiredTaskResult : GetTaskResult +{ + /// + [JsonPropertyName("status")] + public override McpTaskStatus Status => McpTaskStatus.InputRequired; + + /// + /// Gets or sets the server-to-client requests that need to be fulfilled. + /// + /// + /// Keys are arbitrary identifiers for matching requests to responses. + /// Each value is an wrapping the server-to-client request + /// (e.g., a sampling, elicitation, or roots-list request). + /// + [JsonPropertyName("inputRequests")] + public IDictionary? InputRequests { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ListTasksRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ListTasksRequestParams.cs deleted file mode 100644 index 3036d977b..000000000 --- a/src/ModelContextProtocol.Core/Protocol/ListTasksRequestParams.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents the parameters for a tasks/list request to retrieve a list of tasks. -/// -/// -/// This operation supports cursor-based pagination. Receivers should use cursor-based -/// pagination to limit the number of tasks returned in a single response. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class ListTasksRequestParams : PaginatedRequestParams -{ - // Inherits Cursor property from PaginatedRequestParams -} - -/// -/// Represents the result of a tasks/list request. -/// -/// -/// The result contains an array of task objects and an optional cursor for pagination. -/// If is present, more tasks are available. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class ListTasksResult : PaginatedResult -{ - /// - /// Gets or sets the list of tasks. - /// - [JsonPropertyName("tasks")] - public required IList Tasks { get; set; } -} diff --git a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs new file mode 100644 index 000000000..a41e4a576 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs @@ -0,0 +1,18 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Provides constants for well-known MCP extension identifiers. +/// +public static class McpExtensions +{ + /// + /// The extension identifier for the MCP Tasks extension. + /// + /// + /// When included in client per-request capabilities, indicates the client can handle + /// in lieu of a standard result. + /// See the SEP-2663 + /// specification for details. + /// + public const string Tasks = "io.modelcontextprotocol/tasks"; +} diff --git a/src/ModelContextProtocol.Core/Protocol/McpTask.cs b/src/ModelContextProtocol.Core/Protocol/McpTask.cs deleted file mode 100644 index 2056c5890..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpTask.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents an MCP task, which is a durable state machine carrying information -/// about the underlying execution state of a request. -/// -/// -/// -/// Tasks are useful for representing expensive computations and batch processing requests. -/// Each task is uniquely identifiable by a receiver-generated task ID. -/// -/// -/// Tasks follow a defined lifecycle through the property. They begin -/// in the status and may transition through various states -/// before reaching a terminal status (, , -/// or ). -/// -/// -/// See the tasks specification for details. -/// -/// -[DebuggerDisplay("{DebuggerDisplay,nq}")] -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class McpTask -{ - /// - /// Gets or sets the unique identifier for the task. - /// - /// - /// Task IDs are generated by the receiver when creating a task and must be unique - /// among all tasks controlled by that receiver. - /// - [JsonPropertyName("taskId")] - public required string TaskId { get; set; } - - /// - /// Gets or sets the current state of the task execution. - /// - [JsonPropertyName("status")] - public required McpTaskStatus Status { get; set; } - - /// - /// Gets or sets an optional human-readable message describing the current state. - /// - /// - /// This message can be present for any status, including error details for failed tasks. - /// - [JsonPropertyName("statusMessage")] - public string? StatusMessage { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task was created. - /// - /// - /// Receivers must include this timestamp in all task responses to indicate when - /// the task was created. - /// - [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task status was last updated. - /// - /// - /// Receivers must include this timestamp in all task responses to indicate when - /// the task was last updated. - /// - [JsonPropertyName("lastUpdatedAt")] - public required DateTimeOffset LastUpdatedAt { get; set; } - - /// - /// Gets or sets the time to live (retention duration) from creation before the task may be deleted. - /// - /// - /// - /// A null value indicates unlimited lifetime. After a task's TTL lifetime has elapsed, - /// receivers may delete the task and its results, regardless of the task status. - /// - /// - /// Receivers may override the requested TTL duration and must include the actual TTL - /// duration (or null for unlimited) in task responses. - /// - /// - [JsonPropertyName("ttl")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? TimeToLive { get; set; } - - /// - /// Gets or sets the suggested time between status checks. - /// - /// - /// Requestors should respect this value when provided to avoid excessive polling. - /// This value is optional and may not be present in all task responses. - /// - [JsonPropertyName("pollInterval")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? PollInterval { get; set; } - - private string DebuggerDisplay => $"Task {TaskId}: {Status}" + (StatusMessage != null ? $" - {StatusMessage}" : ""); -} diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskMetadata.cs b/src/ModelContextProtocol.Core/Protocol/McpTaskMetadata.cs deleted file mode 100644 index 72dea54f3..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskMetadata.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents metadata for augmenting a request with task execution. -/// -/// -/// -/// When included in a request's params, this metadata signals that the requestor -/// wants the receiver to execute the request as a task rather than synchronously. -/// The receiver will return a containing task data -/// instead of the actual operation result. -/// -/// -/// Requestors can specify a desired TTL (time-to-live) duration for the task, -/// though receivers may override this value based on their resource management policies. -/// -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class McpTaskMetadata -{ - /// - /// Gets or sets the requested time to live (retention duration) to retain the task from creation. - /// - /// - /// - /// This is a hint to the receiver about how long the requestor expects to need access - /// to the task data. Receivers may override this value based on their resource constraints - /// and policies. - /// - /// - /// A null value indicates no specific retention requirement. The actual TTL used by the - /// receiver will be returned in the property. - /// - /// - [JsonPropertyName("ttl")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? TimeToLive { get; set; } -} diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs index 9cf8a2f66..3b705a947 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs +++ b/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol; @@ -7,73 +6,44 @@ namespace ModelContextProtocol.Protocol; /// Represents the status of an MCP task. /// /// -/// -/// Tasks progress through a defined lifecycle: -/// -/// : The request is currently being processed. -/// : The receiver needs input from the requestor. -/// The requestor should call tasks/result to receive input requests. -/// : The request completed successfully and results are available. -/// : The request did not complete successfully. -/// : The request was cancelled before completion. -/// -/// -/// -/// Terminal states are , , and . -/// Once a task reaches a terminal state, it cannot transition to any other status. -/// +/// Tasks are durable state machines that carry information about the underlying execution state +/// of the request they augment. See the +/// SEP-2663 +/// specification for details. /// [JsonConverter(typeof(JsonStringEnumConverter))] -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public enum McpTaskStatus { /// /// The request is currently being processed. /// - /// - /// Tasks begin in this status when created. From , tasks may transition - /// to , , , or . - /// [JsonStringEnumMemberName("working")] Working, /// - /// The receiver needs input from the requestor. + /// The server needs input from the client before the task can proceed. + /// The tasks/get response will include outstanding requests in the inputRequests field. /// - /// - /// The requestor should call tasks/result to receive input requests, even though the task - /// has not reached a terminal state. From , tasks may transition - /// to , , , or . - /// [JsonStringEnumMemberName("input_required")] InputRequired, /// /// The request completed successfully and results are available. + /// This includes tool calls that returned results with isError: true. /// - /// - /// This is a terminal status. Tasks in this status cannot transition to any other status. - /// [JsonStringEnumMemberName("completed")] Completed, /// - /// The associated request did not complete successfully. + /// The request was cancelled before completion. /// - /// - /// This is a terminal status. For tool calls specifically, this includes cases where - /// the tool call result has isError set to true. Tasks in this status cannot transition - /// to any other status. - /// - [JsonStringEnumMemberName("failed")] - Failed, + [JsonStringEnumMemberName("cancelled")] + Cancelled, /// - /// The request was cancelled before completion. + /// The request failed due to a JSON-RPC error during execution. + /// This status must not be used for non-JSON-RPC errors. /// - /// - /// This is a terminal status. Tasks in this status cannot transition to any other status. - /// - [JsonStringEnumMemberName("cancelled")] - Cancelled + [JsonStringEnumMemberName("failed")] + Failed, } diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatusNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/McpTaskStatusNotificationParams.cs deleted file mode 100644 index a9b536102..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatusNotificationParams.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents the parameters for a notifications/tasks/status notification. -/// -/// -/// -/// When a task status changes, receivers may send this notification to inform the -/// requestor of the change. This notification includes the full task state. -/// -/// -/// Requestors must not rely on receiving this notification, as it is optional. Receivers -/// are not required to send status notifications and may choose to only send them for -/// certain status transitions. Requestors should continue to poll via tasks/get to ensure -/// they receive status updates. -/// -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class McpTaskStatusNotificationParams : NotificationParams -{ - /// - /// Gets or sets the task ID. - /// - [JsonPropertyName("taskId")] - public required string TaskId { get; set; } - - /// - /// Gets or sets the current status of the task. - /// - [JsonPropertyName("status")] - public required McpTaskStatus Status { get; set; } - - /// - /// Gets or sets an optional human-readable message describing the current state. - /// - [JsonPropertyName("statusMessage")] - public string? StatusMessage { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task was created. - /// - [JsonPropertyName("createdAt")] - public required DateTimeOffset CreatedAt { get; set; } - - /// - /// Gets or sets the ISO 8601 timestamp when the task status was last updated. - /// - [JsonPropertyName("lastUpdatedAt")] - public required DateTimeOffset LastUpdatedAt { get; set; } - - /// - /// Gets or sets the time to live (retention duration) from creation before the task may be deleted. - /// - [JsonPropertyName("ttl")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? TimeToLive { get; set; } - - /// - /// Gets or sets the suggested time between status checks. - /// - [JsonPropertyName("pollInterval")] - [JsonConverter(typeof(TimeSpanMillisecondsConverter))] - public TimeSpan? PollInterval { get; set; } -} diff --git a/src/ModelContextProtocol.Core/Protocol/McpTasksCapability.cs b/src/ModelContextProtocol.Core/Protocol/McpTasksCapability.cs deleted file mode 100644 index 1b3ccd9dd..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpTasksCapability.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents the tasks capability configuration for servers and clients. -/// -/// -/// -/// The tasks capability enables requestors (clients or servers) to augment their requests with -/// tasks for long-running operations. Tasks are durable state machines that carry information -/// about the underlying execution state of requests. -/// -/// -/// During initialization, both parties exchange their tasks capabilities to establish which -/// operations support task-based execution. Requestors should only augment requests with a -/// task if the corresponding capability has been declared by the receiver. -/// -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class McpTasksCapability -{ - /// - /// Gets or sets whether this party supports the tasks/list operation. - /// - /// - /// When present, indicates support for listing all tasks. - /// - [JsonPropertyName("list")] - public ListMcpTasksCapability? List { get; set; } - - /// - /// Gets or sets whether this party supports the tasks/cancel operation. - /// - /// - /// When present, indicates support for cancelling tasks. - /// - [JsonPropertyName("cancel")] - public CancelMcpTasksCapability? Cancel { get; set; } - - /// - /// Gets or sets which request types support task augmentation. - /// - /// - /// - /// The set of capabilities in this property is exhaustive. If a request type is not present, - /// it does not support task augmentation. - /// - /// - /// For servers, this typically includes tools/call. For clients, this typically includes - /// sampling/createMessage and elicitation/create. - /// - /// - [JsonPropertyName("requests")] - public RequestMcpTasksCapability? Requests { get; set; } -} - -/// -/// Represents task support for tool-specific requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class RequestMcpTasksCapability -{ - /// - /// Gets or sets task support for tool-related requests. - /// - [JsonPropertyName("tools")] - public ToolsMcpTasksCapability? Tools { get; set; } - - /// - /// Gets or sets task support for sampling-related requests. - /// - [JsonPropertyName("sampling")] - public SamplingMcpTasksCapability? Sampling { get; set; } - - /// - /// Gets or sets task support for elicitation-related requests. - /// - [JsonPropertyName("elicitation")] - public ElicitationMcpTasksCapability? Elicitation { get; set; } -} - -/// -/// Represents task support for tool-related requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class ToolsMcpTasksCapability -{ - /// - /// Gets or sets whether tools/call requests support task augmentation. - /// - /// - /// When present, indicates that the server supports task-augmented tools/call requests. - /// - [JsonPropertyName("call")] - public CallToolMcpTasksCapability? Call { get; set; } -} - -/// -/// Represents task support for sampling-related requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class SamplingMcpTasksCapability -{ - /// - /// Gets or sets whether sampling/createMessage requests support task augmentation. - /// - /// - /// When present, indicates that the client supports task-augmented sampling/createMessage requests. - /// - [JsonPropertyName("createMessage")] - public CreateMessageMcpTasksCapability? CreateMessage { get; set; } -} - -/// -/// Represents task support for elicitation-related requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class ElicitationMcpTasksCapability -{ - /// - /// Gets or sets whether elicitation/create requests support task augmentation. - /// - /// - /// When present, indicates that the client supports task-augmented elicitation/create requests. - /// - [JsonPropertyName("create")] - public CreateElicitationMcpTasksCapability? Create { get; set; } -} - -/// -/// Represents the capability for listing tasks. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class ListMcpTasksCapability; - -/// -/// Represents the capability for cancelling tasks. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class CancelMcpTasksCapability; - -/// -/// Represents the capability for task-augmented tools/call requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class CallToolMcpTasksCapability; - -/// -/// Represents the capability for task-augmented sampling/createMessage requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class CreateMessageMcpTasksCapability; - -/// -/// Represents the capability for task-augmented elicitation/create requests. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class CreateElicitationMcpTasksCapability; diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 949361650..cab98a5bc 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -143,39 +143,12 @@ public static class NotificationMethods public const string CancelledNotification = "notifications/cancelled"; /// - /// The name of the notification sent when a task status changes. + /// The name of the notification sent by the server to push task status updates to subscribed clients. /// /// - /// - /// When a task status changes, receivers may send this notification to inform the requestor - /// of the change. This notification includes the full task state. - /// - /// - /// Requestors must not rely on receiving this notification, as it is optional. Receivers - /// are not required to send status notifications and may choose to only send them for - /// certain status transitions. Requestors should continue to poll via tasks/get to ensure - /// they receive status updates. - /// - /// - public const string TaskStatusNotification = "notifications/tasks/status"; - - /// - /// The metadata key used to associate requests, responses, and notifications with a task. - /// - /// - /// - /// This constant defines the key "io.modelcontextprotocol/related-task" used in the - /// _meta field to associate messages with their originating task across the entire - /// request lifecycle. - /// - /// - /// For example, an elicitation that a task-augmented tool call depends on must share the - /// same related task ID with that tool call's task. - /// - /// - /// For tasks/get, tasks/list, and tasks/cancel operations, this - /// metadata should not be included as the taskId is already present in the message structure. - /// + /// Part of the io.modelcontextprotocol/tasks extension. + /// Each notification carries a complete task state for the current status, identical to what + /// tasks/get would have returned at that moment. /// - public const string RelatedTaskMetaKey = "io.modelcontextprotocol/related-task"; + public const string TaskStatusNotification = "notifications/tasks"; } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index e0118fa57..6967dd07d 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -123,30 +123,29 @@ public static class RequestMethods public const string Initialize = "initialize"; /// - /// The name of the request method to retrieve task status. + /// The name of the request method sent from the client to poll for task completion. /// /// - /// Requestors poll for task completion by sending tasks/get requests. They should respect - /// the pollInterval provided in responses when determining polling frequency. + /// Part of the io.modelcontextprotocol/tasks extension. + /// Clients poll for task status by sending this request with the task ID. /// public const string TasksGet = "tasks/get"; /// - /// The name of the request method to retrieve the result of a completed task. + /// The name of the request method sent from the client to provide input responses to a task. /// /// - /// This request blocks until the task reaches a terminal status (completed, failed, or cancelled). - /// The result structure matches the original request type (e.g., CallToolResult for tools/call). + /// Part of the io.modelcontextprotocol/tasks extension. + /// Used when a task has input_required status and the client needs to fulfill outstanding requests. /// - public const string TasksResult = "tasks/result"; + public const string TasksUpdate = "tasks/update"; /// - /// The name of the request method to retrieve a list of tasks with pagination support. - /// - public const string TasksList = "tasks/list"; - - /// - /// The name of the request method to explicitly cancel a task. + /// The name of the request method sent from the client to signal intent to cancel a task. /// + /// + /// Part of the io.modelcontextprotocol/tasks extension. + /// Cancellation is cooperative — the server decides whether and when to honor it. + /// public const string TasksCancel = "tasks/cancel"; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 6e43249a1..15eb6fa46 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -30,6 +30,8 @@ private protected Result() /// When absent or set to "complete", the result is a normal completed response. /// When set to "input_required", the result is an indicating /// that additional input is needed before the request can be completed. + /// When set to "task", the result is a indicating that the server + /// has created a long-running task in lieu of returning the result directly. /// /// /// Defaults to , which is equivalent to "complete". diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs new file mode 100644 index 000000000..87b857470 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs @@ -0,0 +1,76 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a request that supports task-augmented execution, which may be either +/// the standard result or a indicating asynchronous processing. +/// +/// The standard result type for the request (e.g., ). +/// +/// +/// When a server supports the io.modelcontextprotocol/tasks extension and the client declares +/// the extension capability on its request, the server may return a +/// instead of the standard result. This type represents that polymorphic response. +/// +/// +/// Use to determine which variant was returned, then access either +/// for the immediate result or for the task handle. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public class ResultOrCreatedTask where TResult : Result +{ + private readonly TResult? _result; + private readonly CreateTaskResult? _taskCreated; + + /// + /// Initializes a new instance of with an immediate result. + /// + /// The standard result returned by the server. + public ResultOrCreatedTask(TResult result) + { + Throw.IfNull(result); + _result = result; + } + + /// + /// Initializes a new instance of with a task handle. + /// + /// The task creation result returned by the server. + public ResultOrCreatedTask(CreateTaskResult taskCreated) + { + Throw.IfNull(taskCreated); + _taskCreated = taskCreated; + } + + /// + /// Gets a value indicating whether the server created a task instead of returning an immediate result. + /// + public bool IsTask => _taskCreated is not null; + + /// + /// Gets the immediate result, or if the server created a task. + /// + public TResult? Result => _result; + + /// + /// Gets the task creation result, or if the server returned an immediate result. + /// + public CreateTaskResult? TaskCreated => _taskCreated; + + /// + /// Implicitly converts a to a + /// wrapping the immediate result. + /// + /// The result to wrap. + public static implicit operator ResultOrCreatedTask(TResult result) => new(result); + + /// + /// Implicitly converts a to a + /// wrapping the task handle. + /// + /// The task creation result to wrap. + public static implicit operator ResultOrCreatedTask(CreateTaskResult taskCreated) => new(taskCreated); +} diff --git a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs index d4e23a66f..92ffff424 100644 --- a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs @@ -67,32 +67,6 @@ public sealed class ServerCapabilities [JsonPropertyName("completions")] public CompletionsCapability? Completions { get; set; } - /// - /// Gets or sets a server's tasks capability for supporting task-augmented requests. - /// - /// - /// - /// The tasks capability enables clients to augment their requests with tasks for long-running - /// operations. When present, clients can request that certain operations (like tool calls) - /// execute asynchronously, with the ability to poll for status and retrieve results later. - /// - /// - /// See for details on configuring which operations support tasks. - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public McpTasksCapability? Tasks - { - get => TasksCore; - set => TasksCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("tasks")] - internal McpTasksCapability? TasksCore { get; set; } - /// /// Gets or sets optional MCP extensions that the server supports. /// diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs new file mode 100644 index 000000000..e1bbb2f73 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs @@ -0,0 +1,393 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters for a notifications/tasks notification sent by the server +/// to push task status updates to the client. +/// +/// +/// +/// Each notification carries a complete task state for the current status, identical to what +/// tasks/get would have returned at that moment. The concrete type depends on the task's +/// current status: +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// To receive task status notifications, clients send a subscriptions/listen request +/// including the task IDs they are interested in. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +[JsonConverter(typeof(Converter))] +public abstract class TaskStatusNotificationParams : NotificationParams +{ + /// Prevent external derivations. + private protected TaskStatusNotificationParams() + { + } + + /// + /// Gets or sets the stable identifier for this task. + /// + [JsonPropertyName("taskId")] + public required string TaskId { get; set; } + + /// + /// Gets or sets the current task status. + /// + [JsonPropertyName("status")] + public abstract McpTaskStatus Status { get; } + + /// + /// Gets or sets an optional message describing the current task state. + /// + [JsonPropertyName("statusMessage")] + public string? StatusMessage { get; set; } + + /// + /// Gets or sets the ISO 8601 timestamp when the task was created. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the ISO 8601 timestamp when the task was last updated. + /// + [JsonPropertyName("lastUpdatedAt")] + public required DateTimeOffset LastUpdatedAt { get; set; } + + /// + /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// + [JsonPropertyName("ttlMs")] + public long? TtlMs { get; set; } + + /// + /// Gets or sets the suggested polling interval in milliseconds. + /// + [JsonPropertyName("pollIntervalMs")] + public long? PollIntervalMs { get; set; } + + /// + /// JSON converter that deserializes to the appropriate + /// concrete subtype based on the status discriminator field. + /// + internal sealed class Converter : JsonConverter + { + public override TaskStatusNotificationParams? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for TaskStatusNotificationParams."); + } + + string? taskId = null; + string? statusString = null; + string? statusMessage = null; + DateTimeOffset? createdAt = null; + DateTimeOffset? lastUpdatedAt = null; + long? ttlMs = null; + long? pollIntervalMs = null; + JsonObject? meta = null; + JsonElement? result = null; + JsonElement? error = null; + Dictionary? inputRequests = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "taskId": + taskId = reader.GetString(); + break; + case "status": + statusString = reader.GetString(); + break; + case "statusMessage": + statusMessage = reader.GetString(); + break; + case "createdAt": + createdAt = reader.GetDateTimeOffset(); + break; + case "lastUpdatedAt": + lastUpdatedAt = reader.GetDateTimeOffset(); + break; + case "ttlMs": + ttlMs = reader.GetInt64(); + break; + case "pollIntervalMs": + pollIntervalMs = reader.GetInt64(); + break; + case "_meta": + meta = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + break; + case "result": + result = JsonElement.ParseValue(ref reader); + break; + case "error": + error = JsonElement.ParseValue(ref reader); + break; + case "inputRequests": + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("'inputRequests' must be a JSON object."); + } + inputRequests = new Dictionary(StringComparer.Ordinal); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name in 'inputRequests'."); + } + string requestKey = reader.GetString()!; + reader.Read(); + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); + inputRequests[requestKey] = inputRequest; + } + break; + default: + reader.Skip(); + break; + } + } + + if (taskId is null) + { + throw new JsonException("Missing required 'taskId' property on TaskStatusNotificationParams."); + } + + if (statusString is null) + { + throw new JsonException("Missing required 'status' property on TaskStatusNotificationParams."); + } + + if (createdAt is null) + { + throw new JsonException("Missing required 'createdAt' property on TaskStatusNotificationParams."); + } + + if (lastUpdatedAt is null) + { + throw new JsonException("Missing required 'lastUpdatedAt' property on TaskStatusNotificationParams."); + } + + TaskStatusNotificationParams notification = statusString switch + { + "working" => new WorkingTaskNotificationParams + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + }, + "completed" => result is not null + ? new CompletedTaskNotificationParams + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + Result = result.Value, + } + : throw new JsonException("Completed task notification is missing required 'result' property."), + "failed" => error is not null + ? new FailedTaskNotificationParams + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + Error = error.Value, + } + : throw new JsonException("Failed task notification is missing required 'error' property."), + "cancelled" => new CancelledTaskNotificationParams + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + }, + "input_required" => inputRequests is not null + ? new InputRequiredTaskNotificationParams + { + TaskId = taskId, + CreatedAt = createdAt.Value, + LastUpdatedAt = lastUpdatedAt.Value, + InputRequests = inputRequests, + } + : throw new JsonException("Input-required task notification is missing required 'inputRequests' property."), + _ => throw new JsonException($"Unknown task status: '{statusString}'.") + }; + + notification.StatusMessage = statusMessage; + notification.TtlMs = ttlMs; + notification.PollIntervalMs = pollIntervalMs; + notification.Meta = meta; + + return notification; + } + + public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value.Meta is not null) + { + writer.WritePropertyName("_meta"); + JsonSerializer.Serialize(writer, value.Meta, options.GetTypeInfo()); + } + + writer.WriteString("taskId", value.TaskId); + writer.WriteString("status", value.Status switch + { + McpTaskStatus.Working => "working", + McpTaskStatus.Completed => "completed", + McpTaskStatus.Failed => "failed", + McpTaskStatus.Cancelled => "cancelled", + McpTaskStatus.InputRequired => "input_required", + _ => throw new JsonException($"Unknown McpTaskStatus: {value.Status}") + }); + + if (value.StatusMessage is not null) + { + writer.WriteString("statusMessage", value.StatusMessage); + } + + writer.WriteString("createdAt", value.CreatedAt); + writer.WriteString("lastUpdatedAt", value.LastUpdatedAt); + + if (value.TtlMs is not null) + { + writer.WriteNumber("ttlMs", value.TtlMs.Value); + } + + if (value.PollIntervalMs is not null) + { + writer.WriteNumber("pollIntervalMs", value.PollIntervalMs.Value); + } + + switch (value) + { + case CompletedTaskNotificationParams completed: + writer.WritePropertyName("result"); + completed.Result.WriteTo(writer); + break; + case FailedTaskNotificationParams failed: + writer.WritePropertyName("error"); + failed.Error.WriteTo(writer); + break; + case InputRequiredTaskNotificationParams inputRequired: + writer.WritePropertyName("inputRequests"); + writer.WriteStartObject(); + if (inputRequired.InputRequests is { } reqs) + { + foreach (var kvp in reqs) + { + writer.WritePropertyName(kvp.Key); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + } + } + writer.WriteEndObject(); + break; + } + + writer.WriteEndObject(); + } + } +} + +/// +/// Task notification for a task that is currently being processed. +/// +public sealed class WorkingTaskNotificationParams : TaskStatusNotificationParams +{ + /// + public override McpTaskStatus Status => McpTaskStatus.Working; +} + +/// +/// Task notification for a task that has completed successfully. +/// +public sealed class CompletedTaskNotificationParams : TaskStatusNotificationParams +{ + /// + public override McpTaskStatus Status => McpTaskStatus.Completed; + + /// + /// Gets or sets the final result of the task. + /// + [JsonPropertyName("result")] + public required JsonElement Result { get; set; } +} + +/// +/// Task notification for a task that failed. +/// +public sealed class FailedTaskNotificationParams : TaskStatusNotificationParams +{ + /// + public override McpTaskStatus Status => McpTaskStatus.Failed; + + /// + /// Gets or sets the JSON-RPC error that caused the task to fail. + /// + [JsonPropertyName("error")] + public required JsonElement Error { get; set; } +} + +/// +/// Task notification for a task that was cancelled. +/// +public sealed class CancelledTaskNotificationParams : TaskStatusNotificationParams +{ + /// + public override McpTaskStatus Status => McpTaskStatus.Cancelled; +} + +/// +/// Task notification for a task that requires input from the client. +/// +public sealed class InputRequiredTaskNotificationParams : TaskStatusNotificationParams +{ + /// + public override McpTaskStatus Status => McpTaskStatus.InputRequired; + + /// + /// Gets or sets the server-to-client requests that need to be fulfilled. + /// + /// + /// Keys are arbitrary identifiers for matching requests to responses. Each value is an + /// wrapping the server-to-client request payload, matching + /// the typed format defined by the Multi Round-Trip Requests (MRTR) extension (SEP-2322). + /// + [JsonPropertyName("inputRequests")] + public IDictionary? InputRequests { get; set; } +} + diff --git a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs deleted file mode 100644 index e789db186..000000000 --- a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Provides a JSON converter for that serializes as integer milliseconds. -/// -/// -/// This converter serializes TimeSpan values as the total number of milliseconds (as an integer), -/// and deserializes integer millisecond values back to TimeSpan. System.Text.Json automatically -/// handles nullable TimeSpan properties using this converter. -/// -[EditorBrowsable(EditorBrowsableState.Never)] -public sealed class TimeSpanMillisecondsConverter : JsonConverter -{ - /// - public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType is JsonTokenType.Number) - { - if (reader.TryGetInt64(out long milliseconds)) - { - return TimeSpan.FromMilliseconds(milliseconds); - } - - // For non-integer values, convert from fractional milliseconds - double fractionalMilliseconds = reader.GetDouble(); - return TimeSpan.FromTicks((long)(fractionalMilliseconds * TimeSpan.TicksPerMillisecond)); - } - - throw new JsonException($"Unable to convert {reader.TokenType} to TimeSpan."); - } - - /// - public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) - { - writer.WriteNumberValue((long)value.TotalMilliseconds); - } -} diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index ce17d9f7d..8974e3bb5 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -120,26 +120,6 @@ public JsonElement? OutputSchema [JsonPropertyName("annotations")] public ToolAnnotations? Annotations { get; set; } - /// - /// Gets or sets execution-related metadata for this tool. - /// - /// - /// This property provides hints about how the tool should be executed, particularly - /// regarding task augmentation support. See for details. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - [JsonIgnore] - public ToolExecution? Execution - { - get => ExecutionCore; - set => ExecutionCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] - [JsonPropertyName("execution")] - internal ToolExecution? ExecutionCore { get; set; } - /// /// Gets or sets an optional list of icons for this tool. /// diff --git a/src/ModelContextProtocol.Core/Protocol/ToolExecution.cs b/src/ModelContextProtocol.Core/Protocol/ToolExecution.cs deleted file mode 100644 index 174298471..000000000 --- a/src/ModelContextProtocol.Core/Protocol/ToolExecution.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Protocol; - -/// -/// Represents execution-related metadata for a tool. -/// -/// -/// This type provides hints about how a tool should be executed, particularly -/// regarding task augmentation support. -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class ToolExecution -{ - /// - /// Gets or sets the level of task augmentation support for this tool. - /// - /// - /// - /// This property declares whether a tool supports task-augmented execution: - /// - /// : Clients must not attempt to invoke - /// the tool as a task. This is the default behavior. - /// : Clients may invoke the tool as a task - /// or as a normal request. - /// : Clients must invoke the tool as a task. - /// - /// - /// - /// - /// This is a fine-grained layer in addition to server capabilities. Even if a server's capabilities - /// include tasks.requests.tools.call, this property controls whether each specific tool supports tasks. - /// - /// - [JsonPropertyName("taskSupport")] - public ToolTaskSupport? TaskSupport { get; set; } -} - -/// -/// Represents the level of task augmentation support for a tool. -/// -/// -/// -/// This enum defines how a tool interacts with the task augmentation system: -/// -/// : Task augmentation is not allowed (default) -/// : Task augmentation is supported but not required -/// : Task augmentation is mandatory -/// -/// -/// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ToolTaskSupport -{ - /// - /// Clients must not attempt to invoke the tool as a task. - /// - /// - /// This is the default behavior. Servers should return a -32601 (Method not found) error - /// if a client attempts to invoke the tool as a task when this is set. - /// - [JsonStringEnumMemberName("forbidden")] - Forbidden, - - /// - /// Clients may invoke the tool as a task or as a normal request. - /// - /// - /// When this is set, clients can choose whether to use task augmentation based on their needs. - /// - [JsonStringEnumMemberName("optional")] - Optional, - - /// - /// Clients must invoke the tool as a task. - /// - /// - /// Servers must return a -32601 (Method not found) error if a client does not attempt - /// to invoke the tool as a task when this is set. - /// - [JsonStringEnumMemberName("required")] - Required -} diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs new file mode 100644 index 000000000..07b45de15 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters for a tasks/update request to provide input responses +/// to outstanding server-to-client requests on a task. +/// +/// +/// +/// When a task requires input from the client (indicated by ), +/// the server includes outstanding requests in the inputRequests field of the tasks/get response. +/// The client provides responses via the inherited field in +/// tasks/update requests; the wire format matches the typed envelope defined by the Multi Round-Trip +/// Requests (MRTR) extension (SEP-2322). +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class UpdateTaskRequestParams : RequestParams +{ + /// + /// Gets or sets the identifier of the task to update. + /// + [JsonPropertyName("taskId")] + public required string TaskId { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs new file mode 100644 index 000000000..531039c23 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a tasks/update request. This is an empty acknowledgement. +/// +/// +/// +/// On success, the server acknowledges the request with an empty result. +/// The acknowledgement is eventually consistent: the server may accept the responses and +/// return the ack before the task's observable status reflects them. +/// +/// +/// See the SEP-2663 +/// specification for details. +/// +/// +public sealed class UpdateTaskResult : Result +{ +} diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index 97e8b95df..f15ce316c 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -45,4 +45,49 @@ public void Set( return JsonSerializer.SerializeToNode(result, responseTypeInfo); }; } + + /// + /// Registers a handler that may return either a standard result or a + /// for task-augmented execution. + /// + public void SetTaskAugmented( + string method, + Func>> handler, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo, + JsonTypeInfo taskResultTypeInfo) + where TResult : Result + { + Throw.IfNull(method); + Throw.IfNull(handler); + Throw.IfNull(requestTypeInfo); + Throw.IfNull(responseTypeInfo); + Throw.IfNull(taskResultTypeInfo); + + this[method] = async (request, cancellationToken) => + { + TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; + var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); + + if (augmented.IsTask) + { + // Guard against a misconfiguration where a handler opts into task-augmented + // execution but the server has no task lifecycle handlers wired up. Without + // tasks/get, a client that received a CreateTaskResult would have no way to + // poll the task to completion. Configure McpServerOptions.TaskStore or set + // the task handlers explicitly via McpServerOptions.Handlers. + if (!ContainsKey(RequestMethods.TasksGet)) + { + throw new InvalidOperationException( + $"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " + + $"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " + + "or set the task handlers explicitly in McpServerOptions.Handlers before starting the server."); + } + + return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo); + } + + return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); + }; + } } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 5951d7e41..ae9c3ca45 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -154,23 +154,6 @@ options.OpenWorld is not null || tool.Meta = function.UnderlyingMethod is not null ? CreateMetaFromAttributes(function.UnderlyingMethod, options.Meta) : options.Meta; - - // Apply user-specified Execution settings if provided - if (options.Execution is not null) - { - tool.Execution = options.Execution; - } - } - - // Auto-detect async methods and mark with taskSupport = "optional" unless explicitly configured. - // This enables implicit task support for async tools: clients can choose to invoke them - // synchronously (wait for completion) or as a task (receive taskId, poll for result). - if (function.UnderlyingMethod is not null && - IsAsyncMethod(function.UnderlyingMethod) && - tool.Execution?.TaskSupport is null) - { - tool.Execution ??= new ToolExecution(); - tool.Execution.TaskSupport = ToolTaskSupport.Optional; } return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); @@ -218,12 +201,6 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, inferenceOptions: newOptions.SchemaCreateOptions); } - - if (toolAttr._taskSupport is { } taskSupport) - { - newOptions.Execution ??= new ToolExecution(); - newOptions.Execution.TaskSupport ??= taskSupport; - } } if (method.GetCustomAttribute() is { } descAttr) @@ -350,27 +327,27 @@ internal static string DeriveName(MethodInfo method, JsonNamingPolicy? policy = // Case the name based on the provided naming policy. return (policy ?? JsonNamingPolicy.SnakeCaseLower).ConvertName(name) ?? name; - } - private static bool IsAsyncMethod(MethodInfo method) - { - Type t = method.ReturnType; - - if (t == typeof(Task) || t == typeof(ValueTask)) + static bool IsAsyncMethod(MethodInfo method) { - return true; - } + Type t = method.ReturnType; - if (t.IsGenericType) - { - t = t.GetGenericTypeDefinition(); - if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>)) + if (t == typeof(Task) || t == typeof(ValueTask)) { return true; } - } - return false; + if (t.IsGenericType) + { + t = t.GetGenericTypeDefinition(); + if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>)) + { + return true; + } + } + + return false; + } } /// Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item. diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs index d322d21ef..9740ca378 100644 --- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs @@ -2,165 +2,155 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; -namespace ModelContextProtocol; +namespace ModelContextProtocol.Server; /// -/// Provides an interface for pluggable task storage implementations in MCP servers. +/// Provides an interface for storing and managing the lifecycle of MCP tasks. /// /// /// -/// The task store is responsible for managing the lifecycle of tasks, including creation, -/// status updates, result storage, and retrieval. Implementations must be thread-safe and -/// may support session-based isolation for multi-session scenarios. +/// The task store manages the state of tasks created by the server's request handling pipeline. +/// When a client signals support for the io.modelcontextprotocol/tasks extension on a request, +/// the server creates a task in the store, executes the work in the background, and stores the result +/// upon completion. /// /// -/// TTL (Time To Live) Management: Implementations may override the requested TTL value in -/// to enforce resource limits. The actual TTL -/// used is returned in the property. A null TTL indicates -/// unlimited lifetime. Tasks may be deleted after their TTL expires, regardless of status. +/// Implementations must be thread-safe. The store also provides the backing implementation for +/// tasks/get, tasks/update, and tasks/cancel protocol methods. +/// +/// +/// Lifetime under stateless HTTP: when the server is configured for stateless HTTP +/// (each request creates a fresh server instance), the same instance +/// MUST be shared across requests — either by registering the store as a singleton in the DI +/// container, or by backing it with external storage (database, distributed cache, etc.) that +/// every server instance can reach. Otherwise tasks/get polls issued on subsequent +/// requests will see an empty in-memory store and never find the task they are polling for. +/// +/// +/// See the SEP-2663 +/// specification for details on the tasks extension. /// /// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] +[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public interface IMcpTaskStore { /// - /// Creates a new task for tracking an asynchronous operation. + /// Creates a new task for tracking an asynchronous execution. /// - /// Metadata for the task, including requested TTL. - /// The JSON-RPC request ID that initiated this task. - /// The original JSON-RPC request that triggered task creation. - /// Optional session identifier for multi-session isolation. /// Cancellation token for the operation. /// - /// A new with a unique task ID, initial status of , - /// and the actual TTL that will be used (which may differ from the requested TTL). + /// A with a unique task ID, initial status of , + /// and timing metadata (TTL, poll interval). /// /// - /// Implementations must generate a unique task ID and set the - /// and timestamps. The implementation may override the - /// requested TTL to enforce storage limits. + /// + /// Implementations must generate a unique task ID and set appropriate timestamps. + /// The server infrastructure maps the returned to the appropriate + /// protocol response type when communicating with clients. + /// + /// + /// Per the MCP specification (SEP-2663 §306), the returned task MUST be durably created + /// before this method completes: a subsequent with the returned + /// MUST resolve, even if it runs on a different process or + /// node. Implementations backed by eventually-consistent storage must therefore wait for the + /// write to be visible (e.g., quorum acknowledgement, write-through, or an equivalent + /// barrier) before returning. + /// /// - Task CreateTaskAsync( - McpTaskMetadata taskParams, - RequestId requestId, - JsonRpcRequest request, - string? sessionId = null, - CancellationToken cancellationToken = default); + Task CreateTaskAsync(CancellationToken cancellationToken = default); /// - /// Retrieves a task by its unique identifier. + /// Retrieves the current state of a task. /// /// The unique identifier of the task to retrieve. - /// Optional session identifier for access control. /// Cancellation token for the operation. /// - /// The if found and accessible, otherwise . + /// A representing the current task state, + /// or if the task does not exist. /// - /// - /// Returns null if the task does not exist or if session-based access control denies access. - /// - Task GetTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default); + Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default); /// - /// Stores the final result of a task that has reached a terminal status. + /// Stores the result of a completed execution, transitioning the task to . /// /// The unique identifier of the task. - /// The terminal status: or . - /// The operation result to store as a JSON element. - /// Optional session identifier for access control. + /// The serialized result payload. /// Cancellation token for the operation. - /// The updated with the new status and result stored. - /// - /// - /// The must be either or - /// . This method updates the task status and stores - /// the result for later retrieval via . - /// - /// - /// Implementations should throw if called on a task - /// that is already in a terminal state, to prevent result overwrites. - /// - /// - Task StoreTaskResultAsync( - string taskId, - McpTaskStatus status, - JsonElement result, - string? sessionId = null, - CancellationToken cancellationToken = default); + /// A task representing the asynchronous operation. + Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default); /// - /// Retrieves the stored result of a completed or failed task. + /// Marks a task as failed, transitioning it to . /// /// The unique identifier of the task. - /// Optional session identifier for access control. + /// The serialized error information. /// Cancellation token for the operation. - /// The stored operation result as a JSON element. - /// - /// This method should only be called on tasks in terminal states ( - /// or ). The result contains the JSON representation of the - /// original operation result (e.g., for tools/call). - /// - Task GetTaskResultAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default); + /// A task representing the asynchronous operation. + Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default); + + /// + /// Transitions the task to . + /// + /// The unique identifier of the task to cancel. + /// Cancellation token for the operation. + /// + /// if the task was successfully cancelled; + /// if the task does not exist or was already in a terminal state. + /// + Task SetCancelledAsync(string taskId, CancellationToken cancellationToken = default); /// - /// Updates the status and optional status message of a task. + /// Removes input requests that have been satisfied by the provided responses and + /// raises for each resolved entry. /// /// The unique identifier of the task. - /// The new status to set. - /// Optional diagnostic message describing the status change. - /// Optional session identifier for access control. + /// + /// The input responses keyed by the original request identifier. + /// Matched input requests are removed from the task's pending set. + /// /// Cancellation token for the operation. - /// The updated with the new status applied. /// - /// This method updates the task's , , - /// and properties. Common uses include transitioning to - /// , , or updating - /// progress messages while in status. + /// + /// After removing the satisfied requests, if no pending input requests remain the task + /// transitions back to . Otherwise it remains in + /// . + /// + /// + /// Implementations must raise for each entry in + /// after updating the store state. In distributed + /// deployments, this event enables the originating server to be notified even if a + /// different server instance processes the tasks/update request. + /// /// - Task UpdateTaskStatusAsync( + Task ResolveInputRequestsAsync( string taskId, - McpTaskStatus status, - string? statusMessage, - string? sessionId = null, + IDictionary inputResponses, CancellationToken cancellationToken = default); /// - /// Lists tasks with pagination support. + /// Occurs when an input response is resolved for a task. /// - /// Optional cursor for pagination, from a previous call's nextCursor value. - /// Optional session identifier for filtering tasks by session. - /// Cancellation token for the operation. - /// A containing the tasks and an optional cursor for the next page. /// - /// When is provided, implementations should filter to only return - /// tasks associated with that session. The cursor format is implementation-specific. + /// Implementations must raise this event for each input response resolved in + /// . Subscribers use this to complete + /// pending input request waiters (e.g., elicitation or sampling calls that are + /// awaiting a client response). /// - Task ListTasksAsync( - string? cursor = null, - string? sessionId = null, - CancellationToken cancellationToken = default); + event Action? InputResponseReceived; /// - /// Attempts to cancel a task, transitioning it to status. + /// Adds input requests to a task, transitioning it to . /// - /// The unique identifier of the task to cancel. - /// Optional session identifier for access control. + /// The unique identifier of the task. + /// + /// The input requests to add. Keys are arbitrary identifiers for matching requests to responses. + /// Each value is an wrapping the server-to-client request payload. + /// New requests are merged with any existing pending requests. + /// /// Cancellation token for the operation. - /// - /// The updated . If the task is already in a terminal state - /// (, , or - /// ), the task is returned unchanged. - /// - /// - /// - /// This method must be idempotent. If called on a task that is already in a terminal state, - /// it returns the current task without error. This behavior differs from the MCP specification - /// but ensures idempotency and avoids race conditions between cancellation and task completion. - /// - /// - /// For tasks not in a terminal state, the implementation should attempt to stop the underlying - /// operation and transition the task to status before returning. - /// - /// - Task CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default); + /// A task representing the asynchronous operation. + Task SetInputRequestsAsync( + string taskId, + IDictionary inputRequests, + CancellationToken cancellationToken = default); } diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index b2f9b050d..a13af13e9 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -1,543 +1,222 @@ using ModelContextProtocol.Protocol; using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -#if MCP_TEST_TIME_PROVIDER -namespace ModelContextProtocol.Tests.Internal; -#else -namespace ModelContextProtocol; -#endif +namespace ModelContextProtocol.Server; /// -/// Provides an in-memory implementation of for development and testing. +/// Provides an in-memory implementation of for development and testing scenarios. /// /// /// -/// This implementation uses thread-safe concurrent collections and is suitable for single-server -/// scenarios and testing. It is not recommended for production multi-server deployments as tasks -/// are stored only in memory and are lost on server restart. +/// This implementation stores all task state in memory using immutable snapshots and +/// compare-and-swap updates for thread safety without locks. +/// Tasks are not persisted across process restarts. /// /// -/// Features: -/// -/// Thread-safe operations using -/// Automatic TTL-based cleanup via background task -/// Session-based isolation when sessionId is provided -/// Configurable default TTL and maximum TTL limits -/// +/// For production scenarios requiring durability, session isolation, or TTL-based cleanup, +/// implement a custom . /// /// -[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] -public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable +[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] +public class InMemoryMcpTaskStore : IMcpTaskStore { - private readonly ConcurrentDictionary _tasks = new(); - private readonly TimeSpan? _defaultTtl; - private readonly TimeSpan? _maxTtl; - private readonly TimeSpan _pollInterval; -#if MCP_TEST_TIME_PROVIDER - private readonly ITimer? _cleanupTimer; -#else - private readonly Timer? _cleanupTimer; -#endif - private readonly int _pageSize; - private readonly int? _maxTasks; - private readonly int? _maxTasksPerSession; -#if MCP_TEST_TIME_PROVIDER - private readonly TimeProvider _timeProvider; -#endif + private readonly ConcurrentDictionary _tasks = new(); /// - /// Initializes a new instance of the class. + /// Gets or sets the default poll interval in milliseconds for new tasks. /// - /// - /// Default TTL to use when task creation does not specify a TTL. Null means unlimited. - /// - /// - /// Maximum TTL allowed. If a task requests a longer TTL, it will be capped to this value. - /// Null means no maximum limit. - /// - /// - /// Advertised polling interval for tasks. Default is 1 second. - /// This value is used when creating new tasks to indicate how frequently clients should poll for updates. - /// - /// - /// Interval for running background cleanup of expired tasks. Default is 1 minute. - /// Pass to disable automatic cleanup. - /// - /// - /// Maximum number of tasks to return per page in . Default is 100. - /// - /// - /// Maximum number of tasks allowed in the store globally. Null means unlimited. - /// When the limit is reached, will throw . - /// - /// - /// Maximum number of tasks allowed per session. Null means unlimited. - /// When the limit is reached for a session, will throw . - /// - public InMemoryMcpTaskStore( - TimeSpan? defaultTtl = null, - TimeSpan? maxTtl = null, - TimeSpan? pollInterval = null, - TimeSpan? cleanupInterval = null, - int pageSize = 100, - int? maxTasks = null, - int? maxTasksPerSession = null) - { - if (defaultTtl.HasValue && maxTtl.HasValue && defaultTtl.Value > maxTtl.Value) - { - throw new ArgumentException( - $"Default TTL ({defaultTtl.Value}) cannot exceed maximum TTL ({maxTtl.Value}).", - nameof(defaultTtl)); - } - - pollInterval ??= TimeSpan.FromSeconds(1); - if (pollInterval <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException( - nameof(pollInterval), - pollInterval, - "Poll interval must be positive."); - } + /// The default is 1000 milliseconds. + public long DefaultPollIntervalMs { get; set; } = 1000; - if (pageSize <= 0) - { - throw new ArgumentOutOfRangeException( - nameof(pageSize), - pageSize, - "Page size must be positive."); - } + /// + /// Gets or sets the default time-to-live in milliseconds for new tasks, or for unlimited. + /// + public long? DefaultTtlMs { get; set; } - if (maxTasks is <= 0) - { - throw new ArgumentOutOfRangeException( - nameof(maxTasks), - maxTasks, - "Max tasks must be positive."); - } + /// + public Task CreateTaskAsync(CancellationToken cancellationToken = default) + { + var taskId = Guid.NewGuid().ToString("N"); + var now = DateTimeOffset.UtcNow; - if (maxTasksPerSession is <= 0) - { - throw new ArgumentOutOfRangeException( - nameof(maxTasksPerSession), - maxTasksPerSession, - "Max tasks per session must be positive."); - } + var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTtlMs, DefaultPollIntervalMs); + _tasks[taskId] = info; - _defaultTtl = defaultTtl; - _maxTtl = maxTtl; - _pollInterval = pollInterval.Value; - _pageSize = pageSize; - _maxTasks = maxTasks; - _maxTasksPerSession = maxTasksPerSession; -#if MCP_TEST_TIME_PROVIDER - _timeProvider = TimeProvider.System; -#endif - - cleanupInterval ??= TimeSpan.FromMinutes(1); - if (cleanupInterval.Value != Timeout.InfiniteTimeSpan) - { -#if MCP_TEST_TIME_PROVIDER - _cleanupTimer = _timeProvider.CreateTimer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value); -#else - _cleanupTimer = new Timer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value); -#endif - } + return Task.FromResult(info); } -#if MCP_TEST_TIME_PROVIDER - /// - /// Initializes a new instance of the class with a custom time provider. - /// This constructor is only available for testing purposes. - /// - internal InMemoryMcpTaskStore( - TimeSpan? defaultTtl, - TimeSpan? maxTtl, - TimeSpan? pollInterval, - TimeSpan? cleanupInterval, - int pageSize, - int? maxTasks, - int? maxTasksPerSession, - TimeProvider timeProvider) - : this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession) + /// + public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) { - _timeProvider = timeProvider ?? TimeProvider.System; + _tasks.TryGetValue(taskId, out var info); + return Task.FromResult(info); } -#endif /// - public Task CreateTaskAsync( - McpTaskMetadata taskParams, - RequestId requestId, - JsonRpcRequest request, - string? sessionId = null, - CancellationToken cancellationToken = default) + public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default) { - // Check global task limit - if (_maxTasks is { } maxTasks && _tasks.Count >= maxTasks) - { - throw new InvalidOperationException( - $"Maximum number of tasks ({maxTasks}) has been reached. Cannot create new task."); - } - - // Check per-session task limit - if (_maxTasksPerSession is { } maxPerSession && sessionId is not null) - { - var sessionTaskCount = _tasks.Values.Count(e => e.SessionId == sessionId && !IsExpired(e)); - if (sessionTaskCount >= maxPerSession) + Update(taskId, entry => IsTerminal(entry.Status) + ? entry + : entry with { - throw new InvalidOperationException( - $"Maximum number of tasks per session ({maxPerSession}) has been reached for session '{sessionId}'. Cannot create new task."); - } - } + Status = McpTaskStatus.Completed, + Result = result, + LastUpdatedAt = DateTimeOffset.UtcNow, + }); - var taskId = GenerateTaskId(); - var now = GetUtcNow(); - - // Determine TTL: use requested, fall back to default, respect max limit - var ttl = taskParams.TimeToLive ?? _defaultTtl; - if (ttl is { } ttlValue && _maxTtl is { } maxTtlValue && ttlValue > maxTtlValue) - { - ttl = maxTtlValue; - } - - TaskEntry entry = new() - { - TaskId = taskId, - Status = McpTaskStatus.Working, - CreatedAt = now, - LastUpdatedAt = now, - TimeToLive = ttl, - PollInterval = _pollInterval, - RequestId = requestId, - Request = request, - SessionId = sessionId - }; - - if (!_tasks.TryAdd(taskId, entry)) - { - // This should be extremely rare with GUID-based IDs - throw new InvalidOperationException($"Task ID collision: {taskId}"); - } - - return Task.FromResult(entry.ToMcpTask()); + return Task.CompletedTask; } /// - public Task GetTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default) + public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default) { - if (!_tasks.TryGetValue(taskId, out var entry)) - { - return Task.FromResult(null); - } - - // Enforce session isolation if sessionId is provided - if (sessionId != null && entry.SessionId != sessionId) - { - return Task.FromResult(null); - } + Update(taskId, entry => IsTerminal(entry.Status) + ? entry + : entry with + { + Status = McpTaskStatus.Failed, + Error = error, + LastUpdatedAt = DateTimeOffset.UtcNow, + }); - return Task.FromResult(entry.ToMcpTask()); + return Task.CompletedTask; } /// - public Task StoreTaskResultAsync( - string taskId, - McpTaskStatus status, - JsonElement result, - string? sessionId = null, - CancellationToken cancellationToken = default) + public Task SetCancelledAsync(string taskId, CancellationToken cancellationToken = default) { - if (status is not (McpTaskStatus.Completed or McpTaskStatus.Failed)) + if (!_tasks.TryGetValue(taskId, out var entry) || IsTerminal(entry.Status)) { - throw new ArgumentException( - $"Status must be {nameof(McpTaskStatus.Completed)} or {nameof(McpTaskStatus.Failed)}.", - nameof(status)); + return Task.FromResult(false); } - // Retry loop for optimistic concurrency - while (true) + bool transitioned = false; + Update(taskId, e => { - if (!_tasks.TryGetValue(taskId, out var entry)) - { - throw new InvalidOperationException($"Task not found: {taskId}"); - } - - // Enforce session isolation - if (sessionId != null && entry.SessionId != sessionId) - { - throw new InvalidOperationException($"Task not found: {taskId}"); - } - - // Prevent overwriting terminal state - if (IsTerminalStatus(entry.Status)) + if (IsTerminal(e.Status)) { - throw new InvalidOperationException( - $"Cannot store result for task in terminal state: {entry.Status}"); + return e; } - var updatedEntry = new TaskEntry(entry) - { - Status = status, - LastUpdatedAt = GetUtcNow(), - StoredResult = result - }; - - if (_tasks.TryUpdate(taskId, updatedEntry, entry)) - { - return Task.FromResult(updatedEntry.ToMcpTask()); - } + transitioned = true; + return e with { Status = McpTaskStatus.Cancelled, LastUpdatedAt = DateTimeOffset.UtcNow }; + }); - // Entry was modified by another thread, retry - } + return Task.FromResult(transitioned); } /// - public Task GetTaskResultAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default) - { - if (!_tasks.TryGetValue(taskId, out var entry)) - { - throw new InvalidOperationException($"Task not found: {taskId}"); - } - - // Enforce session isolation - if (sessionId != entry.SessionId) - { - throw new InvalidOperationException($"Invalid sessionId: {sessionId} provided for {taskId}"); - } - - if (entry.StoredResult is not { } storedResult) - { - throw new InvalidOperationException($"No result stored for task: {taskId}"); - } - - return Task.FromResult(storedResult); - } + public event Action? InputResponseReceived; /// - public Task UpdateTaskStatusAsync( + public Task ResolveInputRequestsAsync( string taskId, - McpTaskStatus status, - string? statusMessage, - string? sessionId = null, + IDictionary inputResponses, CancellationToken cancellationToken = default) { - // Retry loop for optimistic concurrency - while (true) + bool wasTerminal = false; + Update(taskId, entry => { - if (!_tasks.TryGetValue(taskId, out var entry)) + if (IsTerminal(entry.Status)) { - throw new InvalidOperationException($"Task not found: {taskId}"); + wasTerminal = true; + return entry; } - // Enforce session isolation - if (sessionId != null && entry.SessionId != sessionId) + var requests = entry.InputRequests as ImmutableDictionary + ?? entry.InputRequests?.ToImmutableDictionary() + ?? ImmutableDictionary.Empty; + + foreach (var key in inputResponses.Keys) { - throw new InvalidOperationException($"Task not found: {taskId}"); + requests = requests.Remove(key); } - var updatedEntry = new TaskEntry(entry) + var status = requests.IsEmpty ? McpTaskStatus.Working : entry.Status; + + return entry with { + InputRequests = requests, Status = status, - StatusMessage = statusMessage, - LastUpdatedAt = GetUtcNow(), + LastUpdatedAt = DateTimeOffset.UtcNow, }; + }); - if (_tasks.TryUpdate(taskId, updatedEntry, entry)) - { - return Task.FromResult(updatedEntry.ToMcpTask()); - } - - // Entry was modified by another thread, retry - } - } - - /// - public Task ListTasksAsync( - string? cursor = null, - string? sessionId = null, - CancellationToken cancellationToken = default) - { - // Stream enumeration - filter by session, exclude expired, apply keyset pagination - var query = _tasks.Values - .Where(e => sessionId == null || e.SessionId == sessionId) - .Where(e => !IsExpired(e)); - - // Apply keyset filter if cursor provided: TaskId > cursor - // UUID v7 task IDs are monotonically increasing and inherently time-ordered - if (cursor != null) + if (wasTerminal) { - query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0); + // Drop responses targeting a terminal task — there are no listeners that can act on them. + return Task.CompletedTask; } - // Order by TaskId for stable, deterministic pagination - // UUID v7 task IDs sort chronologically due to embedded timestamp - var page = query - .OrderBy(e => e.TaskId, StringComparer.Ordinal) - .Take(_pageSize + 1) // Take one extra to check if there's a next page - .Select(e => e.ToMcpTask()) - .ToList(); - - // Set nextCursor if we have more results - string? nextCursor; - if (page.Count > _pageSize) - { - var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return - nextCursor = lastItemInPage.TaskId; - page.RemoveAt(_pageSize); // Remove the extra item - } - else + foreach (var kvp in inputResponses) { - nextCursor = null; + InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs + { + TaskId = taskId, + RequestId = kvp.Key, + Response = kvp.Value, + }); } - return Task.FromResult(new ListTasksResult - { - Tasks = page.ToArray(), - NextCursor = nextCursor - }); + return Task.CompletedTask; } /// - public Task CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default) + public Task SetInputRequestsAsync( + string taskId, + IDictionary inputRequests, + CancellationToken cancellationToken = default) { - // Retry loop for optimistic concurrency - while (true) + Update(taskId, entry => { - if (!_tasks.TryGetValue(taskId, out var entry)) + if (IsTerminal(entry.Status)) { - throw new InvalidOperationException($"Task not found: {taskId}"); + return entry; } - // Enforce session isolation - if (sessionId != null && entry.SessionId != sessionId) - { - throw new InvalidOperationException($"Task not found: {taskId}"); - } + var requests = entry.InputRequests as ImmutableDictionary + ?? entry.InputRequests?.ToImmutableDictionary() + ?? ImmutableDictionary.Empty; - // If already in terminal state, return unchanged - if (IsTerminalStatus(entry.Status)) + foreach (var kvp in inputRequests) { - return Task.FromResult(entry.ToMcpTask()); + requests = requests.SetItem(kvp.Key, kvp.Value); } - var updatedEntry = new TaskEntry(entry) + return entry with { - Status = McpTaskStatus.Cancelled, - LastUpdatedAt = GetUtcNow(), + InputRequests = requests, + Status = McpTaskStatus.InputRequired, + LastUpdatedAt = DateTimeOffset.UtcNow, }; + }); - if (_tasks.TryUpdate(taskId, updatedEntry, entry)) - { - return Task.FromResult(updatedEntry.ToMcpTask()); - } - - // Entry was modified by another thread, retry - } + return Task.CompletedTask; } - /// - /// Disposes the task store and stops background cleanup. - /// - public void Dispose() - { - _cleanupTimer?.Dispose(); - } - - private string GenerateTaskId() => - IdHelpers.CreateMonotonicId(GetUtcNow()); - - private static bool IsTerminalStatus(McpTaskStatus status) => + private static bool IsTerminal(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; -#if MCP_TEST_TIME_PROVIDER - private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow(); -#else - private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow; -#endif - -#if MCP_TEST_TIME_PROVIDER - private bool IsExpired(TaskEntry entry) -#else - private static bool IsExpired(TaskEntry entry) -#endif + private void Update(string taskId, Func transform) { - if (entry.TimeToLive == null) + SpinWait spin = default; + while (true) { - return false; // Unlimited lifetime - } - - var expirationTime = entry.CreatedAt + entry.TimeToLive.Value; - return GetUtcNow() >= expirationTime; - } - - private void CleanupExpiredTasks(object? state) - { - var expiredTaskIds = _tasks - .Where(kvp => IsExpired(kvp.Value)) - .Select(kvp => kvp.Key) - .ToList(); + if (!_tasks.TryGetValue(taskId, out var current)) + { + throw new InvalidOperationException($"Task '{taskId}' not found."); + } - foreach (var taskId in expiredTaskIds) - { - _tasks.TryRemove(taskId, out _); - } - } + var updated = transform(current); + if (ReferenceEquals(updated, current) || _tasks.TryUpdate(taskId, updated, current)) + { + return; + } - private sealed class TaskEntry - { - // Flattened McpTask properties - public required string TaskId { get; init; } - public required McpTaskStatus Status { get; init; } - public string? StatusMessage { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public required DateTimeOffset LastUpdatedAt { get; init; } - public TimeSpan? TimeToLive { get; init; } - public TimeSpan? PollInterval { get; init; } - - // Request metadata - public required RequestId RequestId { get; init; } - public required JsonRpcRequest Request { get; init; } - public required string? SessionId { get; init; } - public JsonElement? StoredResult { get; init; } - - /// - /// Copy constructor for creating modified copies. - /// - [SetsRequiredMembers] - public TaskEntry(TaskEntry source) - { - TaskId = source.TaskId; - Status = source.Status; - StatusMessage = source.StatusMessage; - CreatedAt = source.CreatedAt; - LastUpdatedAt = source.LastUpdatedAt; - TimeToLive = source.TimeToLive; - PollInterval = source.PollInterval; - RequestId = source.RequestId; - Request = source.Request; - SessionId = source.SessionId; - StoredResult = source.StoredResult; + spin.SpinOnce(); } - - /// - /// Default constructor for initial creation. - /// - public TaskEntry() { } - - /// - /// Converts this entry back to an McpTask for external consumption. - /// - public McpTask ToMcpTask() => new() - { - TaskId = TaskId, - Status = Status, - StatusMessage = StatusMessage, - CreatedAt = CreatedAt, - LastUpdatedAt = LastUpdatedAt, - TimeToLive = TimeToLive, - PollInterval = PollInterval - }; } } diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs new file mode 100644 index 000000000..f54152368 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs @@ -0,0 +1,26 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Server; + +/// +/// Provides data for the event. +/// +[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] +public sealed class InputResponseReceivedEventArgs +{ + /// + /// Gets the task identifier. + /// + public required string TaskId { get; init; } + + /// + /// Gets the request identifier that was resolved. + /// + public required string RequestId { get; init; } + + /// + /// Gets the response payload. + /// + public required InputResponse Response { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index 5044f8928..e778d9d1b 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -36,9 +36,15 @@ public IList> ListTool /// Gets or sets the filters for the call-tool handler pipeline. /// /// + /// /// These filters wrap handlers that are invoked when a client makes a call to a tool that isn't found in the collection. /// The filters can modify, log, or perform additional operations on requests and responses for /// requests. The handler should implement logic to execute the requested tool and return appropriate results. + /// + /// + /// Cannot be used together with . If both are non-empty at configuration time, + /// an will be thrown. + /// /// public IList> CallToolFilters { @@ -50,6 +56,31 @@ public IList> CallToolFi } } + /// + /// Gets or sets the filters for the call-tool handler pipeline with task support. + /// + /// + /// + /// These filters wrap the task-augmented call-tool handler whose return type is + /// . Use these filters when the server's tool pipeline + /// supports returning either an immediate or a + /// for asynchronous execution. + /// + /// + /// Cannot be used together with . If both are non-empty at configuration time, + /// an will be thrown. + /// + /// + public IList>> CallToolWithTaskFilters + { + get => field ??= []; + set + { + Throw.IfNull(value); + field = value; + } + } + /// /// Gets or sets the filters for the list-prompts handler pipeline. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index fe99794c8..a0d788a27 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -67,58 +67,33 @@ public static McpServer Create( /// then returns to when the response is received. /// /// - public async ValueTask SampleAsync( + public ValueTask SampleAsync( CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - ThrowIfSamplingUnsupported(); - return await SendRequestWithTaskStatusTrackingAsync( - RequestMethods.SamplingCreateMessage, - requestParams, - McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, - McpJsonUtilities.JsonContext.Default.CreateMessageResult, - "Waiting for sampling response", - cancellationToken).ConfigureAwait(false); - } + // If executing inside a background task, redirect sampling through the task store. + // Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the + // client opted into the tasks extension when submitting the originating request, and input + // requests are delivered through the tasks/get response channel rather than as direct + // server->client requests. See SendRequestViaTaskAsync remarks. + if (McpTaskExecutionContext.Current.Value is { } taskContext) + { + return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams, + McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, + McpJsonUtilities.JsonContext.Default.CreateMessageResult, + cancellationToken); + } - /// - /// Requests to sample an LLM via the client as a task, allowing the server to poll for completion. - /// - /// The parameters for the sampling request. - /// The task metadata specifying TTL and other task-related options. - /// The to monitor for cancellation requests. - /// An representing the created task on the client. - /// or is . - /// The client does not support sampling or task-augmented sampling. - /// The request failed or the client returned an error response. - /// - /// Use to poll for task status and - /// (with ) to retrieve the final result when the task completes. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask SampleAsTaskAsync( - CreateMessageRequestParams requestParams, - McpTaskMetadata taskMetadata, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - Throw.IfNull(taskMetadata); ThrowIfSamplingUnsupported(); - ThrowIfTasksUnsupportedForSampling(); - // Set the task metadata on the request - requestParams.Task = taskMetadata; - - var result = await SendRequestAsync( + return SendRequestAsync( RequestMethods.SamplingCreateMessage, requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, - McpJsonUtilities.JsonContext.Default.CreateTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); - - return result.Task; + McpJsonUtilities.JsonContext.Default.CreateMessageResult, + cancellationToken: cancellationToken); } /// @@ -301,6 +276,20 @@ public ValueTask RequestRootsAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + + // If executing inside a background task, redirect through the task store. + // Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the + // client opted into the tasks extension when submitting the originating request, and input + // requests are delivered through the tasks/get response channel rather than as direct + // server->client requests. See SendRequestViaTaskAsync remarks. + if (McpTaskExecutionContext.Current.Value is { } taskContext) + { + return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams, + McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, + McpJsonUtilities.JsonContext.Default.ListRootsResult, + cancellationToken); + } + ThrowIfRootsUnsupported(); return SendRequestAsync( @@ -339,350 +328,31 @@ public async ValueTask ElicitAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - ThrowIfElicitationUnsupported(requestParams); - var result = await SendRequestWithTaskStatusTrackingAsync( - RequestMethods.ElicitationCreate, - requestParams, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.ElicitResult, - "Waiting for user input", - cancellationToken).ConfigureAwait(false); - - return ElicitResult.WithDefaults(requestParams, result); - } + // If executing inside a background task, redirect elicitation through the task store. + // Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because + // the client opted into the tasks extension when submitting the originating request, and + // input requests are delivered through the tasks/get response channel rather than as + // direct server->client requests. See SendRequestViaTaskAsync remarks. + if (McpTaskExecutionContext.Current.Value is { } taskContext) + { + var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams, + McpJsonUtilities.JsonContext.Default.ElicitRequestParams, + McpJsonUtilities.JsonContext.Default.ElicitResult, + cancellationToken).ConfigureAwait(false); + return taskResult ?? new ElicitResult { Action = "cancel" }; + } - /// - /// Requests additional information from the user via the client as a task, allowing the server to poll for completion. - /// - /// The parameters for the elicitation request. - /// The task metadata specifying TTL and other task-related options. - /// The to monitor for cancellation requests. - /// An representing the created task on the client. - /// or is . - /// The client does not support elicitation or task-augmented elicitation. - /// The request failed or the client returned an error response. - /// - /// Use to poll for task status and - /// (with ) to retrieve the final result when the task completes. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask ElicitAsTaskAsync( - ElicitRequestParams requestParams, - McpTaskMetadata taskMetadata, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - Throw.IfNull(taskMetadata); ThrowIfElicitationUnsupported(requestParams); - ThrowIfTasksUnsupportedForElicitation(); - - // Set the task metadata on the request - requestParams.Task = taskMetadata; var result = await SendRequestAsync( RequestMethods.ElicitationCreate, requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.CreateTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); - - return result.Task; - } - - /// - /// Retrieves the current state of a specific task from the client. - /// - /// The unique identifier of the task to retrieve. - /// The to monitor for cancellation requests. The default is . - /// The current state of the task. - /// is . - /// is empty or composed entirely of whitespace. - /// The client does not support tasks. - /// The request failed or the client returned an error response. - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask GetTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNullOrWhiteSpace(taskId); - ThrowIfTasksUnsupported(); - - var result = await SendRequestAsync( - RequestMethods.TasksGet, - new GetTaskRequestParams { TaskId = taskId }, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken).ConfigureAwait(false); - - // Convert GetTaskResult to McpTask - return new McpTask - { - TaskId = result.TaskId, - Status = result.Status, - StatusMessage = result.StatusMessage, - CreatedAt = result.CreatedAt, - LastUpdatedAt = result.LastUpdatedAt, - TimeToLive = result.TimeToLive, - PollInterval = result.PollInterval - }; - } - - /// - /// Retrieves the result of a completed task from the client, blocking until the task reaches a terminal state. - /// - /// The type to deserialize the task result into. - /// The unique identifier of the task whose result to retrieve. - /// Optional serializer options for deserializing the result. - /// The to monitor for cancellation requests. The default is . - /// The result of the task, deserialized into type . - /// is . - /// is empty or composed entirely of whitespace. - /// The client does not support tasks. - /// The request failed or the client returned an error response. - /// - /// - /// This method sends a tasks/result request to the client, which will block until the task completes if it hasn't already. - /// The client handles all polling logic internally. - /// - /// - /// For sampling tasks, use as . - /// For elicitation tasks, use as . - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask GetTaskResultAsync( - string taskId, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - Throw.IfNullOrWhiteSpace(taskId); - ThrowIfTasksUnsupported(); - - var result = await SendRequestAsync( - RequestMethods.TasksResult, - new GetTaskPayloadRequestParams { TaskId = taskId }, - McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams, - McpJsonUtilities.JsonContext.Default.JsonElement, - cancellationToken: cancellationToken).ConfigureAwait(false); - - var serializerOptions = jsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; - serializerOptions.MakeReadOnly(); - - var typeInfo = serializerOptions.GetTypeInfo(); - return result.Deserialize(typeInfo); - } - - /// - /// Retrieves a list of all tasks from the client. - /// - /// The to monitor for cancellation requests. The default is . - /// A list of all tasks. - /// The client does not support tasks or task listing. - /// The request failed or the client returned an error response. - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask> ListTasksAsync( - CancellationToken cancellationToken = default) - { - ThrowIfTasksUnsupported(); - ThrowIfTaskListingUnsupported(); - - List? tasks = null; - ListTasksRequestParams requestParams = new(); - do - { - var taskResults = await ListTasksAsync(requestParams, cancellationToken).ConfigureAwait(false); - if (tasks is null) - { - tasks = new List(taskResults.Tasks.Count); - } - - foreach (var mcpTask in taskResults.Tasks) - { - tasks.Add(mcpTask); - } - - requestParams.Cursor = taskResults.NextCursor; - } - while (requestParams.Cursor is not null); - - return tasks; - } - - /// - /// Retrieves a list of tasks from the client. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// The result of the request as provided by the client. - /// is . - /// The client does not support tasks or task listing. - /// The request failed or the client returned an error response. - /// - /// The overload retrieves all tasks by automatically handling pagination. - /// This overload works with the lower-level and , returning the raw result from the client. - /// Any pagination needs to be managed by the caller. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public ValueTask ListTasksAsync( - ListTasksRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksUnsupported(); - ThrowIfTaskListingUnsupported(); - - return SendRequestAsync( - RequestMethods.TasksList, - requestParams, - McpJsonUtilities.JsonContext.Default.ListTasksRequestParams, - McpJsonUtilities.JsonContext.Default.ListTasksResult, - cancellationToken: cancellationToken); - } - - /// - /// Cancels a running task on the client. - /// - /// The unique identifier of the task to cancel. - /// The to monitor for cancellation requests. The default is . - /// The updated state of the task after cancellation. - /// is . - /// is empty or composed entirely of whitespace. - /// The client does not support tasks or task cancellation. - /// The request failed or the client returned an error response. - /// - /// Cancelling a task requests that the client stop execution. The client may not immediately cancel the task, - /// and may choose to allow the task to complete if it's close to finishing. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask CancelTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNullOrWhiteSpace(taskId); - ThrowIfTasksUnsupported(); - ThrowIfTaskCancellationUnsupported(); - - var result = await SendRequestAsync( - RequestMethods.TasksCancel, - new CancelMcpTaskRequestParams { TaskId = taskId }, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskResult, + McpJsonUtilities.JsonContext.Default.ElicitResult, cancellationToken: cancellationToken).ConfigureAwait(false); - // Convert CancelMcpTaskResult to McpTask - return new McpTask - { - TaskId = result.TaskId, - Status = result.Status, - StatusMessage = result.StatusMessage, - CreatedAt = result.CreatedAt, - LastUpdatedAt = result.LastUpdatedAt, - TimeToLive = result.TimeToLive, - PollInterval = result.PollInterval - }; - } - - /// - /// Polls a task on the client until it reaches a terminal state. - /// - /// The unique identifier of the task to poll. - /// The to monitor for cancellation requests. The default is . - /// The task in its terminal state. - /// is . - /// is empty or composed entirely of whitespace. - /// The client does not support tasks. - /// The request failed or the client returned an error response. - /// - /// - /// This method repeatedly calls until the task reaches a terminal status. - /// It respects the returned by the client to determine how long - /// to wait between polling attempts. - /// - /// - /// For retrieving the actual result of a completed task, use - /// or . - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask PollTaskUntilCompleteAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNullOrWhiteSpace(taskId); - - McpTask task; - do - { - task = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - - // If task is in a terminal state, we're done - if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) - { - break; - } - - // Wait for the poll interval before checking again (default to 1 second) - var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1); - await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false); - } - while (true); - - return task; - } - - /// - /// Waits for a task on the client to complete and retrieves its result. - /// - /// The type to deserialize the task result into. - /// The unique identifier of the task whose result to retrieve. - /// Optional serializer options for deserializing the result. - /// The to monitor for cancellation requests. The default is . - /// A tuple containing the final task state and its result. - /// is . - /// is empty or composed entirely of whitespace. - /// The client does not support tasks. - /// The task failed or was cancelled. - /// - /// - /// This method combines and - /// to provide a convenient way to wait for a task to complete and retrieve its result in a single call. - /// - /// - /// If the task completes with a status of or , - /// an is thrown. - /// - /// - /// For sampling tasks, use as . - /// For elicitation tasks, use as . - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public async ValueTask<(McpTask Task, TResult? Result)> WaitForTaskResultAsync( - string taskId, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - Throw.IfNullOrWhiteSpace(taskId); - - // Poll until task reaches terminal state - var task = await PollTaskUntilCompleteAsync(taskId, cancellationToken).ConfigureAwait(false); - - // Check for failure or cancellation - if (task.Status == McpTaskStatus.Failed) - { - throw new McpException($"Task '{taskId}' failed: {task.StatusMessage ?? "Unknown error"}"); - } - - if (task.Status == McpTaskStatus.Cancelled) - { - throw new McpException($"Task '{taskId}' was cancelled"); - } - - // Retrieve the result - var result = await GetTaskResultAsync(taskId, jsonSerializerOptions, cancellationToken).ConfigureAwait(false); - - return (task, result); + return ElicitResult.WithDefaults(requestParams, result); } /// @@ -747,6 +417,26 @@ public async ValueTask> ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } + /// + /// Sends a task status notification to the connected client. + /// + /// The task status notification parameters to send. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous send operation. + /// is . + public Task SendTaskStatusNotificationAsync( + TaskStatusNotificationParams notificationParams, + CancellationToken cancellationToken = default) + { + Throw.IfNull(notificationParams); + + return SendNotificationAsync( + NotificationMethods.TaskStatusNotification, + notificationParams, + McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams, + cancellationToken); + } + /// /// Builds a request schema for elicitation based on the public serializable properties of . /// @@ -896,6 +586,88 @@ private void ThrowIfRootsUnsupported() } } + /// + /// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through + /// the task store as input requests for the duration of the scope. Use this when executing tool logic + /// in the background as a task, so that any server-to-client requests are surfaced to the client via + /// the task's state instead of direct JSON-RPC messages. + /// + /// The task ID in the store. + /// The task store to write input requests to. + /// An that restores the previous context when disposed. + [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] + public IDisposable CreateMcpTaskScope( + string taskId, + IMcpTaskStore store) + { + Throw.IfNull(taskId); + Throw.IfNull(store); + + var previous = McpTaskExecutionContext.Current.Value; + McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext + { + TaskId = taskId, + Store = store, + }; + return new McpTaskExecutionContext.Scope(previous); + } + + /// + /// Sends a server-initiated request through the task store as an input request, then awaits the response. + /// + /// + /// When executing inside a task scope, capability negotiation checks (such as + /// , , and + /// ) are intentionally skipped by the callers + /// of this helper. The task channel itself is the negotiated capability: the client opted + /// in to the tasks extension when it submitted the originating request, and is responsible + /// for handling or rejecting the input requests surfaced through tasks/get. + /// + private async ValueTask SendRequestViaTaskAsync( + McpTaskExecutionContext taskContext, + string method, + TRequest request, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo, + CancellationToken cancellationToken) + { + var requestId = Guid.NewGuid().ToString("N"); + var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsJson, + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskContext.TaskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + taskContext.Store.InputResponseReceived += handler; + try + { + await taskContext.Store.SetInputRequestsAsync( + taskContext.TaskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + return response.Deserialize(responseTypeInfo)!; + } + finally + { + taskContext.Store.InputResponseReceived -= handler; + } + } + private void ThrowIfElicitationUnsupported(ElicitRequestParams request) { if (ClientCapabilities is null) @@ -940,120 +712,6 @@ private void ThrowIfElicitationUnsupported(ElicitRequestParams request) } } - private void ThrowIfTasksUnsupportedForSampling() - { - if (ClientCapabilities?.Tasks?.Requests?.Sampling?.CreateMessage is null) - { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Task-augmented sampling is not supported in stateless mode."); - } - - throw new InvalidOperationException("Client does not support task-augmented sampling requests."); - } - } - - private void ThrowIfTasksUnsupportedForElicitation() - { - if (ClientCapabilities?.Tasks?.Requests?.Elicitation?.Create is null) - { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Task-augmented elicitation is not supported in stateless mode."); - } - - throw new InvalidOperationException("Client does not support task-augmented elicitation requests."); - } - } - - private void ThrowIfTasksUnsupported() - { - if (ClientCapabilities?.Tasks is null) - { - if (ClientCapabilities is null) - { - throw new InvalidOperationException("Tasks are not supported in stateless mode."); - } - - throw new InvalidOperationException("Client does not support tasks."); - } - } - - private void ThrowIfTaskListingUnsupported() - { - if (ClientCapabilities?.Tasks?.List is null) - { - throw new InvalidOperationException("Client does not support task listing."); - } - } - - private void ThrowIfTaskCancellationUnsupported() - { - if (ClientCapabilities?.Tasks?.Cancel is null) - { - throw new InvalidOperationException("Client does not support task cancellation."); - } - } - - /// - /// Sends a request to the client, automatically updating task status to InputRequired during - /// the request when called within a task execution context. - /// - private async ValueTask SendRequestWithTaskStatusTrackingAsync( - string method, - TParams requestParams, - JsonTypeInfo paramsTypeInfo, - JsonTypeInfo resultTypeInfo, - string inputRequiredMessage, - CancellationToken cancellationToken) - where TParams : RequestParams - where TResult : notnull - { - var taskContext = TaskExecutionContext.Current; - - // If we're not in a task execution context, just send the request normally - if (taskContext is null) - { - return await SendRequestAsync(method, requestParams, paramsTypeInfo, resultTypeInfo, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - // Update task status to InputRequired - var inputRequiredTask = await taskContext.TaskStore.UpdateTaskStatusAsync( - taskContext.TaskId, - Protocol.McpTaskStatus.InputRequired, - inputRequiredMessage, - taskContext.SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send notification if enabled - if (taskContext.SendNotifications && taskContext.NotifyTaskStatusFunc is not null) - { - _ = taskContext.NotifyTaskStatusFunc(inputRequiredTask, CancellationToken.None); - } - - try - { - // Send the actual request - return await SendRequestAsync(method, requestParams, paramsTypeInfo, resultTypeInfo, cancellationToken: cancellationToken).ConfigureAwait(false); - } - finally - { - // Update task status back to Working - var workingTask = await taskContext.TaskStore.UpdateTaskStatusAsync( - taskContext.TaskId, - Protocol.McpTaskStatus.Working, - null, // Clear status message - taskContext.SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send notification if enabled - if (taskContext.SendNotifications && taskContext.NotifyTaskStatusFunc is not null) - { - _ = taskContext.NotifyTaskStatusFunc(workingTask, CancellationToken.None); - } - } - } - /// Provides an implementation that's implemented via client sampling. private sealed class SamplingChatClient(McpServer server, JsonSerializerOptions serializerOptions) : IChatClient { @@ -1091,50 +749,6 @@ async IAsyncEnumerable IChatClient.GetStreamingResponseAsync void IDisposable.Dispose() { } // nop } - /// - /// Sends a task status notification to the connected client. - /// - /// The task whose status changed. - /// The to monitor for cancellation requests. - /// A task representing the asynchronous notification operation. - /// is . - /// The notification failed or the client returned an error response. - /// - /// - /// This method sends an optional status notification to inform the client of task state changes. - /// According to the MCP specification, receivers MAY send this notification but are not required to. - /// Clients must not rely on receiving these notifications and should continue polling via tasks/get. - /// - /// - /// The notification is sent using the standard notifications/tasks/status method and includes - /// the full task state information. - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public Task NotifyTaskStatusAsync( - McpTask task, - CancellationToken cancellationToken = default) - { - Throw.IfNull(task); - - var notificationParams = new McpTaskStatusNotificationParams - { - TaskId = task.TaskId, - Status = task.Status, - StatusMessage = task.StatusMessage, - CreatedAt = task.CreatedAt, - LastUpdatedAt = task.LastUpdatedAt, - TimeToLive = task.TimeToLive, - PollInterval = task.PollInterval - }; - - return SendNotificationAsync( - NotificationMethods.TaskStatusNotification, - notificationParams, - McpJsonUtilities.JsonContext.Default.McpTaskStatusNotificationParams, - cancellationToken); - } - /// /// Provides an implementation for creating loggers /// that send logging message notifications to the client for logged messages. diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 6dbcea8af..f650a0011 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -42,8 +42,53 @@ public sealed class McpServerHandlers /// /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. + /// Use instead if the tool may return a + /// for asynchronous execution. /// - public McpRequestHandler? CallToolHandler { get; set; } + /// is already set. + public McpRequestHandler? CallToolHandler + { + get; + set + { + if (value is not null && CallToolWithTaskHandler is not null) + { + throw new InvalidOperationException( + $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured."); + } + + field = value; + } + } + + /// + /// Gets or sets the handler for requests with task support. + /// + /// + /// + /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either + /// a for immediate results or a for + /// long-running asynchronous operations. + /// + /// + /// Cannot be set if is already set. + /// + /// + /// is already set. + public McpRequestHandler>? CallToolWithTaskHandler + { + get; + set + { + if (value is not null && CallToolHandler is not null) + { + throw new InvalidOperationException( + $"Cannot set {nameof(CallToolWithTaskHandler)} when {nameof(CallToolHandler)} is already set. Only one call tool handler may be configured."); + } + + field = value; + } + } /// /// Gets or sets the handler for requests. @@ -156,6 +201,74 @@ public sealed class McpServerHandlers /// public McpRequestHandler? SetLoggingLevelHandler { get; set; } + /// + /// Gets or sets the handler for requests. + /// + /// + /// + /// This handler is invoked when a client polls for the current state of a task. + /// The handler should return the appropriate subtype + /// based on the task's current status (for example, , + /// , , + /// , or ). + /// + /// + /// Setting is the recommended way to wire all three + /// task lifecycle handlers (, , + /// and ) from a single source while still allowing explicit + /// handlers to override any slot. If can return a + /// but no is configured (either + /// directly or via a task store), the server throws + /// when processing the request so misconfigured deployments fail loudly instead of producing + /// unpollable tasks. + /// + /// + public McpRequestHandler? GetTaskHandler { get; set; } + + /// + /// Gets or sets the handler for requests. + /// + /// + /// + /// This handler is invoked when a client provides input responses for a task + /// that is in the state. Responses keyed + /// by an identifier that does not currently correspond to an outstanding input request + /// (including responses for tasks in a terminal state) should be silently ignored per + /// SEP-2663. + /// + /// + /// Prefer configuring instead of setting this + /// handler directly; the default implementation built from the store dispatches to + /// and raises + /// to wake any pending + /// + /// or + /// calls executing inside a task scope. + /// + /// + public McpRequestHandler? UpdateTaskHandler { get; set; } + + /// + /// Gets or sets the handler for requests. + /// + /// + /// + /// This handler is invoked when a client requests cancellation of an in-progress task. + /// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should + /// always acknowledge the request with , even if the task is + /// unknown, already terminal, or cannot actually be stopped. Whether the task transitions + /// to is up to the implementation. + /// + /// + /// Prefer configuring instead of setting this + /// handler directly; the default implementation built from the store calls + /// and signals the per-task + /// so the tool's + /// observes cancellation. + /// + /// + public McpRequestHandler? CancelTaskHandler { get; set; } + /// Gets or sets notification handlers to register with the server. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index c48ee2da0..85b1cc26a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.Server; /// -#pragma warning disable MCPEXP002 +#pragma warning disable MCPEXP001, MCPEXP002 internal sealed partial class McpServerImpl : McpServer { internal static Implementation DefaultImplementation { get; } = new() @@ -28,7 +28,7 @@ internal sealed partial class McpServerImpl : McpServer private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); - private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; + private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); @@ -77,12 +77,6 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact _servicesScopePerRequest = options.ScopeRequests; _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; - // Only allocate the cancellation token provider if a task store is configured - if (options.TaskStore is not null) - { - _taskCancellationTokenProvider = new McpTaskCancellationTokenProvider(); - } - _clientInfo = options.KnownClientInfo; _clientCapabilities = options.KnownClientCapabilities; UpdateEndpointNameWithClientInfo(); @@ -96,10 +90,10 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureTools(options); ConfigurePrompts(options); ConfigureResources(options); - ConfigureTasks(options); ConfigureLogging(options); ConfigureCompletion(options); ConfigureExperimentalAndExtensions(options); + ConfigureTasks(options); ConfigureMrtr(); // Register any notification handlers that were provided. @@ -220,11 +214,17 @@ public override async ValueTask DisposeAsync() _disposed = true; - // Dispose the session handler first - cancels message processing and waits for all + foreach (var kvp in _taskCancellationSources) + { + kvp.Value.Cancel(); + kvp.Value.Dispose(); + } + _taskCancellationSources.Clear(); + + // Dispose the session handler - cancels message processing and waits for all // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. // After this returns, no new requests can be processed and no new MRTR continuations // can be created, so _mrtrContinuations is effectively frozen. - _taskCancellationTokenProvider?.Dispose(); _disposables.ForEach(d => d()); await _sessionHandler.DisposeAsync().ConfigureAwait(false); @@ -431,6 +431,84 @@ private void ConfigureCompletion(McpServerOptions options) return result; } + private void ConfigureTasks(McpServerOptions options) + { + var getTaskHandler = options.Handlers.GetTaskHandler; + var updateTaskHandler = options.Handlers.UpdateTaskHandler; + var cancelTaskHandler = options.Handlers.CancelTaskHandler; + var taskStore = options.TaskStore; + + // If a task store is provided, wire up handlers from it for any that aren't explicitly set. + if (taskStore is not null) + { + getTaskHandler ??= async (request, cancellationToken) => + { + var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false); + return info is null + ? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams) + : ToGetTaskResult(info); + }; + + updateTaskHandler ??= async (request, cancellationToken) => + { + var inputResponses = request.Params!.InputResponses ?? new Dictionary(); + await taskStore.ResolveInputRequestsAsync(request.Params.TaskId, inputResponses, cancellationToken).ConfigureAwait(false); + + return new UpdateTaskResult(); + }; + + cancelTaskHandler ??= async (request, cancellationToken) => + { + // Idempotent ack per SEP-2663: always return CancelTaskResult regardless of whether + // the task was known/cancellable. The store's SetCancelledAsync no-ops for unknown + // or already-terminal tasks; we still surface a success response to the client. + await taskStore.SetCancelledAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false); + + // Signal the task's CancellationTokenSource if one exists. Whichever side + // (this handler or the background runner's finally block) wins TryRemove owns disposal, + // which prevents the runner from observing ObjectDisposedException through cts.Token. + if (_taskCancellationSources.TryRemove(request.Params.TaskId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + + return new CancelTaskResult(); + }; + } + + if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null) + { + return; + } + + getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); + updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); + cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); + + // Advertise tasks extension in server capabilities. + ServerCapabilities.Extensions ??= new Dictionary(); + ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); + + SetHandler( + RequestMethods.TasksGet, + getTaskHandler, + McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, + McpJsonUtilities.JsonContext.Default.GetTaskResult); + + SetHandler( + RequestMethods.TasksUpdate, + updateTaskHandler, + McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, + McpJsonUtilities.JsonContext.Default.UpdateTaskResult); + + SetHandler( + RequestMethods.TasksCancel, + cancelTaskHandler, + McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, + McpJsonUtilities.JsonContext.Default.CancelTaskResult); + } + private void ConfigureExperimentalAndExtensions(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; @@ -706,10 +784,11 @@ private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; var callToolHandler = options.Handlers.CallToolHandler; + var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler; var tools = options.ToolCollection; var toolsCapability = options.Capabilities?.Tools; - if (listToolsHandler is null && callToolHandler is null && tools is null && + if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null && toolsCapability is null) { return; @@ -718,10 +797,23 @@ private void ConfigureTools(McpServerOptions options) ServerCapabilities.Tools = new(); listToolsHandler ??= (static async (_, __) => new ListToolsResult()); - callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); var listChanged = toolsCapability?.ListChanged; - // Handle tools provided via DI by augmenting the handlers to incorporate them. + var callToolFilters = options.Filters.Request.CallToolFilters; + var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters; + + // Validate: cannot mix non-task filters/handler with task filters/handler. + bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0; + bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0; + + if (hasNonTaskPath && hasTaskPath) + { + throw new InvalidOperationException( + $"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + + $"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other."); + } + + // Handle tools provided via DI by augmenting the list handler. if (tools is not null) { var originalListToolsHandler = listToolsHandler; @@ -742,103 +834,158 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) return result; }; - var originalCallToolHandler = callToolHandler; - var taskStore = options.TaskStore; - var sendNotifications = options.SendTaskStatusNotifications; - callToolHandler = async (request, cancellationToken) => - { - if (request.MatchedPrimitive is McpServerTool tool) - { - var taskSupport = tool.ProtocolTool.Execution?.TaskSupport ?? ToolTaskSupport.Forbidden; + listChanged = true; + } - // Check if this is a task-augmented request - if (request.Params?.Task is { } taskMetadata) - { - // Validate tool-level task support - if (taskSupport is ToolTaskSupport.Forbidden) - { - throw new McpProtocolException( - $"Tool '{tool.ProtocolTool.Name}' does not support task-augmented execution.", - McpErrorCode.InvalidParams); - } + listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - // Task augmentation requested with immediate creation - return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false); - } + // Build the unified task-augmented handler from one of the two paths. + if (hasTaskPath) + { + // Case 2: task filter + task handler + callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); - // Validate that required task support is satisfied - if (taskSupport is ToolTaskSupport.Required) + // Augment with DI tools. + if (tools is not null) + { + var originalHandler = callToolWithTaskHandler; + callToolWithTaskHandler = (request, cancellationToken) => + { + if (request.MatchedPrimitive is McpServerTool tool) { - throw new McpProtocolException( - $"Tool '{tool.ProtocolTool.Name}' requires task-augmented execution. " + - "Include a 'task' parameter with the request.", - McpErrorCode.InvalidParams); + return InvokeToolAsTask(tool, request, cancellationToken); } - // Normal synchronous execution - return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); - } - - return await originalCallToolHandler(request, cancellationToken).ConfigureAwait(false); - }; + return originalHandler(request, cancellationToken); + }; + } - listChanged = true; + callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools)); } + else + { + // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based + callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); - listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - callToolHandler = BuildFilterPipeline(callToolHandler, options.Filters.Request.CallToolFilters, handler => - async (request, cancellationToken) => + // Augment with DI tools. + if (tools is not null) { - // Initial handler that sets MatchedPrimitive - if (request.Params?.Name is { } toolName && tools is not null && - tools.TryGetPrimitive(toolName, out var tool)) - { - request.MatchedPrimitive = tool; - } - - try + var originalHandler = callToolHandler; + callToolHandler = (request, cancellationToken) => { - var result = await handler(request, cancellationToken).ConfigureAwait(false); - - // Don't log here for task-augmented calls; logging happens asynchronously - // in ExecuteToolAsTaskAsync when the tool actually completes. - if (result.Task is null) + if (request.MatchedPrimitive is McpServerTool tool) { - ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true); + return tool.InvokeAsync(request, cancellationToken); } - return result; - } - catch (Exception e) + return originalHandler(request, cancellationToken); + }; + } + + callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); + + // Convert to task-based. + var finalCallToolHandler = callToolHandler; + callToolWithTaskHandler = async (request, cancellationToken) => + await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); + } + + // If a task store is configured, wrap so that when the client signals task support + // the tool execution is offloaded to the background via the store. + if (options.TaskStore is { } taskStore) + { + var innerTaskHandler = callToolWithTaskHandler; + callToolWithTaskHandler = async (request, cancellationToken) => + { + if (HasTaskExtensionOptIn(request.Params?.Meta)) { - // Skip logging for OperationCanceledException when the cancellation token - // is signaled - tool handler cancellation is an expected lifecycle event - // (client request cancellation, session shutdown, MRTR teardown), not a - // tool error. - // Skip logging for InputRequiredException - it's normal MRTR control flow, - // not an error (tools throw it to signal an InputRequiredResult). - if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) - { - ToolCallError(request.Params?.Name ?? string.Empty, e); - } + var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + var taskId = taskInfo.TaskId; - if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) - { - throw; - } + var cts = new CancellationTokenSource(); + _taskCancellationSources[taskId] = cts; - return new() + // Capture the token synchronously before Task.Run dispatches the work. + // The cancel handler may race with the background runner: whichever side wins + // the TryRemove call owns disposal. If we accessed cts.Token from inside the + // lambda after the handler had already disposed cts, we'd hit ObjectDisposedException. + var taskCancellationToken = cts.Token; + + _ = Task.Run(async () => { - IsError = true, - Content = [new TextContentBlock + using (CreateMcpTaskScope(taskId, taskStore)) { - Text = e is McpException ? - $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : - $"An error occurred invoking '{request.Params?.Name}'.", - }], - }; + try + { + var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false); + if (augmented.IsTask) + { + // The handler created its own task externally, but the client already holds + // the store's taskId from the synchronous return below — we can't redirect. + // Fail the store's task so the client sees a clear error instead of polling forever. + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InternalError, + Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithTaskHandler)} returned IsTask = true. Use only one mechanism to create the task.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); + await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + return; + } + + var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.JsonContext.Default.CallToolResult); + await taskStore.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) + { + await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch (InputRequiredException) + { + // MRTR (input requests) cannot be composed with the task-store wrapper for + // [McpServerTool] methods today: the task ID was already returned synchronously, + // so we have no way to surface InputRequiredResult to the client retroactively. + // Fail the task with a clear, actionable error instead of leaking the raw + // InputRequiredException through the generic catch below. + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidRequest, + Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " + + $"use {nameof(McpServerHandlers.CallToolWithTaskHandler)} to manage the input-request loop manually within the task body.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); + await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (Exception ex) + { + // SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}. + // McpProtocolException carries a JSON-RPC ErrorCode and is documented as safe to + // propagate (Message + ErrorCode). For any other exception type, redact the message + // and use InternalError (mirrors the redaction in BuildInitialCallToolFilter). + var error = ex is McpProtocolException mcpEx + ? new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message } + : new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = "An error occurred while executing the task." }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); + await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + finally + { + // Only the side that wins TryRemove disposes cts. This prevents a + // double-dispose race with the default tasks/cancel handler. + if (_taskCancellationSources.TryRemove(taskId, out var registeredCts)) + { + registeredCts.Dispose(); + } + } + } + }, CancellationToken.None); + + return ToCreateTaskResult(taskInfo); } - }); + + return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false); + }; + } ServerCapabilities.Tools.ListChanged = listChanged; @@ -848,144 +995,190 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); - SetHandler( + SetTaskAugmentedHandler( RequestMethods.ToolsCall, - callToolHandler, + callToolWithTaskHandler, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult); + McpJsonUtilities.JsonContext.Default.CallToolResult, + McpJsonUtilities.JsonContext.Default.CreateTaskResult); } - private void ConfigureTasks(McpServerOptions options) + private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() { - var taskStore = options.TaskStore; + TaskId = info.TaskId, + Status = info.Status, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TtlMs = info.TtlMs, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "task", + }; - // If no task store is configured, tasks are not supported - if (taskStore is null) + private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch + { + McpTaskStatus.Working => new WorkingTaskResult { - return; - } - - // Advertise task support in server capabilities - ServerCapabilities.Tasks = new McpTasksCapability + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TtlMs = info.TtlMs, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.Completed => new CompletedTaskResult { - List = new ListMcpTasksCapability(), - Cancel = new CancelMcpTasksCapability(), - Requests = new RequestMcpTasksCapability - { - Tools = new ToolsMcpTasksCapability - { - Call = new CallToolMcpTasksCapability() - } - } - }; + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TtlMs = info.TtlMs, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), + ResultType = "complete", + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TtlMs = info.TtlMs, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), + ResultType = "complete", + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TtlMs = info.TtlMs, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TtlMs = info.TtlMs, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store + // implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire + // DTO uses IDictionary like every other Protocol type. Most concrete stores back + // their dictionaries with a type that implements both interfaces (Dictionary, + // ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we + // only allocate a copy as a fallback. + InputRequests = info.InputRequests is IDictionary dict + ? dict + : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + ?? new Dictionary(), + ResultType = "complete", + }, + _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), + }; - // tasks/get handler - Retrieve task status - McpRequestHandler getTaskHandler = async (request, cancellationToken) => + private static async ValueTask> InvokeToolAsTask( + McpServerTool tool, + RequestContext request, + CancellationToken cancellationToken) + { + return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + } + + private McpRequestFilter BuildInitialCallToolFilter( + McpServerPrimitiveCollection? tools) => handler => + async (request, cancellationToken) => { - if (request.Params?.TaskId is not { } taskId) + if (request.Params?.Name is { } toolName && tools is not null && + tools.TryGetPrimitive(toolName, out var tool)) { - throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams); + request.MatchedPrimitive = tool; } - var task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - if (task is null) + try { - throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams); + var result = await handler(request, cancellationToken).ConfigureAwait(false); + ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true); + return result; } - - return task; - }; - - // tasks/result handler - Retrieve task result (blocking until terminal status) - McpRequestHandler getTaskResultHandler = (request, cancellationToken) => - { - return new ValueTask(GetTaskResultAsync(request, cancellationToken)); - - async Task GetTaskResultAsync(RequestContext request, CancellationToken cancellationToken) + catch (Exception e) { - if (request.Params?.TaskId is not { } taskId) + // Skip logging for InputRequiredException - it's normal MRTR control flow, + // not an error (tools throw it to signal an InputRequiredResult). + if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) { - throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams); + ToolCallError(request.Params?.Name ?? string.Empty, e); } - // Poll until task reaches terminal status - while (true) + if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) { - McpTask? task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - if (task is null) - { - throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams); - } - - // If terminal, break and retrieve result - if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) - { - break; - } - - // Poll according to task's pollInterval (default 1 second) - var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1); - await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false); + throw; } - // Retrieve the stored result - already stored as JsonElement - return await taskStore.GetTaskResultAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); + return new() + { + IsError = true, + Content = [new TextContentBlock + { + Text = e is McpException ? + $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : + $"An error occurred invoking '{request.Params?.Name}'.", + }], + }; } }; - // tasks/list handler - List tasks with pagination - McpRequestHandler listTasksHandler = async (request, cancellationToken) => - { - var cursor = request.Params?.Cursor; - return await taskStore.ListTasksAsync(cursor, SessionId, cancellationToken).ConfigureAwait(false); - }; - - // tasks/cancel handler - Cancel a task - McpRequestHandler cancelTaskHandler = async (request, cancellationToken) => + private McpRequestFilter> BuildInitialTaskToolFilter( + McpServerPrimitiveCollection? tools) => handler => + async (request, cancellationToken) => { - if (request.Params?.TaskId is not { } taskId) + if (request.Params?.Name is { } toolName && tools is not null && + tools.TryGetPrimitive(toolName, out var tool)) { - throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams); + request.MatchedPrimitive = tool; } - // Signal cancellation if task is still running - _taskCancellationTokenProvider!.Cancel(taskId); - - // Delegate to task store - it handles idempotent cancellation - var task = await taskStore.CancelTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false); - if (task is null) + try { - throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams); - } - - return task; - }; - - // Register handlers - SetHandler( - RequestMethods.TasksGet, - getTaskHandler, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.McpTask); + var result = await handler(request, cancellationToken).ConfigureAwait(false); + if (!result.IsTask) + { + ToolCallCompleted(request.Params?.Name ?? string.Empty, result.Result!.IsError is true); + } - SetHandler( - RequestMethods.TasksResult, - getTaskResultHandler, - McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams, - McpJsonUtilities.JsonContext.Default.JsonElement); + return result; + } + catch (Exception e) + { + // Skip logging for InputRequiredException - it's normal MRTR control flow, + // not an error (tools throw it to signal an InputRequiredResult). + if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) + { + ToolCallError(request.Params?.Name ?? string.Empty, e); + } - SetHandler( - RequestMethods.TasksList, - listTasksHandler, - McpJsonUtilities.JsonContext.Default.ListTasksRequestParams, - McpJsonUtilities.JsonContext.Default.ListTasksResult); + if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) + { + throw; + } - SetHandler( - RequestMethods.TasksCancel, - cancelTaskHandler, - McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams, - McpJsonUtilities.JsonContext.Default.McpTask); - } + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock + { + Text = e is McpException ? + $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : + $"An error occurred invoking '{request.Params?.Name}'.", + }], + }; + } + }; private void ConfigureLogging(McpServerOptions options) { @@ -1088,6 +1281,20 @@ private void SetHandler( requestTypeInfo, responseTypeInfo); } + private void SetTaskAugmentedHandler( + string method, + McpRequestHandler> handler, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo, + JsonTypeInfo taskResultTypeInfo) + where TResult : Result + { + _requestHandlers.SetTaskAugmented(method, + (request, jsonRpcRequest, cancellationToken) => + InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), + requestTypeInfo, responseTypeInfo, taskResultTypeInfo); + } + private static McpRequestHandler BuildFilterPipeline( McpRequestHandler baseHandler, IList> filters, @@ -1108,6 +1315,20 @@ private static McpRequestHandler BuildFilterPipeline + meta is not null && + meta[ClientCapabilitiesMetaKey] is JsonObject caps && + caps[ExtensionsKey] is JsonObject exts && + exts.ContainsKey(McpExtensions.Tasks); + private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) { if (filters.Count == 0) @@ -1617,160 +1838,4 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); - - /// - /// Executes a tool call as a task and returns a CallToolTaskResult immediately. - /// - private async ValueTask ExecuteToolAsTaskAsync( - McpServerTool tool, - RequestContext request, - McpTaskMetadata taskMetadata, - IMcpTaskStore? taskStore, - bool sendNotifications, - CancellationToken cancellationToken) - { - if (taskStore is null) - { - throw new McpProtocolException( - "Task-augmented requests are not supported. No task store configured.", - McpErrorCode.InvalidRequest); - } - - // Create the task in the task store - var mcpTask = await taskStore.CreateTaskAsync( - taskMetadata, - request.JsonRpcRequest.Id, - request.JsonRpcRequest, - SessionId, - cancellationToken).ConfigureAwait(false); - - // Register the task for TTL-based cancellation - var taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive); - - // Execute the tool asynchronously in the background - _ = Task.Run(async () => - { - // When per-request service scoping is enabled, InvokeHandlerAsync creates a new - // IServiceScope and disposes it once the handler returns. Since ExecuteToolAsTaskAsync - // returns immediately (before the tool runs), the scope is disposed before the tool - // gets a chance to resolve any DI services. Create a fresh scope here, tied to this - // background task's lifetime, so the tool's DI resolution uses a live provider. - var taskScope = _servicesScopePerRequest - ? Services?.GetService()?.CreateAsyncScope() - : null; - if (taskScope is not null) - { - request.Services = taskScope.Value.ServiceProvider; - } - - // Set up the task execution context for automatic input_required status tracking - TaskExecutionContext.Current = new TaskExecutionContext - { - TaskId = mcpTask.TaskId, - SessionId = SessionId, - TaskStore = taskStore, - SendNotifications = sendNotifications, - NotifyTaskStatusFunc = NotifyTaskStatusAsync - }; - - try - { - // Update task status to working - var workingTask = await taskStore.UpdateTaskStatusAsync( - mcpTask.TaskId, - McpTaskStatus.Working, - null, // statusMessage - SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send notification if enabled - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(workingTask, CancellationToken.None); - } - - // Invoke the tool with task-specific cancellation token - var result = await tool.InvokeAsync(request, taskCancellationToken).ConfigureAwait(false); - ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true); - - // Determine final status based on whether there was an error - var finalStatus = result.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed; - - // Store the result (serialize to JsonElement) - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult); - var finalTask = await taskStore.StoreTaskResultAsync( - mcpTask.TaskId, - finalStatus, - resultElement, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send final notification if enabled - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(finalTask, CancellationToken.None); - } - } - catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) - { - // Task was cancelled via TTL expiration or explicit cancellation. - // For TTL expiration, the task is deleted so no status update needed. - // For explicit cancellation, the cancel handler already updates the status. - } - catch (Exception ex) - { - // Log the error - ToolCallError(request.Params?.Name ?? string.Empty, ex); - - // Store error result - var errorResult = new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"Task execution failed: {ex.Message}" }], - }; - - try - { - var errorResultElement = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.JsonContext.Default.CallToolResult); - var failedTask = await taskStore.StoreTaskResultAsync( - mcpTask.TaskId, - McpTaskStatus.Failed, - errorResultElement, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - // Send failure notification if enabled - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(failedTask, CancellationToken.None); - } - } - catch - { - // If we can't store the error result, there's not much we can do - // The task will remain in "working" status, which will eventually be cleaned up - } - } - finally - { - // Clean up task execution context - TaskExecutionContext.Current = null; - - // Clean up task cancellation tracking - _taskCancellationTokenProvider!.Complete(mcpTask.TaskId); - - // Dispose the per-task service scope (if one was created) - if (taskScope is not null) - { - await taskScope.Value.DisposeAsync().ConfigureAwait(false); - } - } - }, CancellationToken.None); - - // Return the task result immediately - return new CallToolResult - { - Task = mcpTask - }; - } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 6da8bbfbe..32c13da27 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -1,6 +1,8 @@ using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; +#pragma warning disable MCPEXP001 + namespace ModelContextProtocol.Server; /// @@ -188,54 +190,17 @@ public McpServerFilters Filters public int MaxSamplingOutputTokens { get; set; } = 1000; /// - /// Gets or sets the task store for managing asynchronous task execution. + /// Gets or sets the task store for managing asynchronous task executions. /// /// /// - /// When non-null, enables explicit task support with persistence, allowing clients to: - /// - /// Execute operations asynchronously by augmenting requests with task metadata - /// Poll for task status via tasks/get requests - /// Retrieve task results via tasks/result requests - /// List all tasks via tasks/list requests - /// Cancel tasks via tasks/cancel requests - /// - /// - /// - /// When null, implicit task support may still be available for async methods (returning or - /// ), but tasks will be ephemeral and not persisted. Use - /// for development/testing or implement for production scenarios. + /// When set, the server automatically enables the io.modelcontextprotocol/tasks extension + /// and wires up tasks/get, tasks/update, and tasks/cancel handlers backed by this store. + /// Tool executions from clients that signal task support will be wrapped in tasks via the store. /// /// - /// The server will automatically advertise task capabilities based on the presence of a task store - /// and the detection of async server primitives (tools, prompts, resources). + /// If explicit task handlers are also set on , the explicit handlers take precedence. /// /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public IMcpTaskStore? TaskStore { get; set; } - - /// - /// Gets or sets whether to send task status notifications to clients. - /// - /// - /// to send optional notifications/tasks/status notifications when task status changes; - /// to not send notifications. The default is . - /// - /// - /// - /// When enabled, the server will send notifications/tasks/status notifications to inform clients - /// of task state changes. According to the MCP specification, these notifications are optional and - /// receivers MAY send them but are not required to. - /// - /// - /// Clients must not rely on receiving these notifications and should continue polling via tasks/get - /// requests to ensure they receive status updates. - /// - /// - /// Even when this is set to , notifications are only sent when - /// is configured, as task-augmented requests require a task store. - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public bool SendTaskStatusNotifications { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index d67bac18c..34e77e2b4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -157,7 +157,6 @@ public sealed class McpServerToolAttribute : Attribute internal bool? _idempotent; internal bool? _openWorld; internal bool? _readOnly; - internal ToolTaskSupport? _taskSupport; /// /// Initializes a new instance of the class. @@ -300,29 +299,4 @@ public bool ReadOnly /// /// public string? IconSource { get; set; } - - /// - /// Gets or sets the task support configuration for the tool. - /// - /// - /// A value indicating how the tool supports task-based invocation. - /// The default value is . - /// - /// - /// - /// When set to , clients must not attempt to invoke the tool as a task. - /// When set to , clients may invoke the tool as a task or as a normal request. - /// When set to , clients must invoke the tool as a task. - /// - /// - /// If this property is not explicitly set on the attribute, the task support behavior will be determined - /// automatically based on the tool's characteristics (e.g., async methods default to ). - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public ToolTaskSupport TaskSupport - { - get => _taskSupport ?? ToolTaskSupport.Forbidden; - set => _taskSupport = value; - } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 88d718d13..b0b6b3de7 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -197,23 +197,6 @@ public sealed class McpServerToolCreateOptions /// public JsonObject? Meta { get; set; } - /// - /// Gets or sets the execution hints for this tool. - /// - /// - /// - /// Execution hints provide information about how the tool should be invoked, including - /// task support level (). - /// - /// - /// If , the tool's execution settings are determined automatically based on - /// the method signature (async methods get ; sync methods - /// get ). - /// - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public ToolExecution? Execution { get; set; } - /// /// Creates a shallow clone of the current instance. /// @@ -235,6 +218,5 @@ internal McpServerToolCreateOptions Clone() => Metadata = Metadata, Icons = Icons, Meta = Meta, - Execution = Execution, }; } diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs new file mode 100644 index 000000000..b691956cf --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Server; + +/// +/// Provides ambient context when a tool is executing as a background task. +/// When established, calls to , +/// , +/// and +/// are redirected through the task store as input requests rather than sent directly to the client. +/// +[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] +internal sealed class McpTaskExecutionContext +{ + internal static readonly AsyncLocal Current = new(); + + public required string TaskId { get; init; } + public required IMcpTaskStore Store { get; init; } + + internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable + { + public void Dispose() => Current.Value = previous; + } +} diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs new file mode 100644 index 000000000..ab71d6505 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs @@ -0,0 +1,28 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace ModelContextProtocol.Server; + +/// +/// Represents the state of a task in an . +/// +/// +/// +/// This is the store's representation of a task, decoupled from the MCP protocol wire types. +/// The server infrastructure maps to the appropriate protocol response +/// types (, ) when communicating with clients. +/// +/// +[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] +public sealed record McpTaskInfo( + string TaskId, + McpTaskStatus Status, + DateTimeOffset CreatedAt, + DateTimeOffset LastUpdatedAt, + long? TtlMs = null, + long? PollIntervalMs = null, + string? StatusMessage = null, + JsonElement? Result = null, + JsonElement? Error = null, + IReadOnlyDictionary? InputRequests = null); diff --git a/src/ModelContextProtocol.Core/Server/TaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/TaskExecutionContext.cs deleted file mode 100644 index fc45835c4..000000000 --- a/src/ModelContextProtocol.Core/Server/TaskExecutionContext.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace ModelContextProtocol.Server; - -/// -/// Represents the execution context for a task being executed by the server. -/// This context flows with async execution and enables automatic task status updates. -/// -internal sealed class TaskExecutionContext -{ - /// - /// Gets the AsyncLocal instance used to track the current task execution context. - /// - private static readonly AsyncLocal s_current = new(); - - /// - /// Gets or sets the current task execution context for the executing async flow. - /// - public static TaskExecutionContext? Current - { - get => s_current.Value; - set => s_current.Value = value; - } - - /// - /// Gets the task ID of the currently executing task. - /// - public required string TaskId { get; init; } - - /// - /// Gets the session ID associated with the task. - /// - public string? SessionId { get; init; } - - /// - /// Gets the task store used to persist task state. - /// - public required IMcpTaskStore TaskStore { get; init; } - - /// - /// Gets whether task status notifications should be sent. - /// - public bool SendNotifications { get; init; } - - /// - /// Gets or sets the function to call when sending a task status notification. - /// - public Func? NotifyTaskStatusFunc { get; init; } -} diff --git a/src/ModelContextProtocol/McpServerOptionsSetup.cs b/src/ModelContextProtocol/McpServerOptionsSetup.cs index 5977fae7e..c46854460 100644 --- a/src/ModelContextProtocol/McpServerOptionsSetup.cs +++ b/src/ModelContextProtocol/McpServerOptionsSetup.cs @@ -9,12 +9,10 @@ namespace ModelContextProtocol; /// The individually registered tools. /// The individually registered prompts. /// The individually registered resources. -/// The optional task store registered in DI. internal sealed class McpServerOptionsSetup( IEnumerable serverTools, IEnumerable serverPrompts, - IEnumerable serverResources, - IMcpTaskStore? taskStore = null) : IConfigureOptions + IEnumerable serverResources) : IConfigureOptions { /// /// Configures the given McpServerOptions instance by setting server information @@ -25,8 +23,6 @@ public void Configure(McpServerOptions options) { Throw.IfNull(options); - options.TaskStore ??= taskStore; - // Collect all of the provided tools into a tools collection. If the options already has // a collection, add to it, otherwise create a new one. We want to maintain the identity // of an existing collection in case someone has provided their own derived type, wants diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 231eb073a..07167c438 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -8,7 +8,7 @@ .NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions. README.md True - + $(NoWarn);MCPEXP001 diff --git a/tests/Common/Utils/TestServerTransport.cs b/tests/Common/Utils/TestServerTransport.cs index 43cd5c262..ed9b6ee72 100644 --- a/tests/Common/Utils/TestServerTransport.cs +++ b/tests/Common/Utils/TestServerTransport.cs @@ -46,14 +46,6 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can await SamplingAsync(request, cancellationToken); else if (request.Method == RequestMethods.ElicitationCreate) await ElicitAsync(request, cancellationToken); - else if (request.Method == RequestMethods.TasksGet) - await TasksGetAsync(request, cancellationToken); - else if (request.Method == RequestMethods.TasksResult) - await TasksResultAsync(request, cancellationToken); - else if (request.Method == RequestMethods.TasksList) - await TasksListAsync(request, cancellationToken); - else if (request.Method == RequestMethods.TasksCancel) - await TasksCancelAsync(request, cancellationToken); else await WriteMessageAsync(request, cancellationToken); } @@ -79,161 +71,21 @@ await WriteMessageAsync(new JsonRpcResponse private async Task SamplingAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - // Check if the request is task-augmented (has Task metadata) - var requestParams = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.DefaultOptions); - if (requestParams?.Task is not null && MockTask is not null) - { - // Return a task-augmented response - await WriteMessageAsync(new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(new CreateTaskResult { Task = MockTask }, McpJsonUtilities.DefaultOptions), - }, cancellationToken); - } - else - { - // Return a normal sampling response - await WriteMessageAsync(new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = [new TextContentBlock { Text = "" }], Model = "model" }, McpJsonUtilities.DefaultOptions), - }, cancellationToken); - } - } - - private async Task ElicitAsync(JsonRpcRequest request, CancellationToken cancellationToken) - { - // Check if the request is task-augmented (has Task metadata) - var requestParams = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.DefaultOptions); - if (requestParams?.Task is not null && MockTask is not null) - { - // Return a task-augmented response - await WriteMessageAsync(new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(new CreateTaskResult { Task = MockTask }, McpJsonUtilities.DefaultOptions), - }, cancellationToken); - } - else - { - // Return a normal elicitation response - await WriteMessageAsync(new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "decline" }, McpJsonUtilities.DefaultOptions), - }, cancellationToken); - } - } - - /// - /// Gets or sets the task to return from tasks/get requests. - /// - public McpTask? MockTask { get; set; } - - /// - /// Gets or sets the result to return from tasks/result requests. - /// - public object? MockTaskResult { get; set; } - - /// - /// Gets or sets the list of tasks to return from tasks/list requests. - /// - public McpTask[]? MockTaskList { get; set; } - - private async Task TasksGetAsync(JsonRpcRequest request, CancellationToken cancellationToken) - { - var task = MockTask ?? new McpTask - { - TaskId = "test-task-id", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - + // Return a normal sampling response await WriteMessageAsync(new JsonRpcResponse { Id = request.Id, - Result = JsonSerializer.SerializeToNode(new GetTaskResult - { - TaskId = task.TaskId, - Status = task.Status, - StatusMessage = task.StatusMessage, - CreatedAt = task.CreatedAt, - LastUpdatedAt = task.LastUpdatedAt, - TimeToLive = task.TimeToLive, - PollInterval = task.PollInterval - }, McpJsonUtilities.DefaultOptions), + Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = [new TextContentBlock { Text = "" }], Model = "model" }, McpJsonUtilities.DefaultOptions), }, cancellationToken); } - private async Task TasksResultAsync(JsonRpcRequest request, CancellationToken cancellationToken) - { - var result = MockTaskResult ?? new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Task result" }], - Model = "test-model" - }; - - await WriteMessageAsync(new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions), - }, cancellationToken); - } - - private async Task TasksListAsync(JsonRpcRequest request, CancellationToken cancellationToken) - { - var tasks = MockTaskList ?? [ - new McpTask - { - TaskId = "task-1", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }, - new McpTask - { - TaskId = "task-2", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-3), - LastUpdatedAt = DateTimeOffset.UtcNow, - } - ]; - - await WriteMessageAsync(new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(new ListTasksResult - { - Tasks = tasks, - }, McpJsonUtilities.DefaultOptions), - }, cancellationToken); - } - - private async Task TasksCancelAsync(JsonRpcRequest request, CancellationToken cancellationToken) + private async Task ElicitAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - var task = MockTask ?? new McpTask - { - TaskId = "test-task-id", - Status = McpTaskStatus.Cancelled, - StatusMessage = "Task cancelled by request", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - + // Return a normal elicitation response await WriteMessageAsync(new JsonRpcResponse { Id = request.Id, - Result = JsonSerializer.SerializeToNode(new CancelMcpTaskResult - { - TaskId = task.TaskId, - Status = McpTaskStatus.Cancelled, - StatusMessage = task.StatusMessage ?? "Task cancelled", - CreatedAt = task.CreatedAt, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = task.TimeToLive, - PollInterval = task.PollInterval - }, McpJsonUtilities.DefaultOptions), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "decline" }, McpJsonUtilities.DefaultOptions), }, cancellationToken); } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 1071ec394..bc169333f 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -3,7 +3,7 @@ True - + $(NoWarn);MCPEXP001 $(NoWarn);MCP9004 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs deleted file mode 100644 index 2b74fcd14..000000000 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpTaskIntegrationTests.cs +++ /dev/null @@ -1,342 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.AspNetCore.Tests.Utils; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.ComponentModel; -using System.Text.Json; - -namespace ModelContextProtocol.AspNetCore.Tests; - -/// -/// Integration tests for MCP Tasks feature over HTTP transports. -/// Tests task creation, polling, cancellation, and result retrieval. -/// -public class HttpTaskIntegrationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper) -{ - private readonly HttpClientTransportOptions DefaultTransportOptions = new() - { - Endpoint = new("http://localhost:5000/"), - Name = "In-memory Streamable HTTP Client", - }; - - private Task ConnectMcpClientAsync( - HttpClient? httpClient = null, - HttpClientTransportOptions? transportOptions = null, - McpClientOptions? clientOptions = null) - => McpClient.CreateAsync( - new HttpClientTransport(transportOptions ?? DefaultTransportOptions, httpClient ?? HttpClient, LoggerFactory), - clientOptions, - LoggerFactory, - TestContext.Current.CancellationToken); - - private static IDictionary CreateArguments(string key, object? value) - { - return new Dictionary - { - [key] = JsonSerializer.SerializeToElement(value, McpJsonUtilities.DefaultOptions) - }; - } - - [Fact] - public async Task CallToolAsTask_ReturnsTask_WhenServerSupportsTasksAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectMcpClientAsync(); - - // Act - Call tool with task augmentation - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long_running_operation", - Arguments = CreateArguments("durationMs", 100), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - // Assert - Response should indicate task was created - Assert.NotNull(result); - Assert.Null(result.IsError); - } - - [Fact] - public async Task GetTaskAsync_ReturnsTaskStatus_WhenTaskExistsAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectMcpClientAsync(); - - // First create a task by calling a tool with task augmentation - _ = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long_running_operation", - Arguments = CreateArguments("durationMs", 500), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - // Get all tasks - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotEmpty(tasks); - - // Act - Get the task status - var task = await client.GetTaskAsync(tasks[0].TaskId, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal(tasks[0].TaskId, task.TaskId); - } - - [Fact] - public async Task ListTasksAsync_ReturnsTasks_WhenTasksExistAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectMcpClientAsync(); - - // Create multiple tasks - for (int i = 0; i < 3; i++) - { - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long_running_operation", - Arguments = CreateArguments("durationMs", 1000), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - } - - // Act - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(tasks); - Assert.Equal(3, tasks.Count); - } - - [Fact] - public async Task CancelTaskAsync_CancelsTask_WhenTaskIsRunningAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectMcpClientAsync(); - - // Create a long-running task - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long_running_operation", - Arguments = CreateArguments("durationMs", 10000), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotEmpty(tasks); - - // Act - Cancel the task - var cancelledTask = await client.CancelTaskAsync(tasks[0].TaskId, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(cancelledTask); - Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status); - } - - [Fact] - public async Task GetTaskResultAsync_ReturnsResult_WhenTaskCompletesAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectMcpClientAsync(); - - // Create a quick task - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long_running_operation", - Arguments = CreateArguments("durationMs", 50), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.NotEmpty(tasks); - - // Wait a bit for the task to complete - await Task.Delay(200, TestContext.Current.CancellationToken); - - // Act - Get the task result - var result = await client.GetTaskResultAsync(tasks[0].TaskId, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotEqual(default, result); - } - - [Fact] - public async Task TasksIsolated_BetweenSessions_WhenMultipleClientsConnectAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - // Connect two separate clients - await using var client1 = await ConnectMcpClientAsync(); - await using var client2 = await ConnectMcpClientAsync(); - - // Client 1 creates a task - await client1.CallToolAsync( - new CallToolRequestParams - { - Name = "long_running_operation", - Arguments = CreateArguments("durationMs", 1000), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - // Act - Both clients list tasks - var client1Tasks = await client1.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - var client2Tasks = await client2.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Tasks should be isolated by session - Assert.Single(client1Tasks); - Assert.Empty(client2Tasks); - } - - [Fact] - public async Task ServerCapabilities_IncludesTasks_WhenTaskStoreConfiguredAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - // Act - await using var client = await ConnectMcpClientAsync(); - - // Assert - Assert.NotNull(client.ServerCapabilities?.Tasks); - } - - [Fact] - public async Task ListTools_ShowsTaskSupport_WhenToolIsAsyncAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - Builder.Services.AddMcpServer(options => - { - options.TaskStore = taskStore; - }) - .WithHttpTransport() - .WithTools(); - - await using var app = Builder.Build(); - app.MapMcp(); - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var client = await ConnectMcpClientAsync(); - - // Act - var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - var asyncTool = tools.FirstOrDefault(t => t.Name == "long_running_operation"); - Assert.NotNull(asyncTool); - Assert.NotNull(asyncTool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution.TaskSupport); - } - - [McpServerToolType] - public sealed class LongRunningTools - { - [McpServerTool, Description("Simulates a long-running operation")] - public static async Task LongRunningOperation( - [Description("Duration of the operation in milliseconds")] int durationMs, - CancellationToken cancellationToken) - { - await Task.Delay(durationMs, cancellationToken); - return $"Operation completed after {durationMs}ms"; - } - - [McpServerTool, Description("A synchronous tool that does not support tasks")] - public static string SyncTool([Description("Input message")] string message) - { - return $"Sync result: {message}"; - } - } -} diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index aaa6104cc..d04f2167a 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -162,27 +162,6 @@ private static void ConfigureTools(McpServerOptions options, string? cliArg) """), }, new Tool - { - Name = "longRunning", - Description = "Simulates a long-running operation that supports task-based execution.", - InputSchema = JsonElement.Parse(""" - { - "type": "object", - "properties": { - "durationMs": { - "type": "number", - "description": "Duration of the operation in milliseconds" - } - }, - "required": ["durationMs"] - } - """), - Execution = new ToolExecution - { - TaskSupport = ToolTaskSupport.Optional - } - }, - new Tool { Name = "crash", Description = "Terminates the server process with a specified exit code.", @@ -245,19 +224,6 @@ private static void ConfigureTools(McpServerOptions options, string? cliArg) Content = [new TextContentBlock { Text = cliArg ?? "null" }] }; } - else if (request.Params.Name == "longRunning") - { - if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("durationMs", out var durationMsValue)) - { - throw new McpProtocolException("Missing required argument 'durationMs'", McpErrorCode.InvalidParams); - } - int durationMs = Convert.ToInt32(durationMsValue.GetRawText()); - await Task.Delay(durationMs, cancellationToken); - return new CallToolResult - { - Content = [new TextContentBlock { Text = $"Long-running operation completed after {durationMs}ms" }] - }; - } else if (request.Params.Name == "crash") { if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("exitCode", out var exitCodeValue)) diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index e52a8ff1f..75211bb60 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -146,27 +146,6 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st } """), }, - new Tool - { - Name = "longRunning", - Description = "Simulates a long-running operation that supports task-based execution.", - InputSchema = JsonElement.Parse(""" - { - "type": "object", - "properties": { - "durationMs": { - "type": "number", - "description": "Duration of the operation in milliseconds" - } - }, - "required": ["durationMs"] - } - """), - Execution = new ToolExecution - { - TaskSupport = ToolTaskSupport.Optional - } - } ] }; }, @@ -212,19 +191,6 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st Content = [new TextContentBlock { Text = $"LLM sampling result: {sampleResult.Content.OfType().FirstOrDefault()?.Text}" }] }; } - else if (request.Params.Name == "longRunning") - { - if (request.Params.Arguments is null || !request.Params.Arguments.TryGetValue("durationMs", out var durationMsValue)) - { - throw new McpProtocolException("Missing required argument 'durationMs'", McpErrorCode.InvalidParams); - } - int durationMs = Convert.ToInt32(durationMsValue.ToString()); - await Task.Delay(durationMs, cancellationToken); - return new CallToolResult - { - Content = [new TextContentBlock { Text = $"Long-running operation completed after {durationMs}ms" }] - }; - } else { throw new McpProtocolException($"Unknown tool: '{request.Params.Name}'", McpErrorCode.InvalidParams); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index ada9970cf..879173819 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -1,32 +1,38 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using System.Runtime.InteropServices; using System.Text.Json; +#pragma warning disable MCPEXP001 + namespace ModelContextProtocol.Tests.Client; +/// +/// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync, +/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync. +/// public class McpClientTaskMethodsTests : ClientServerTestBase { public McpClientTaskMethodsTests(ITestOutputHelper outputHelper) : base(outputHelper) { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif } protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Add task store for server-side task support - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - - // Configure server to use the task store directly - services.Configure(options => + mcpServerBuilder.Services.Configure(options => { - options.TaskStore = taskStore; + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + }; }); - // Add a simple tool for testing mcpServerBuilder.WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { @@ -40,9 +46,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer })]); } - private static IDictionary CreateArguments(string key, object? value) + private static IDictionary CreateArguments(string key, string value) { - // For simple strings, just create a JsonElement from a string value return new Dictionary { [key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone() @@ -52,210 +57,196 @@ private static IDictionary CreateArguments(string key, obje [Fact] public async Task GetTaskAsync_ReturnsTaskStatus() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - // Create a task by calling a tool with task metadata - var callResult = await client.CallToolAsync( + var augmented = await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); + }, ct); - // The response should contain task metadata - Assert.NotNull(callResult.Task); - - string taskId = callResult.Task.TaskId; + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; - // Now get the task status - var task = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + // Get the task status + var task = await client.GetTaskAsync(taskId, ct); + Assert.NotNull(task); + } + + [Fact] + public async Task GetTaskAsync_UnknownTaskId_Throws() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - Assert.Equal(taskId, task.TaskId); + var ex = await Assert.ThrowsAsync(async () => + await client.GetTaskAsync("nonexistent-id", ct)); + + Assert.Contains("Unknown task", ex.Message); } [Fact] - public async Task GetTaskAsync_ThrowsForInvalidTaskId() + public async Task GetTaskAsync_NullTaskId_Throws() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync(async () => - await client.GetTaskAsync("", cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => + await client.GetTaskAsync((string)null!, TestContext.Current.CancellationToken)); } [Fact] - public async Task GetTaskResultAsync_ReturnsDeserializedResult() + public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - // Create a task - var callResult = await client.CallToolAsync( + var augmented = await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "hello"), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; + }, ct); - // Wait for task to complete and get the result - JsonElement result = await client.GetTaskResultAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - - // Verify the result has the expected CallToolResult shape - CallToolResult? toolResult = result.Deserialize(McpJsonUtilities.DefaultOptions); - Assert.NotNull(toolResult); - Assert.NotEmpty(toolResult.Content); - - TextContentBlock? textContent = toolResult.Content[0] as TextContentBlock; - Assert.NotNull(textContent); - Assert.Equal("Processed: hello", textContent.Text); + Assert.True(augmented.IsTask); + Assert.NotNull(augmented.TaskCreated); + Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status); + Assert.NotNull(augmented.TaskCreated.TaskId); + Assert.True(augmented.TaskCreated.PollIntervalMs > 0); } [Fact] - public async Task GetTaskResultAsync_ThrowsForInvalidTaskId() + public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "hello"), + }, ct); - await Assert.ThrowsAsync(async () => - await client.GetTaskResultAsync("", cancellationToken: TestContext.Current.CancellationToken)); + Assert.NotNull(result); + Assert.NotEmpty(result.Content); + var textContent = Assert.IsType(result.Content[0]); + Assert.Equal("Processed: hello", textContent.Text); } [Fact] - public async Task ListTasksAsync_ReturnsTasks() + public async Task CancelTaskAsync_ForWorkingTask_Succeeds() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - // Create a task - var callResult = await client.CallToolAsync( + var augmented = await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); + }, ct); - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; - // List all tasks - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); + // Cancel immediately (may succeed or fail depending on timing) + try + { + await client.CancelTaskAsync(taskId, ct); - Assert.NotNull(tasks); - Assert.Contains(tasks, t => t.TaskId == taskId); + // If cancel succeeded, verify the task is cancelled + var taskResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(taskResult); + } + catch (McpProtocolException) + { + // Task may have already completed before we could cancel — that's fine + } } [Fact] - public async Task ListTasksAsync_HandlesEmptyResult() + public async Task CancelTaskAsync_NullTaskId_Throws() { - await using McpClient client = await CreateMcpClientForServer(); - - // List tasks (may or may not be empty depending on state) - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); + await using var client = await CreateMcpClientForServer(); - Assert.NotNull(tasks); + await Assert.ThrowsAsync(async () => + await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken)); } [Fact] - public async Task ListTasksAsync_LowLevel_ReturnsRawResult() + public async Task CancelTaskAsync_UnknownTaskId_AcknowledgesIdempotently() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - // Create a task first - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "test-tool", - Arguments = CreateArguments("input", "task1"), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Use low-level API - var result = await client.ListTasksAsync(new ListTasksRequestParams(), TestContext.Current.CancellationToken); + // SEP-2663 requires servers to always acknowledge tasks/cancel, even when the task is + // unknown (e.g., has been garbage collected). The default handler must not throw. + var result = await client.CancelTaskAsync("nonexistent-id", ct); Assert.NotNull(result); - Assert.NotNull(result.Tasks); } [Fact] - public async Task ListTasksAsync_LowLevel_ThrowsForNullParams() + public async Task GetTaskAsync_AfterCompletion_ReturnsCompletedResult() { - await using McpClient client = await CreateMcpClientForServer(); - - await Assert.ThrowsAsync(async () => - await client.ListTasksAsync((ListTasksRequestParams)null!, TestContext.Current.CancellationToken)); - } + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - [Fact] - public async Task CancelTaskAsync_CancelsRunningTask() - { - await using McpClient client = await CreateMcpClientForServer(); - - // Create a task - var callResult = await client.CallToolAsync( + var augmented = await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", - Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; + Arguments = CreateArguments("input", "hello"), + }, ct); - // Cancel the task - var canceledTask = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + var taskId = augmented.TaskCreated!.TaskId; - Assert.Equal(taskId, canceledTask.TaskId); - } + // Poll until completed + GetTaskResult? taskResult = null; + for (int i = 0; i < 40; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is CompletedTaskResult) + { + break; + } + } - [Fact] - public async Task CancelTaskAsync_ThrowsForInvalidTaskId() - { - await using McpClient client = await CreateMcpClientForServer(); + var completed = Assert.IsType(taskResult); - await Assert.ThrowsAsync(async () => - await client.CancelTaskAsync("", cancellationToken: TestContext.Current.CancellationToken)); + // Deserialize the stored result + var toolResult = JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(toolResult); + Assert.NotEmpty(toolResult.Content); + var textContent = Assert.IsType(toolResult.Content[0]); + Assert.Equal("Processed: hello", textContent.Text); } [Fact] - public async Task ListTasksAsync_HandlesPagination() + public async Task MultipleTasks_CreatedConcurrently_HaveUniqueIds() { - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var taskIds = new HashSet(); - // Create multiple tasks - var taskIds = new List(); - for (int i = 0; i < 3; i++) + for (int i = 0; i < 5; i++) { - var result = await client.CallToolAsync( + var augmented = await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", $"task-{i}"), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result.Task); - taskIds.Add(result.Task.TaskId); - } - - // List all tasks (should handle pagination automatically if needed) - var tasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); + }, ct); - Assert.NotNull(tasks); - Assert.True(tasks.Count >= taskIds.Count, "Should retrieve at least the tasks we created"); - - // Verify all our tasks are in the result - foreach (var taskId in taskIds) - { - Assert.Contains(tasks, t => t.TaskId == taskId); + Assert.True(augmented.IsTask); + taskIds.Add(augmented.TaskCreated!.TaskId); } + + // All task IDs should be unique + Assert.Equal(5, taskIds.Count); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs deleted file mode 100644 index 906b4f491..000000000 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskSamplingElicitationTests.cs +++ /dev/null @@ -1,867 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Client; - -/// -/// Integration tests for task-based sampling and elicitation on the client side. -/// Tests the client's ability to receive task-augmented requests from the server, -/// execute them as tasks, and report results. -/// -public class McpClientTaskSamplingElicitationTests : ClientServerTestBase -{ - public McpClientTaskSamplingElicitationTests(ITestOutputHelper outputHelper) - : base(outputHelper) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - // Add task store for server-side task support - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - - // Configure server to use the task store - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - // Add a tool that uses sampling to generate responses - mcpServerBuilder.WithTools([McpServerTool.Create( - async (string prompt, McpServer server, CancellationToken ct) => - { - // This tool requests sampling from the client - var result = await server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], - MaxTokens = 100 - }, ct); - - return result.Content.OfType().FirstOrDefault()?.Text ?? "No response"; - }, - new McpServerToolCreateOptions - { - Name = "sample-tool", - Description = "A tool that uses sampling" - }), - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - // This tool requests elicitation from the client - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return result.Action == "confirm" ? "Confirmed" : "Declined"; - }, - new McpServerToolCreateOptions - { - Name = "elicit-tool", - Description = "A tool that uses elicitation" - })]); - } - - private static IDictionary CreateArguments(string key, object? value) - { - return new Dictionary - { - [key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone() - }; - } - - #region Client Task-Based Sampling Tests - - [Fact] - public async Task Client_WithTaskStoreAndSamplingHandler_AdvertisesTaskAugmentedSamplingCapability() - { - // Arrange - Create client with task store and sampling handler - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Sampled response" }], - Model = "test-model" - }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // The server should see the client's task capabilities - // We verify by checking server can use task-augmented requests - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Sampling); - Assert.NotNull(Server.ClientCapabilities.Tasks); - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Sampling?.CreateMessage); - } - - [Fact] - public async Task Client_WithoutTaskStore_DoesNotAdvertiseTaskAugmentedSamplingCapability() - { - // Arrange - Create client with sampling handler but NO task store - var clientOptions = new McpClientOptions - { - // No TaskStore configured - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Sampled response" }], - Model = "test-model" - }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // The server should see sampling capability but NOT task-augmented sampling - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Sampling); - - // Task capabilities should be null (no task store) - Assert.Null(Server.ClientCapabilities.Tasks); - } - - [Fact] - public async Task Server_SampleAsTaskAsync_FailsWhenClientDoesNotSupportTaskAugmentedSampling() - { - // Arrange - Client with sampling handler but NO task store - var clientOptions = new McpClientOptions - { - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Response" }], - Model = "model" - }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act & Assert - Server should throw when trying to use task-augmented sampling - var exception = await Assert.ThrowsAsync(async () => - { - await Server.SampleAsTaskAsync( - new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Test" }] }], - MaxTokens = 100 - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - }); - - Assert.Contains("task-augmented sampling", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Client_WithTaskStore_CanExecuteSamplingAsTask() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var samplingCompleted = new TaskCompletionSource(); - - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - // Simulate some work - await Task.Delay(50, ct); - samplingCompleted.TrySetResult(true); - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Task-based sampling response" }], - Model = "test-model" - }; - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act - Server requests task-augmented sampling - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], - MaxTokens = 100 - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Assert - Task was created - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - Assert.Equal(McpTaskStatus.Working, mcpTask.Status); - - // Wait for sampling to complete - await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Poll until task is complete - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - - // Get the result - var result = await Server.GetTaskResultAsync( - mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - var textContent = Assert.IsType(Assert.Single(result.Content)); - Assert.Equal("Task-based sampling response", textContent.Text); - } - - #endregion - - #region Client Task-Based Elicitation Tests - - [Fact] - public async Task Client_WithTaskStoreAndElicitationHandler_AdvertisesTaskAugmentedElicitationCapability() - { - // Arrange - Create client with task store and elicitation handler - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "confirm" }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Verify client advertised task-augmented elicitation - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Elicitation); - Assert.NotNull(Server.ClientCapabilities.Tasks); - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Elicitation?.Create); - } - - [Fact] - public async Task Client_WithoutTaskStore_DoesNotAdvertiseTaskAugmentedElicitationCapability() - { - // Arrange - Create client with elicitation handler but NO task store - var clientOptions = new McpClientOptions - { - // No TaskStore configured - Handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "confirm" }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Verify elicitation is supported but NOT task-augmented - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Elicitation); - Assert.Null(Server.ClientCapabilities.Tasks); - } - - [Fact] - public async Task Server_ElicitAsTaskAsync_FailsWhenClientDoesNotSupportTaskAugmentedElicitation() - { - // Arrange - Client with elicitation handler but NO task store - var clientOptions = new McpClientOptions - { - Handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "confirm" }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act & Assert - Server should throw when trying to use task-augmented elicitation - var exception = await Assert.ThrowsAsync(async () => - { - await Server.ElicitAsTaskAsync( - new ElicitRequestParams - { - Message = "Please confirm", - RequestedSchema = new() - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - }); - - Assert.Contains("task-augmented elicitation", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Client_WithTaskStore_CanExecuteElicitationAsTask() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var elicitationCompleted = new TaskCompletionSource(); - - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - ElicitationHandler = async (request, ct) => - { - // Simulate user interaction time - await Task.Delay(50, ct); - elicitationCompleted.TrySetResult(true); - return new ElicitResult - { - Action = "accept", - Content = new Dictionary - { - ["answer"] = JsonDocument.Parse("\"yes\"").RootElement.Clone() - } - }; - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act - Server requests task-augmented elicitation - var mcpTask = await Server.ElicitAsTaskAsync( - new ElicitRequestParams - { - Message = "Do you want to proceed?", - RequestedSchema = new() - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Assert - Task was created - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - Assert.Equal(McpTaskStatus.Working, mcpTask.Status); - - // Wait for elicitation to complete - await elicitationCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Poll until task is complete - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - - // Get the result - var result = await Server.GetTaskResultAsync( - mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - Assert.Equal("accept", result.Action); - } - - #endregion - - #region Client Task Reporting Tests - - [Fact] - public async Task Client_CanListOwnTasks() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - await Task.Delay(50, ct); - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Response" }], - Model = "model" - }; - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Create multiple tasks - var task1 = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 100 }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - var task2 = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 100 }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Act - Server lists tasks from client - var tasks = await Server.ListTasksAsync(TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(tasks); - Assert.True(tasks.Count >= 2, "Should have at least 2 tasks"); - Assert.Contains(tasks, t => t.TaskId == task1.TaskId); - Assert.Contains(tasks, t => t.TaskId == task2.TaskId); - } - - [Fact] - public async Task Client_CanCancelTasks() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var samplingStarted = new TaskCompletionSource(); - var allowCompletion = new TaskCompletionSource(); - - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - samplingStarted.TrySetResult(true); - // Wait for either completion signal or cancellation - try - { - await allowCompletion.Task.WaitAsync(ct); - } - catch (OperationCanceledException) - { - throw; - } - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Should not reach here" }], - Model = "model" - }; - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Create a task that will be in progress - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 100 }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Wait for sampling to start - await samplingStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Act - Cancel the task - var cancelledTask = await Server.CancelTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(cancelledTask); - Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status); - - // Allow completion to avoid hanging (the handler might still be running) - allowCompletion.TrySetResult(true); - } - - [Fact] - public async Task Client_TaskStatusNotifications_SentWhenEnabled() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var workingNotificationReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var completedNotificationReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var notificationsReceived = new List(); - var notificationsLock = new object(); - string? expectedTaskId = null; - var expectedTaskIdLock = new object(); - - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - SendTaskStatusNotifications = true, - Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - await Task.Delay(100, ct); - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Done" }], - Model = "model" - }; - } - } - }; - - // Register notification handler on the server BEFORE creating the client - var notificationHandler = Server.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, - (notification, ct) => - { - if (notification.Params is not { } paramsNode) - { - return default; - } - - var taskNotification = JsonSerializer.Deserialize( - paramsNode, McpJsonUtilities.DefaultOptions); - if (taskNotification is null) - { - return default; - } - - // Only track notifications for our task - string? taskId; - lock (expectedTaskIdLock) - { - taskId = expectedTaskId; - } - if (taskId is not null && taskNotification.TaskId != taskId) - { - return default; - } - - lock (notificationsLock) - { - notificationsReceived.Add(new McpTask - { - TaskId = taskNotification.TaskId, - Status = taskNotification.Status, - CreatedAt = taskNotification.CreatedAt, - LastUpdatedAt = taskNotification.LastUpdatedAt - }); - } - - // Signal when we receive the Working and Completed notifications - if (taskNotification.Status == McpTaskStatus.Working) - { - workingNotificationReceived.TrySetResult(true); - } - else if (taskNotification.Status == McpTaskStatus.Completed) - { - completedNotificationReceived.TrySetResult(true); - } - - return default; - }); - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act - Create a task - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 100 }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Store the expected task ID for filtering - lock (expectedTaskIdLock) - { - expectedTaskId = mcpTask.TaskId; - } - - // Wait for both Working and Completed notifications to arrive - // The notifications are sent asynchronously so we need to wait for both - await Task.WhenAll( - workingNotificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken), - completedNotificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); - - // Assert - Should have received notifications for status transitions - await notificationHandler.DisposeAsync(); - - List notifications; - lock (notificationsLock) - { - notifications = [.. notificationsReceived]; - } - - Assert.NotEmpty(notifications); - Assert.Contains(notifications, t => t.Status == McpTaskStatus.Working); - Assert.Contains(notifications, t => t.Status == McpTaskStatus.Completed); - - // Verify all notifications are for the correct task - Assert.All(notifications, t => Assert.Equal(mcpTask.TaskId, t.TaskId)); - } - - #endregion - - #region Error Handling Tests - - [Fact] - public async Task Client_SamplingHandlerException_ResultsInFailedTask() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var samplingAttempted = new TaskCompletionSource(); - - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - samplingAttempted.TrySetResult(true); - throw new InvalidOperationException("Sampling failed!"); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 100 }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Wait for sampling attempt - await samplingAttempted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Poll until task status changes - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - // Assert - Task should be in failed state - Assert.Equal(McpTaskStatus.Failed, taskStatus.Status); - Assert.NotNull(taskStatus.StatusMessage); - Assert.Contains("Sampling failed!", taskStatus.StatusMessage); - } - - [Fact] - public async Task Client_ElicitationHandlerException_ResultsInFailedTask() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var elicitationAttempted = new TaskCompletionSource(); - - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - elicitationAttempted.TrySetResult(true); - throw new InvalidOperationException("Elicitation failed!"); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Act - var mcpTask = await Server.ElicitAsTaskAsync( - new ElicitRequestParams - { - Message = "Test", - RequestedSchema = new() - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - // Wait for elicitation attempt - await elicitationAttempted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Poll until task status changes - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - // Assert - Assert.Equal(McpTaskStatus.Failed, taskStatus.Status); - Assert.NotNull(taskStatus.StatusMessage); - Assert.Contains("Elicitation failed!", taskStatus.StatusMessage); - } - - #endregion - - #region Capability Validation Tests - - [Fact] - public async Task Client_WithOnlySamplingHandler_OnlyAdvertisesSamplingTasks() - { - // Arrange - Client with only sampling handler and task store - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Response" }], - Model = "model" - }); - } - // No ElicitationHandler - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Assert - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Tasks); - - // Should have sampling task capability - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Sampling?.CreateMessage); - - // Should NOT have elicitation task capability - Assert.Null(Server.ClientCapabilities.Tasks.Requests?.Elicitation); - } - - [Fact] - public async Task Client_WithOnlyElicitationHandler_OnlyAdvertisesElicitationTasks() - { - // Arrange - Client with only elicitation handler and task store - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "confirm" }); - } - // No SamplingHandler - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Assert - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Tasks); - - // Should have elicitation task capability - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests?.Elicitation?.Create); - - // Should NOT have sampling task capability - Assert.Null(Server.ClientCapabilities.Tasks.Requests?.Sampling); - } - - [Fact] - public async Task Client_WithBothHandlers_AdvertisesBothTaskCapabilities() - { - // Arrange - Client with both handlers and task store - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Response" }], - Model = "model" - }); - }, - ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "confirm" }); - } - } - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Assert - Assert.NotNull(Server.ClientCapabilities); - Assert.NotNull(Server.ClientCapabilities.Tasks); - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests); - - // Should have both capabilities - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests.Sampling?.CreateMessage); - Assert.NotNull(Server.ClientCapabilities.Tasks.Requests.Elicitation?.Create); - - // Should also have list and cancel capabilities - Assert.NotNull(Server.ClientCapabilities.Tasks.List); - Assert.NotNull(Server.ClientCapabilities.Tasks.Cancel); - } - - [Fact] - public async Task Client_WithNoHandlers_DoesNotAdvertiseTaskCapabilities() - { - // Arrange - Client with task store but no handlers - var taskStore = new InMemoryMcpTaskStore(); - var clientOptions = new McpClientOptions - { - TaskStore = taskStore, - Handlers = new McpClientHandlers() - // No handlers configured - }; - - await using McpClient client = await CreateMcpClientForServer(clientOptions); - - // Assert - No capabilities should be advertised without handlers - Assert.NotNull(Server.ClientCapabilities); - - // Note: Tasks capability is advertised based on task store being present, - // but request types depend on specific handlers - if (Server.ClientCapabilities.Tasks is not null) - { - // If Tasks is present, requests should be null or have no request types - var requests = Server.ClientCapabilities.Tasks.Requests; - if (requests is not null) - { - Assert.Null(requests.Sampling); - Assert.Null(requests.Elicitation); - } - } - } - - #endregion -} diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs index c59c6a09e..f339e0f20 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs @@ -72,12 +72,23 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() { List messageTypes = []; + // The client sends notifications/initialized fire-and-forget, so unlike the initialize and + // tools/list request/response exchanges it has no synchronization point the test can await. + // Signal once the filter finishes processing it so the strict counts below observe a + // complete, stable log instead of racing the still-in-flight notification. + var initializedNotificationProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerBuilder .WithMessageFilters(filters => filters.AddIncomingFilter((next) => async (context, cancellationToken) => { var messageTypeName = context.JsonRpcMessage.GetType().Name; messageTypes.Add(messageTypeName); await next(context, cancellationToken); + + if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification }) + { + initializedNotificationProcessed.TrySetResult(true); + } })) .WithTools(); @@ -87,6 +98,10 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + // Wait for the fire-and-forget initialized notification to flow through the filter pipeline + // before snapshotting the counts; otherwise the strict counts below can race the notification. + await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + // The message filter should intercept JsonRpcRequest messages. // Use strict counts so a regression that invokes the filter pipeline more than once per // incoming message (analogous to the SendRequestAsync double-wrap regression on the outgoing diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerOptionsSetupTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerOptionsSetupTests.cs index 689aba9d0..dc2eaf805 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerOptionsSetupTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerOptionsSetupTests.cs @@ -284,57 +284,4 @@ public void Configure_WithCompleteHandler_CreatesCompletionsCapability() Assert.NotNull(options.Capabilities?.Completions); } #endregion - - #region TaskStore Tests - [Fact] - public void TaskStore_IsPopulatedFromDI_WhenNotExplicitlySet() - { - var services = new ServiceCollection(); - services.AddMcpServer(); - services.AddSingleton(); - - var options = services.BuildServiceProvider().GetRequiredService>().Value; - - Assert.IsType(options.TaskStore); - } - - [Fact] - public void TaskStore_ExplicitOption_TakesPrecedenceOverDI() - { - var explicitStore = new InMemoryMcpTaskStore(); - - var services = new ServiceCollection(); - services.AddMcpServer(options => options.TaskStore = explicitStore); - services.AddSingleton(); - - var options = services.BuildServiceProvider().GetRequiredService>().Value; - - Assert.Same(explicitStore, options.TaskStore); - } - - [Fact] - public void TaskStore_RemainsNull_WhenNothingIsRegistered() - { - var services = new ServiceCollection(); - services.AddMcpServer(); - - var options = services.BuildServiceProvider().GetRequiredService>().Value; - - Assert.Null(options.TaskStore); - } - - [Fact] - public void TaskStore_CanBeOverriddenToNull_AfterDIRegistration() - { - var services = new ServiceCollection(); - services.AddMcpServer(); - services.AddSingleton(); - - services.Configure(options => options.TaskStore = null); - - var options = services.BuildServiceProvider().GetRequiredService>().Value; - - Assert.Null(options.TaskStore); - } - #endregion } \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs index d68902ef5..866a59c61 100644 --- a/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using ModelContextProtocol.Protocol; @@ -10,13 +11,13 @@ namespace ModelContextProtocol.Tests; /// /// /// -/// Experimental properties (e.g. , ) +/// Experimental properties (e.g. , ) /// use an internal *Core property for serialization. A consumer's source-generated /// cannot see internal members, so experimental data is /// silently dropped unless the consumer chains the SDK's resolver into their options. /// /// -/// These tests depend on and +/// These tests depend on and /// being experimental. When those APIs stabilize, update these tests to reference whatever /// experimental properties exist at that time, or remove them entirely if no experimental /// APIs remain. @@ -32,36 +33,36 @@ public void ExperimentalProperties_Dropped_WithConsumerContextOnly() TypeInfoResolverChain = { ConsumerJsonContext.Default } }; - var tool = new Tool + var capabilities = new ServerCapabilities { - Name = "test-tool", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + Tools = new ToolsCapability(), + Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } }; - string json = JsonSerializer.Serialize(tool, options); - Assert.DoesNotContain("\"execution\"", json); - Assert.Contains("\"name\"", json); + string json = JsonSerializer.Serialize(capabilities, options); + Assert.DoesNotContain("\"extensions\"", json); + Assert.Contains("\"tools\"", json); } [Fact] public void ExperimentalProperties_IgnoredOnDeserialize_WithConsumerContextOnly() { string json = JsonSerializer.Serialize( - new Tool + new ServerCapabilities { - Name = "test-tool", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + Tools = new ToolsCapability(), + Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } }, McpJsonUtilities.DefaultOptions); - Assert.Contains("\"execution\"", json); + Assert.Contains("\"extensions\"", json); var options = new JsonSerializerOptions { TypeInfoResolverChain = { ConsumerJsonContext.Default } }; - var deserialized = JsonSerializer.Deserialize(json, options)!; - Assert.Equal("test-tool", deserialized.Name); - Assert.Null(deserialized.Execution); + var deserialized = JsonSerializer.Deserialize(json, options)!; + Assert.NotNull(deserialized.Tools); + Assert.Null(deserialized.Extensions); } [Fact] @@ -76,35 +77,36 @@ public void ExperimentalProperties_RoundTrip_WhenSdkResolverIsChained() } }; - var tool = new Tool + var capabilities = new ServerCapabilities { - Name = "test-tool", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + Tools = new ToolsCapability(), + Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } }; - string json = JsonSerializer.Serialize(tool, options); - Assert.Contains("\"execution\"", json); - Assert.Contains("\"name\"", json); + string json = JsonSerializer.Serialize(capabilities, options); + Assert.Contains("\"extensions\"", json); + Assert.Contains("\"tools\"", json); - var deserialized = JsonSerializer.Deserialize(json, options)!; - Assert.Equal("test-tool", deserialized.Name); - Assert.NotNull(deserialized.Execution); - Assert.Equal(ToolTaskSupport.Optional, deserialized.Execution.TaskSupport); + var deserialized = JsonSerializer.Deserialize(json, options)!; + Assert.NotNull(deserialized.Tools); + Assert.NotNull(deserialized.Extensions); + Assert.True(deserialized.Extensions.ContainsKey("io.test")); } [Fact] public void ExperimentalProperties_RoundTrip_WithDefaultOptions() { - var capabilities = new ServerCapabilities + var capabilities = new ClientCapabilities { - Tasks = new McpTasksCapability() + Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } }; string json = JsonSerializer.Serialize(capabilities, McpJsonUtilities.DefaultOptions); - Assert.Contains("\"tasks\"", json); + Assert.Contains("\"extensions\"", json); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; - Assert.NotNull(deserialized.Tasks); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.NotNull(deserialized.Extensions); + Assert.True(deserialized.Extensions.ContainsKey("io.test")); } } diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 7f7de2a41..a9b40a412 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -35,10 +35,6 @@ - - - - diff --git a/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs index d2f5a09ad..ec758120f 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CallToolRequestParamsTests.cs @@ -17,7 +17,6 @@ public static void CallToolRequestParams_SerializationRoundTrip_PreservesAllProp ["city"] = JsonDocument.Parse("\"Seattle\"").RootElement.Clone(), ["units"] = JsonDocument.Parse("\"metric\"").RootElement.Clone() }, - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromHours(1) }, Meta = new JsonObject { ["progressToken"] = "token-123" } }; @@ -30,8 +29,6 @@ public static void CallToolRequestParams_SerializationRoundTrip_PreservesAllProp Assert.Equal(2, deserialized.Arguments.Count); Assert.Equal("Seattle", deserialized.Arguments["city"].GetString()); Assert.Equal("metric", deserialized.Arguments["units"].GetString()); - Assert.NotNull(deserialized.Task); - Assert.Equal(original.Task.TimeToLive, deserialized.Task.TimeToLive); Assert.NotNull(deserialized.Meta); Assert.Equal("token-123", (string)deserialized.Meta["progressToken"]!); } @@ -50,7 +47,6 @@ public static void CallToolRequestParams_SerializationRoundTrip_WithMinimalPrope Assert.NotNull(deserialized); Assert.Equal(original.Name, deserialized.Name); Assert.Null(deserialized.Arguments); - Assert.Null(deserialized.Task); Assert.Null(deserialized.Meta); } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs index d66e03b3f..b1ac90c9d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CallToolResultTests.cs @@ -14,13 +14,6 @@ public static void CallToolResult_SerializationRoundTrip_PreservesAllProperties( Content = [new TextContentBlock { Text = "Result text" }], StructuredContent = JsonElement.Parse("""{"temperature":72}"""), IsError = false, - Task = new McpTask - { - TaskId = "task-1", - Status = McpTaskStatus.Completed, - CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), - LastUpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - }, Meta = new JsonObject { ["key"] = "value" } }; @@ -34,8 +27,6 @@ public static void CallToolResult_SerializationRoundTrip_PreservesAllProperties( Assert.NotNull(deserialized.StructuredContent); Assert.Equal(72, deserialized.StructuredContent.Value.GetProperty("temperature").GetInt32()); Assert.False(deserialized.IsError); - Assert.NotNull(deserialized.Task); - Assert.Equal("task-1", deserialized.Task.TaskId); Assert.NotNull(deserialized.Meta); Assert.Equal("value", (string)deserialized.Meta["key"]!); } @@ -52,7 +43,6 @@ public static void CallToolResult_SerializationRoundTrip_WithMinimalProperties() Assert.Empty(deserialized.Content); Assert.Null(deserialized.StructuredContent); Assert.Null(deserialized.IsError); - Assert.Null(deserialized.Task); Assert.Null(deserialized.Meta); } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs deleted file mode 100644 index a3b3b2ef6..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskRequestParamsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class CancelMcpTaskRequestParamsTests -{ - [Fact] - public static void CancelMcpTaskRequestParams_SerializationRoundTrip() - { - // Arrange - var original = new CancelMcpTaskRequestParams - { - TaskId = "cancel-task-456" - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs deleted file mode 100644 index 5cf628642..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/CancelMcpTaskResultTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class CancelMcpTaskResultTests -{ - [Fact] - public static void CancelMcpTaskResult_SerializationRoundTrip() - { - // Arrange - var original = new CancelMcpTaskResult - { - TaskId = "cancelled-789", - Status = McpTaskStatus.Cancelled, - StatusMessage = "Cancelled by user", - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = null, - PollInterval = null - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - Assert.Equal(original.Status, deserialized.Status); - Assert.Equal(original.StatusMessage, deserialized.StatusMessage); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs index cacb7e84e..82613dd53 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs @@ -21,7 +21,6 @@ public static void ClientCapabilities_SerializationRoundTrip_PreservesAllPropert Form = new FormElicitationCapability(), Url = new UrlElicitationCapability() }, - Tasks = new McpTasksCapability(), Extensions = new Dictionary { ["io.modelcontextprotocol/test"] = new object() @@ -40,7 +39,6 @@ public static void ClientCapabilities_SerializationRoundTrip_PreservesAllPropert Assert.NotNull(deserialized.Elicitation); Assert.NotNull(deserialized.Elicitation.Form); Assert.NotNull(deserialized.Elicitation.Url); - Assert.NotNull(deserialized.Tasks); Assert.NotNull(deserialized.Extensions); Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/test")); } @@ -58,7 +56,6 @@ public static void ClientCapabilities_SerializationRoundTrip_WithMinimalProperti Assert.Null(deserialized.Roots); Assert.Null(deserialized.Sampling); Assert.Null(deserialized.Elicitation); - Assert.Null(deserialized.Tasks); Assert.Null(deserialized.Extensions); } diff --git a/tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs deleted file mode 100644 index 0252053cb..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/CreateTaskResultTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class CreateTaskResultTests -{ - [Fact] - public static void CreateTaskResult_SerializationRoundTrip_PreservesAllProperties() - { - var original = new CreateTaskResult - { - Task = new McpTask - { - TaskId = "task-123", - Status = McpTaskStatus.Working, - StatusMessage = "Processing", - CreatedAt = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero), - LastUpdatedAt = new DateTimeOffset(2025, 6, 1, 12, 5, 0, TimeSpan.Zero), - TimeToLive = TimeSpan.FromHours(1), - PollInterval = TimeSpan.FromSeconds(5) - }, - Meta = new JsonObject { ["key"] = "value" } - }; - - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - Assert.NotNull(deserialized); - Assert.Equal("task-123", deserialized.Task.TaskId); - Assert.Equal(McpTaskStatus.Working, deserialized.Task.Status); - Assert.Equal("Processing", deserialized.Task.StatusMessage); - Assert.Equal(original.Task.CreatedAt, deserialized.Task.CreatedAt); - Assert.Equal(original.Task.LastUpdatedAt, deserialized.Task.LastUpdatedAt); - Assert.Equal(original.Task.TimeToLive, deserialized.Task.TimeToLive); - Assert.Equal(original.Task.PollInterval, deserialized.Task.PollInterval); - Assert.NotNull(deserialized.Meta); - Assert.Equal("value", (string)deserialized.Meta["key"]!); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs index 1d57f55ad..f8e2fedbf 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitRequestParamsTests.cs @@ -23,7 +23,6 @@ public static void ElicitRequestParams_SerializationRoundTrip_PreservesAllProper ["age"] = new ElicitRequestParams.NumberSchema { Description = "Your age" } } }, - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }, Meta = new JsonObject { ["progressToken"] = "tok-1" } }; @@ -37,8 +36,6 @@ public static void ElicitRequestParams_SerializationRoundTrip_PreservesAllProper Assert.Equal("Please provide your details", deserialized.Message); Assert.NotNull(deserialized.RequestedSchema); Assert.Equal(2, deserialized.RequestedSchema.Properties.Count); - Assert.NotNull(deserialized.Task); - Assert.Equal(TimeSpan.FromMinutes(10), deserialized.Task.TimeToLive); Assert.NotNull(deserialized.Meta); Assert.Equal("tok-1", (string)deserialized.Meta["progressToken"]!); } @@ -63,7 +60,6 @@ public static void ElicitRequestParams_SerializationRoundTrip_UrlMode() Assert.Equal("https://example.com/auth", deserialized.Url); Assert.Equal("Please authenticate", deserialized.Message); Assert.Null(deserialized.RequestedSchema); - Assert.Null(deserialized.Task); } [Fact] @@ -83,7 +79,6 @@ public static void ElicitRequestParams_SerializationRoundTrip_WithMinimalPropert Assert.Null(deserialized.ElicitationId); Assert.Null(deserialized.Url); Assert.Null(deserialized.RequestedSchema); - Assert.Null(deserialized.Task); Assert.Null(deserialized.Meta); } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs deleted file mode 100644 index 47f427259..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/GetTaskPayloadRequestParamsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class GetTaskPayloadRequestParamsTests -{ - [Fact] - public static void GetTaskPayloadRequestParams_SerializationRoundTrip() - { - // Arrange - var original = new GetTaskPayloadRequestParams - { - TaskId = "payload-task-999" - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs deleted file mode 100644 index 9b3e7b1d5..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/GetTaskRequestParamsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class GetTaskRequestParamsTests -{ - [Fact] - public static void GetTaskRequestParams_SerializationRoundTrip() - { - // Arrange - var original = new GetTaskRequestParams - { - TaskId = "get-task-123" - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs deleted file mode 100644 index ece58683f..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/GetTaskResultTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class GetTaskResultTests -{ - [Fact] - public static void GetTaskResult_SerializationRoundTrip() - { - // Arrange - var original = new GetTaskResult - { - TaskId = "result-123", - Status = McpTaskStatus.Completed, - StatusMessage = "Done", - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromHours(1), - PollInterval = TimeSpan.FromSeconds(1) - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - Assert.Equal(original.Status, deserialized.Status); - Assert.Equal(original.StatusMessage, deserialized.StatusMessage); - Assert.Equal(original.CreatedAt, deserialized.CreatedAt); - Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); - Assert.Equal(original.TimeToLive, deserialized.TimeToLive); - Assert.Equal(original.PollInterval, deserialized.PollInterval); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs deleted file mode 100644 index 3e9022757..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/ListTasksRequestParamsTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class ListTasksRequestParamsTests -{ - [Fact] - public static void ListTasksRequestParams_SerializationRoundTrip() - { - // Arrange - var original = new ListTasksRequestParams - { - Cursor = "cursor-abc123" - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Cursor, deserialized.Cursor); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs deleted file mode 100644 index 8d2fbd33b..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/ListTasksResultTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class ListTasksResultTests -{ - [Fact] - public static void ListTasksResult_SerializationRoundTrip() - { - // Arrange - var original = new ListTasksResult - { - Tasks = - [ - new McpTask - { - TaskId = "task-1", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow - }, - new McpTask - { - TaskId = "task-2", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow - } - ], - NextCursor = "next-page-token" - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.NotNull(deserialized.Tasks); - Assert.Equal(2, deserialized.Tasks.Count); - Assert.Equal(original.Tasks[0].TaskId, deserialized.Tasks[0].TaskId); - Assert.Equal(original.Tasks[1].TaskId, deserialized.Tasks[1].TaskId); - Assert.Equal(original.NextCursor, deserialized.NextCursor); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs deleted file mode 100644 index 82f33fbe7..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/McpTaskMetadataTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class McpTaskMetadataTests -{ - [Fact] - public static void McpTaskMetadata_SerializationRoundTrip_WithTimeToLive() - { - // Arrange - var original = new McpTaskMetadata - { - TimeToLive = TimeSpan.FromHours(2) - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TimeToLive, deserialized.TimeToLive); - } - - [Fact] - public static void McpTaskMetadata_SerializationRoundTrip_WithNullTimeToLive() - { - // Arrange - var original = new McpTaskMetadata(); - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Null(deserialized.TimeToLive); - } - - [Fact] - public static void McpTaskMetadata_HasCorrectJsonPropertyNames() - { - var metadata = new McpTaskMetadata - { - TimeToLive = TimeSpan.FromMinutes(15) - }; - - string json = JsonSerializer.Serialize(metadata, McpJsonUtilities.DefaultOptions); - - Assert.Contains("\"ttl\":", json); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs deleted file mode 100644 index bf3cbbbf0..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/McpTaskStatusNotificationParamsTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class McpTaskStatusNotificationParamsTests -{ - [Fact] - public static void McpTaskStatusNotificationParams_SerializationRoundTrip() - { - // Arrange - var original = new McpTaskStatusNotificationParams - { - TaskId = "notification-task", - Status = McpTaskStatus.Completed, - StatusMessage = "Task completed successfully", - CreatedAt = new DateTimeOffset(2025, 12, 9, 10, 0, 0, TimeSpan.Zero), - LastUpdatedAt = new DateTimeOffset(2025, 12, 9, 10, 30, 0, TimeSpan.Zero), - TimeToLive = TimeSpan.FromHours(1), - PollInterval = TimeSpan.FromSeconds(2) - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - Assert.Equal(original.Status, deserialized.Status); - Assert.Equal(original.StatusMessage, deserialized.StatusMessage); - Assert.Equal(original.CreatedAt, deserialized.CreatedAt); - Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); - Assert.Equal(original.TimeToLive, deserialized.TimeToLive); - Assert.Equal(original.PollInterval, deserialized.PollInterval); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs deleted file mode 100644 index 7919e408e..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/McpTaskTests.cs +++ /dev/null @@ -1,160 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class McpTaskTests -{ - [Fact] - public static void McpTask_SerializationRoundTrip_PreservesAllProperties() - { - // Arrange - var original = new McpTask - { - TaskId = "task-12345", - Status = McpTaskStatus.Working, - StatusMessage = "Processing request", - CreatedAt = new DateTimeOffset(2025, 12, 9, 10, 30, 0, TimeSpan.Zero), - LastUpdatedAt = new DateTimeOffset(2025, 12, 9, 10, 35, 0, TimeSpan.Zero), - TimeToLive = TimeSpan.FromHours(24), - PollInterval = TimeSpan.FromSeconds(5) - }; - - // Act - Serialize to JSON - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - - // Act - Deserialize back from JSON - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - Assert.Equal(original.Status, deserialized.Status); - Assert.Equal(original.StatusMessage, deserialized.StatusMessage); - Assert.Equal(original.CreatedAt, deserialized.CreatedAt); - Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); - Assert.Equal(original.TimeToLive, deserialized.TimeToLive); - Assert.Equal(original.PollInterval, deserialized.PollInterval); - } - - [Fact] - public static void McpTask_SerializationRoundTrip_WithMinimalProperties() - { - // Arrange - var original = new McpTask - { - TaskId = "task-minimal", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow - }; - - // Act - Serialize to JSON - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - - // Act - Deserialize back from JSON - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.TaskId, deserialized.TaskId); - Assert.Equal(original.Status, deserialized.Status); - Assert.Null(deserialized.StatusMessage); - Assert.Equal(original.CreatedAt, deserialized.CreatedAt); - Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); - Assert.Null(deserialized.TimeToLive); - Assert.Null(deserialized.PollInterval); - } - - [Fact] - public static void McpTask_HasCorrectJsonPropertyNames() - { - var task = new McpTask - { - TaskId = "test-task", - Status = McpTaskStatus.Working, - StatusMessage = "Test message", - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromMinutes(30), - PollInterval = TimeSpan.FromSeconds(1) - }; - - string json = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); - - Assert.Contains("\"taskId\":", json); - Assert.Contains("\"status\":", json); - Assert.Contains("\"statusMessage\":", json); - Assert.Contains("\"createdAt\":", json); - Assert.Contains("\"lastUpdatedAt\":", json); - Assert.Contains("\"ttl\":", json); - Assert.Contains("\"pollInterval\":", json); - } - - [Fact] - public static void McpTask_TimeToLive_SerializesAsMilliseconds() - { - var task = new McpTask - { - TaskId = "test-ttl", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromSeconds(60) - }; - - string json = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); - - Assert.Contains("\"ttl\":60000", json); - } - - [Theory] - [InlineData(McpTaskStatus.Working)] - [InlineData(McpTaskStatus.InputRequired)] - [InlineData(McpTaskStatus.Completed)] - [InlineData(McpTaskStatus.Failed)] - [InlineData(McpTaskStatus.Cancelled)] - public static void McpTaskStatus_SerializesCorrectly(McpTaskStatus status) - { - var task = new McpTask - { - TaskId = "status-test", - Status = status, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow - }; - - string json = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - Assert.NotNull(deserialized); - Assert.Equal(status, deserialized.Status); - } - - [Fact] - public static void McpTaskStatus_HasCorrectJsonValues() - { - var statuses = new[] - { - (McpTaskStatus.Working, "working"), - (McpTaskStatus.InputRequired, "input_required"), - (McpTaskStatus.Completed, "completed"), - (McpTaskStatus.Failed, "failed"), - (McpTaskStatus.Cancelled, "cancelled") - }; - - foreach (var (status, expectedJson) in statuses) - { - var task = new McpTask - { - TaskId = "test", - Status = status, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow - }; - - string json = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); - Assert.Contains($"\"status\":\"{expectedJson}\"", json); - } - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs deleted file mode 100644 index 4e8caa740..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/McpTasksCapabilityTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class McpTasksCapabilityTests -{ - [Fact] - public static void McpTasksCapability_SerializationRoundTrip_WithAllProperties() - { - // Arrange - var original = new McpTasksCapability - { - List = new ListMcpTasksCapability(), - Cancel = new CancelMcpTasksCapability(), - Requests = new RequestMcpTasksCapability - { - Tools = new ToolsMcpTasksCapability - { - Call = new CallToolMcpTasksCapability() - }, - Sampling = new SamplingMcpTasksCapability - { - CreateMessage = new CreateMessageMcpTasksCapability() - }, - Elicitation = new ElicitationMcpTasksCapability - { - Create = new CreateElicitationMcpTasksCapability() - } - } - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.NotNull(deserialized.List); - Assert.NotNull(deserialized.Cancel); - Assert.NotNull(deserialized.Requests); - Assert.NotNull(deserialized.Requests.Tools); - Assert.NotNull(deserialized.Requests.Tools.Call); - Assert.NotNull(deserialized.Requests.Sampling); - Assert.NotNull(deserialized.Requests.Sampling.CreateMessage); - Assert.NotNull(deserialized.Requests.Elicitation); - Assert.NotNull(deserialized.Requests.Elicitation.Create); - } - - [Fact] - public static void McpTasksCapability_SerializationRoundTrip_WithMinimalProperties() - { - // Arrange - var original = new McpTasksCapability(); - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Null(deserialized.List); - Assert.Null(deserialized.Cancel); - Assert.Null(deserialized.Requests); - } - - [Fact] - public static void McpTasksCapability_HasCorrectJsonPropertyNames() - { - var capability = new McpTasksCapability - { - List = new ListMcpTasksCapability(), - Cancel = new CancelMcpTasksCapability(), - Requests = new RequestMcpTasksCapability - { - Tools = new ToolsMcpTasksCapability - { - Call = new CallToolMcpTasksCapability() - } - } - }; - - string json = JsonSerializer.Serialize(capability, McpJsonUtilities.DefaultOptions); - - Assert.Contains("\"list\":", json); - Assert.Contains("\"cancel\":", json); - Assert.Contains("\"requests\":", json); - Assert.Contains("\"tools\":", json); - Assert.Contains("\"call\":", json); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs deleted file mode 100644 index 8bfcb3be4..000000000 --- a/tests/ModelContextProtocol.Tests/Protocol/RequestMcpTasksCapabilityTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Protocol; - -public static class RequestMcpTasksCapabilityTests -{ - [Fact] - public static void RequestMcpTasksCapability_SerializationRoundTrip_ToolsOnly() - { - // Arrange - var original = new RequestMcpTasksCapability - { - Tools = new ToolsMcpTasksCapability - { - Call = new CallToolMcpTasksCapability() - } - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.NotNull(deserialized.Tools); - Assert.NotNull(deserialized.Tools.Call); - Assert.Null(deserialized.Sampling); - Assert.Null(deserialized.Elicitation); - } - - [Fact] - public static void RequestMcpTasksCapability_SerializationRoundTrip_SamplingOnly() - { - // Arrange - var original = new RequestMcpTasksCapability - { - Sampling = new SamplingMcpTasksCapability - { - CreateMessage = new CreateMessageMcpTasksCapability() - } - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Null(deserialized.Tools); - Assert.NotNull(deserialized.Sampling); - Assert.NotNull(deserialized.Sampling.CreateMessage); - Assert.Null(deserialized.Elicitation); - } - - [Fact] - public static void RequestMcpTasksCapability_SerializationRoundTrip_ElicitationOnly() - { - // Arrange - var original = new RequestMcpTasksCapability - { - Elicitation = new ElicitationMcpTasksCapability - { - Create = new CreateElicitationMcpTasksCapability() - } - }; - - // Act - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Null(deserialized.Tools); - Assert.Null(deserialized.Sampling); - Assert.NotNull(deserialized.Elicitation); - Assert.NotNull(deserialized.Elicitation.Create); - } - - [Fact] - public static void RequestMcpTasksCapability_HasCorrectJsonPropertyNames() - { - var capability = new RequestMcpTasksCapability - { - Tools = new ToolsMcpTasksCapability - { - Call = new CallToolMcpTasksCapability() - }, - Sampling = new SamplingMcpTasksCapability - { - CreateMessage = new CreateMessageMcpTasksCapability() - }, - Elicitation = new ElicitationMcpTasksCapability - { - Create = new CreateElicitationMcpTasksCapability() - } - }; - - string json = JsonSerializer.Serialize(capability, McpJsonUtilities.DefaultOptions); - - Assert.Contains("\"tools\":", json); - Assert.Contains("\"sampling\":", json); - Assert.Contains("\"elicitation\":", json); - Assert.Contains("\"call\":", json); - Assert.Contains("\"createMessage\":", json); - Assert.Contains("\"create\":", json); - } -} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs index a6f8265f1..7b95e911b 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs @@ -15,7 +15,6 @@ public static void ServerCapabilities_SerializationRoundTrip_PreservesAllPropert Resources = new ResourcesCapability { Subscribe = true, ListChanged = true }, Tools = new ToolsCapability { ListChanged = false }, Completions = new CompletionsCapability(), - Tasks = new McpTasksCapability(), Extensions = new Dictionary { ["io.modelcontextprotocol/apps"] = new object() @@ -35,7 +34,6 @@ public static void ServerCapabilities_SerializationRoundTrip_PreservesAllPropert Assert.NotNull(deserialized.Tools); Assert.False(deserialized.Tools.ListChanged); Assert.NotNull(deserialized.Completions); - Assert.NotNull(deserialized.Tasks); Assert.NotNull(deserialized.Extensions); Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/apps")); } @@ -55,7 +53,6 @@ public static void ServerCapabilities_SerializationRoundTrip_WithMinimalProperti Assert.Null(deserialized.Resources); Assert.Null(deserialized.Tools); Assert.Null(deserialized.Completions); - Assert.Null(deserialized.Tasks); Assert.Null(deserialized.Extensions); } diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs new file mode 100644 index 000000000..556403add --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -0,0 +1,517 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization and deserialization tests for SEP-2663 task protocol types. +/// +public static class TaskSerializationTests +{ + #region CreateTaskResult + + [Fact] + public static void CreateTaskResult_SerializationRoundTrip_PreservesAllProperties() + { + var original = new CreateTaskResult + { + TaskId = "task-123", + Status = McpTaskStatus.Working, + StatusMessage = "Processing...", + CreatedAt = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero), + LastUpdatedAt = new DateTimeOffset(2025, 6, 1, 12, 5, 0, TimeSpan.Zero), + TtlMs = 3600000, + PollIntervalMs = 5000, + ResultType = "task", + Meta = new JsonObject { ["key"] = "value" } + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("task-123", deserialized.TaskId); + Assert.Equal(McpTaskStatus.Working, deserialized.Status); + Assert.Equal("Processing...", deserialized.StatusMessage); + Assert.Equal(original.CreatedAt, deserialized.CreatedAt); + Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); + Assert.Equal(3600000, deserialized.TtlMs); + Assert.Equal(5000, deserialized.PollIntervalMs); + Assert.Equal("task", deserialized.ResultType); + Assert.NotNull(deserialized.Meta); + Assert.Equal("value", (string)deserialized.Meta["key"]!); + } + + [Fact] + public static void CreateTaskResult_UsesCorrectWireFieldNames() + { + var result = new CreateTaskResult + { + TaskId = "t1", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + TtlMs = 60000, + PollIntervalMs = 1000, + ResultType = "task", + }; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + + // Must use camelCase wire names + Assert.Contains("\"ttlMs\":", json); + Assert.Contains("\"pollIntervalMs\":", json); + Assert.Contains("\"taskId\":", json); + Assert.Contains("\"resultType\":\"task\"", json); + + // Must NOT contain legacy field names + Assert.DoesNotContain("\"ttl\":", json); + Assert.DoesNotContain("\"pollInterval\":", json); + } + + [Fact] + public static void CreateTaskResult_ResultType_SerializesAsTask() + { + var result = new CreateTaskResult + { + TaskId = "t1", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + ResultType = "task", + }; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!; + + Assert.Equal("task", (string)node["resultType"]!); + } + + #endregion + + #region GetTaskResult Subtypes + + [Fact] + public static void GetTaskResult_Working_RoundTrip() + { + var original = new WorkingTaskResult + { + TaskId = "w1", + CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + LastUpdatedAt = new DateTimeOffset(2025, 1, 1, 0, 1, 0, TimeSpan.Zero), + StatusMessage = "In progress", + PollIntervalMs = 2000, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var working = Assert.IsType(deserialized); + Assert.Equal("w1", working.TaskId); + Assert.Equal(McpTaskStatus.Working, working.Status); + Assert.Equal("In progress", working.StatusMessage); + Assert.Equal(2000, working.PollIntervalMs); + } + + [Fact] + public static void GetTaskResult_Completed_RoundTrip_IncludesResult() + { + var resultPayload = JsonElement.Parse("""{"content":[{"type":"text","text":"done"}]}"""); + var original = new CompletedTaskResult + { + TaskId = "c1", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + Result = resultPayload, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var completed = Assert.IsType(deserialized); + Assert.Equal("c1", completed.TaskId); + Assert.Equal(McpTaskStatus.Completed, completed.Status); + Assert.Equal(JsonValueKind.Object, completed.Result.ValueKind); + } + + [Fact] + public static void GetTaskResult_Failed_RoundTrip_IncludesError() + { + var errorPayload = JsonElement.Parse("""{"code":-32000,"message":"internal error"}"""); + var original = new FailedTaskResult + { + TaskId = "f1", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + Error = errorPayload, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var failed = Assert.IsType(deserialized); + Assert.Equal("f1", failed.TaskId); + Assert.Equal(McpTaskStatus.Failed, failed.Status); + Assert.Equal(-32000, failed.Error.GetProperty("code").GetInt32()); + } + + [Fact] + public static void GetTaskResult_Cancelled_RoundTrip() + { + var original = new CancelledTaskResult + { + TaskId = "x1", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + StatusMessage = "User cancelled", + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var cancelled = Assert.IsType(deserialized); + Assert.Equal("x1", cancelled.TaskId); + Assert.Equal(McpTaskStatus.Cancelled, cancelled.Status); + Assert.Equal("User cancelled", cancelled.StatusMessage); + } + + [Fact] + public static void GetTaskResult_InputRequired_RoundTrip_IncludesInputRequests() + { + var inputRequests = new Dictionary + { + ["req-1"] = new InputRequest + { + Method = "elicitation/create", + Params = JsonElement.Parse("""{"message":"Confirm?"}"""), + } + }; + var original = new InputRequiredTaskResult + { + TaskId = "i1", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + InputRequests = inputRequests, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var inputRequired = Assert.IsType(deserialized); + Assert.Equal("i1", inputRequired.TaskId); + Assert.Equal(McpTaskStatus.InputRequired, inputRequired.Status); + Assert.NotNull(inputRequired.InputRequests); + Assert.Single(inputRequired.InputRequests); + Assert.True(inputRequired.InputRequests.ContainsKey("req-1")); + } + + [Fact] + public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() + { + var statuses = new (string status, Type expectedType)[] + { + ("working", typeof(WorkingTaskResult)), + ("completed", typeof(CompletedTaskResult)), + ("failed", typeof(FailedTaskResult)), + ("cancelled", typeof(CancelledTaskResult)), + ("input_required", typeof(InputRequiredTaskResult)), + }; + + foreach (var (status, expectedType) in statuses) + { + var json = status switch + { + "completed" => """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z","result":{}}""", + "failed" => """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z","error":{}}""", + "input_required" => """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z","inputRequests":{}}""", + _ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""", + }; + + var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.IsType(expectedType, result); + } + } + + [Fact] + public static void GetTaskResult_MissingTaskId_ThrowsJsonException() + { + var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void GetTaskResult_MissingStatus_ThrowsJsonException() + { + var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void GetTaskResult_UnknownStatus_ThrowsJsonException() + { + var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException() + { + var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void GetTaskResult_FailedMissingError_ThrowsJsonException() + { + var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException() + { + var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; + Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Theory] + [InlineData(typeof(WorkingTaskResult))] + [InlineData(typeof(CompletedTaskResult))] + [InlineData(typeof(FailedTaskResult))] + [InlineData(typeof(CancelledTaskResult))] + [InlineData(typeof(InputRequiredTaskResult))] + public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) + { + // SEP-2663: standard task responses (tasks/get, tasks/update, tasks/cancel) use resultType="complete". + var created = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + GetTaskResult value = subType switch + { + Type t when t == typeof(WorkingTaskResult) => new WorkingTaskResult { TaskId = "t", CreatedAt = created, LastUpdatedAt = created, ResultType = "complete" }, + Type t when t == typeof(CompletedTaskResult) => new CompletedTaskResult { TaskId = "t", CreatedAt = created, LastUpdatedAt = created, Result = JsonElement.Parse("""{"ok":true}"""), ResultType = "complete" }, + Type t when t == typeof(FailedTaskResult) => new FailedTaskResult { TaskId = "t", CreatedAt = created, LastUpdatedAt = created, Error = JsonElement.Parse("""{"code":-32603,"message":"boom"}"""), ResultType = "complete" }, + Type t when t == typeof(CancelledTaskResult) => new CancelledTaskResult { TaskId = "t", CreatedAt = created, LastUpdatedAt = created, ResultType = "complete" }, + Type t when t == typeof(InputRequiredTaskResult) => new InputRequiredTaskResult + { + TaskId = "t", + CreatedAt = created, + LastUpdatedAt = created, + ResultType = "complete", + InputRequests = new Dictionary + { + ["k"] = new InputRequest + { + Method = "test/method", + Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions), + }, + }, + }, + _ => throw new InvalidOperationException() + }; + + string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!; + + Assert.Equal("complete", (string?)node["resultType"]); + } + + #endregion + + #region McpTaskStatus Enum + + [Theory] + [InlineData(McpTaskStatus.Working, "working")] + [InlineData(McpTaskStatus.InputRequired, "input_required")] + [InlineData(McpTaskStatus.Completed, "completed")] + [InlineData(McpTaskStatus.Cancelled, "cancelled")] + [InlineData(McpTaskStatus.Failed, "failed")] + public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue) + { + string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions); + Assert.Equal($"\"{expectedWireValue}\"", json); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.Equal(status, deserialized); + } + + #endregion + + #region TaskStatusNotificationParams + + [Fact] + public static void TaskStatusNotificationParams_Working_RoundTrip() + { + var original = new WorkingTaskNotificationParams + { + TaskId = "n1", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + StatusMessage = "Working on it", + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var working = Assert.IsType(deserialized); + Assert.Equal("n1", working.TaskId); + Assert.Equal("Working on it", working.StatusMessage); + } + + [Fact] + public static void TaskStatusNotificationParams_Completed_RoundTrip() + { + var resultPayload = JsonElement.Parse("""{"text":"done"}"""); + var original = new CompletedTaskNotificationParams + { + TaskId = "n2", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + Result = resultPayload, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var completed = Assert.IsType(deserialized); + Assert.Equal("n2", completed.TaskId); + Assert.Equal("done", completed.Result.GetProperty("text").GetString()); + } + + [Fact] + public static void TaskStatusNotificationParams_Failed_RoundTrip() + { + var errorPayload = JsonElement.Parse("""{"code":-1,"message":"boom"}"""); + var original = new FailedTaskNotificationParams + { + TaskId = "n3", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + Error = errorPayload, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var failed = Assert.IsType(deserialized); + Assert.Equal("n3", failed.TaskId); + Assert.Equal("boom", failed.Error.GetProperty("message").GetString()); + } + + [Fact] + public static void TaskStatusNotificationParams_Cancelled_RoundTrip() + { + var original = new CancelledTaskNotificationParams + { + TaskId = "n4", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.IsType(deserialized); + } + + [Fact] + public static void TaskStatusNotificationParams_InputRequired_RoundTrip() + { + var inputRequests = new Dictionary + { + ["r1"] = new InputRequest { Method = "sampling/createMessage" } + }; + var original = new InputRequiredTaskNotificationParams + { + TaskId = "n5", + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + InputRequests = inputRequests, + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var inputRequired = Assert.IsType(deserialized); + Assert.NotNull(inputRequired.InputRequests); + Assert.Single(inputRequired.InputRequests); + } + + #endregion + + #region ResultOrCreatedTask + + [Fact] + public static void ResultOrCreatedTask_ImplicitConversion_FromResult() + { + CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] }; + + ResultOrCreatedTask augmented = callResult; + + Assert.False(augmented.IsTask); + Assert.Same(callResult, augmented.Result); + Assert.Null(augmented.TaskCreated); + } + + [Fact] + public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() + { + CreateTaskResult taskCreated = new() + { + TaskId = "t1", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }; + + ResultOrCreatedTask augmented = taskCreated; + + Assert.True(augmented.IsTask); + Assert.Same(taskCreated, augmented.TaskCreated); + Assert.Null(augmented.Result); + } + + [Fact] + public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask() + { + var result = new ResultOrCreatedTask(new CallToolResult()); + var task = new ResultOrCreatedTask(new CreateTaskResult + { + TaskId = "t", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }); + + Assert.False(result.IsTask); + Assert.True(task.IsTask); + } + + #endregion + + #region UpdateTaskResult / CancelTaskResult Wire Format + + [Fact] + public static void UpdateTaskResult_WireResultType_IsComplete_WhenSet() + { + // SEP-2663: tasks/update responses use resultType="complete". + var result = new UpdateTaskResult { ResultType = "complete" }; + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!; + + Assert.Equal("complete", (string?)node["resultType"]); + } + + [Fact] + public static void CancelTaskResult_WireResultType_IsComplete_WhenSet() + { + // SEP-2663: tasks/cancel responses use resultType="complete". + var result = new CancelTaskResult { ResultType = "complete" }; + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!; + + Assert.Equal("complete", (string?)node["resultType"]); + } + + #endregion +} diff --git a/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs b/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs deleted file mode 100644 index 1f5c51c6c..000000000 --- a/tests/ModelContextProtocol.Tests/Server/AutomaticInputRequiredStatusTests.cs +++ /dev/null @@ -1,478 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.IO.Pipelines; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for automatic InputRequired status tracking when server-to-client -/// requests (SampleAsync, ElicitAsync) are made during task-augmented tool execution. -/// -public class AutomaticInputRequiredStatusTests : LoggedTest -{ - public AutomaticInputRequiredStatusTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) - { - } - -#pragma warning disable MCPEXP001 // Tasks feature is experimental - - [Fact] - public async Task TaskStatus_TransitionsToInputRequired_DuringSampleAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var statusesDuringSampling = new List(); - var samplingRequestReceived = new TaskCompletionSource(); - var continueSampling = new TaskCompletionSource(); - - await using var fixture = new InputRequiredTestFixture( - LoggerFactory, - configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - options.SendTaskStatusNotifications = true; // Enable notifications - }); - - // Tool that calls SampleAsync during execution - builder.WithTools([McpServerTool.Create( - async (string prompt, McpServer server, CancellationToken ct) => - { - // Call SampleAsync - this should trigger InputRequired status - var result = await server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage - { - Role = Role.User, - Content = [new TextContentBlock { Text = prompt }] - }], - MaxTokens = 100 - }, ct); - - var textContent = result.Content.OfType().FirstOrDefault(); - return textContent?.Text ?? "No response"; - }, - new McpServerToolCreateOptions - { - Name = "sampling-tool", - Description = "A tool that uses sampling" - })]); - }, - configureClient: clientOptions => - { - clientOptions.Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - // Signal that we received the sampling request - samplingRequestReceived.TrySetResult(true); - - // Wait for permission to continue (so we can check status) - await continueSampling.Task.WaitAsync(ct); - - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Sampled response" }], - Model = "test-model" - }; - } - }; - }); - - // Act - Call the tool as a task - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "sampling-tool", - arguments: new Dictionary { ["prompt"] = "Hello" }, - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - // Wait for the sampling request to be received by the client - await samplingRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Check the task status while sampling is in progress - var statusDuringSampling = await taskStore.GetTaskAsync( - mcpTask.TaskId, - cancellationToken: TestContext.Current.CancellationToken); - - if (statusDuringSampling is not null) - { - statusesDuringSampling.Add(statusDuringSampling.Status); - } - - // Allow sampling to complete - continueSampling.TrySetResult(true); - - // Wait for task to complete - McpTask? finalStatus = null; - int maxAttempts = 50; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - finalStatus = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - maxAttempts--; - } - while (finalStatus?.Status is not McpTaskStatus.Completed && maxAttempts > 0); - - // Assert - Status should have been InputRequired during sampling - Assert.Contains(McpTaskStatus.InputRequired, statusesDuringSampling); - - // Final status should be Completed - Assert.NotNull(finalStatus); - Assert.Equal(McpTaskStatus.Completed, finalStatus.Status); - } - - [Fact] - public async Task TaskStatus_TransitionsToInputRequired_DuringElicitAsync() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var statusesDuringElicitation = new List(); - var elicitationRequestReceived = new TaskCompletionSource(); - var continueElicitation = new TaskCompletionSource(); - - await using var fixture = new InputRequiredTestFixture( - LoggerFactory, - configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - options.SendTaskStatusNotifications = true; - }); - - // Tool that calls ElicitAsync during execution - builder.WithTools([McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - // Call ElicitAsync - this should trigger InputRequired status - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return result.Action == "confirm" ? "Confirmed" : "Declined"; - }, - new McpServerToolCreateOptions - { - Name = "elicitation-tool", - Description = "A tool that uses elicitation" - })]); - }, - configureClient: clientOptions => - { - clientOptions.Handlers = new McpClientHandlers - { - ElicitationHandler = async (request, ct) => - { - // Signal that we received the elicitation request - elicitationRequestReceived.TrySetResult(true); - - // Wait for permission to continue - await continueElicitation.Task.WaitAsync(ct); - - return new ElicitResult { Action = "confirm" }; - } - }; - }); - - // Act - Call the tool as a task - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "elicitation-tool", - arguments: new Dictionary { ["message"] = "Please confirm" }, - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - // Wait for the elicitation request to be received - await elicitationRequestReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Check the task status while elicitation is in progress - var statusDuringElicitation = await taskStore.GetTaskAsync( - mcpTask.TaskId, - cancellationToken: TestContext.Current.CancellationToken); - - if (statusDuringElicitation is not null) - { - statusesDuringElicitation.Add(statusDuringElicitation.Status); - } - - // Allow elicitation to complete - continueElicitation.TrySetResult(true); - - // Wait for task to complete - McpTask? finalStatus = null; - int maxAttempts = 50; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - finalStatus = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - maxAttempts--; - } - while (finalStatus?.Status is not McpTaskStatus.Completed && maxAttempts > 0); - - // Assert - Status should have been InputRequired during elicitation - Assert.Contains(McpTaskStatus.InputRequired, statusesDuringElicitation); - - // Final status should be Completed - Assert.NotNull(finalStatus); - Assert.Equal(McpTaskStatus.Completed, finalStatus.Status); - } - - [Fact] - public async Task TaskStatus_ReturnsToWorking_AfterSamplingCompletes() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - var samplingCompleted = new TaskCompletionSource(); - var checkStatusAfterSampling = new TaskCompletionSource(); - - await using var fixture = new InputRequiredTestFixture( - LoggerFactory, - configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - // Tool that calls SampleAsync and then waits - builder.WithTools([McpServerTool.Create( - async (string prompt, McpServer server, CancellationToken ct) => - { - // Call SampleAsync - var result = await server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage - { - Role = Role.User, - Content = [new TextContentBlock { Text = prompt }] - }], - MaxTokens = 100 - }, ct); - - // Signal that sampling completed - samplingCompleted.TrySetResult(true); - - // Wait so test can check status - await checkStatusAfterSampling.Task.WaitAsync(ct); - - var textContent = result.Content.OfType().FirstOrDefault(); - return textContent?.Text ?? "No response"; - }, - new McpServerToolCreateOptions - { - Name = "sampling-tool", - Description = "A tool that uses sampling" - })]); - }, - configureClient: clientOptions => - { - clientOptions.Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - // Return immediately to let sampling complete - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Response" }], - Model = "test-model" - }); - } - }; - }); - - // Act - Call the tool as a task - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "sampling-tool", - arguments: new Dictionary { ["prompt"] = "Hello" }, - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - // Wait for sampling to complete inside the tool - await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Small delay to ensure status update is processed - await Task.Delay(50, TestContext.Current.CancellationToken); - - // Check status after sampling completed (should be back to Working) - var taskAfterSampling = await taskStore.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - // Allow tool to complete - checkStatusAfterSampling.TrySetResult(true); - - // Assert - Status should be Working after sampling completes (before tool completes) - Assert.NotNull(taskAfterSampling); - Assert.Equal(McpTaskStatus.Working, taskAfterSampling.Status); - } - - [Fact] - public async Task TaskStatus_DoesNotChangeToInputRequired_ForNonTaskExecution() - { - // Arrange - When a tool is NOT executed as a task, SampleAsync should not change any task status - var taskStore = new InMemoryMcpTaskStore(); - var samplingCompleted = new TaskCompletionSource(); - - await using var fixture = new InputRequiredTestFixture( - LoggerFactory, - configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - // Tool that calls SampleAsync - note it doesn't have TaskSupport.Required so can be called directly - builder.WithTools([McpServerTool.Create( - async (string prompt, McpServer server, CancellationToken ct) => - { - var result = await server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage - { - Role = Role.User, - Content = [new TextContentBlock { Text = prompt }] - }], - MaxTokens = 100 - }, ct); - - samplingCompleted.TrySetResult(true); - var textContent = result.Content.OfType().FirstOrDefault(); - return textContent?.Text ?? "No response"; - }, - new McpServerToolCreateOptions - { - Name = "sampling-tool", - Description = "A tool that uses sampling", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - })]); - }, - configureClient: clientOptions => - { - clientOptions.Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Response" }], - Model = "test-model" - }); - } - }; - }); - - // Act - Call the tool DIRECTLY (not as a task) - var result = await fixture.Client.CallToolAsync( - "sampling-tool", - arguments: new Dictionary { ["prompt"] = "Hello" }, - cancellationToken: TestContext.Current.CancellationToken); - - await samplingCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Assert - No task should exist (tool was not called as a task) - var tasks = await taskStore.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.Empty(tasks.Tasks); - - // And the result should still work - Assert.NotNull(result); - } - -#pragma warning restore MCPEXP001 - - /// - /// Test fixture that supports both server and client configuration for InputRequired status tests. - /// - private sealed class InputRequiredTestFixture : IAsyncDisposable - { - private readonly Pipe _clientToServerPipe = new(); - private readonly Pipe _serverToClientPipe = new(); - private readonly IServiceProvider _serviceProvider; - private readonly McpServer _server; - private readonly Task _serverTask; - private readonly CancellationTokenSource _cts; - - public McpClient Client { get; } - public McpServer Server => _server; - - public InputRequiredTestFixture( - ILoggerFactory loggerFactory, - Action? configureServer = null, - Action? configureClient = null) - { - _cts = new CancellationTokenSource(); - - // Configure server - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(loggerFactory); - - var builder = services - .AddMcpServer() - .WithStreamServerTransport( - _clientToServerPipe.Reader.AsStream(), - _serverToClientPipe.Writer.AsStream()); - - configureServer?.Invoke(services, builder); - - _serviceProvider = services.BuildServiceProvider(validateScopes: true); - _server = _serviceProvider.GetRequiredService(); - _serverTask = _server.RunAsync(_cts.Token); - - // Configure client - var clientOptions = new McpClientOptions(); - configureClient?.Invoke(clientOptions); - - // Create client synchronously (test code) - Client = McpClient.CreateAsync( - new StreamClientTransport( - serverInput: _clientToServerPipe.Writer.AsStream(), - _serverToClientPipe.Reader.AsStream(), - loggerFactory), - clientOptions: clientOptions, - loggerFactory: loggerFactory, - cancellationToken: TestContext.Current.CancellationToken).GetAwaiter().GetResult(); - } - - public async ValueTask DisposeAsync() - { - await Client.DisposeAsync(); - await _cts.CancelAsync(); - - _clientToServerPipe.Writer.Complete(); - _serverToClientPipe.Writer.Complete(); - - try - { - await _serverTask; - } - catch (OperationCanceledException) - { - // Expected - } - - if (_serviceProvider is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - else if (_serviceProvider is IDisposable disposable) - { - disposable.Dispose(); - } - - _cts.Dispose(); - } - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 7d2fc5596..7c87786eb 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1,1231 +1,495 @@ -using Microsoft.Extensions.Time.Testing; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; using System.Text.Json; -using TestInMemoryMcpTaskStore = ModelContextProtocol.Tests.Internal.InMemoryMcpTaskStore; + +#pragma warning disable MCPEXP001 namespace ModelContextProtocol.Tests.Server; -public class InMemoryMcpTaskStoreTests : LoggedTest +/// +/// Unit tests for . +/// +public class InMemoryMcpTaskStoreTests { - public InMemoryMcpTaskStoreTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) - { - } + private CancellationToken CT => TestContext.Current.CancellationToken; - [Fact] - public async Task CreateTaskAsync_CreatesTaskWithUniqueId() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var requestId = new RequestId("req-1"); - var request = new JsonRpcRequest { Method = "tools/call" }; + private static InputRequest MakeRequest(string payload) => + new() { Method = "test/method", Params = JsonSerializer.SerializeToElement(payload, McpJsonUtilities.DefaultOptions) }; - // Act - var task = await store.CreateTaskAsync(metadata, requestId, request, "session-1", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.NotEmpty(task.TaskId); - Assert.Equal(McpTaskStatus.Working, task.Status); - Assert.NotEqual(default, task.CreatedAt); - Assert.NotEqual(default, task.LastUpdatedAt); - } + private static InputResponse MakeResponse(string payload) => + new() { RawValue = JsonSerializer.SerializeToElement(payload, McpJsonUtilities.DefaultOptions) }; [Fact] - public async Task CreateTaskAsync_GeneratesUniqueTaskIds() + public async Task CreateTaskAsync_ReturnsWorkingTaskWithUniqueId() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); + var store = new InMemoryMcpTaskStore(); - // Act - var task1 = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var task2 = await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + var result = await store.CreateTaskAsync(CT); - // Assert - Assert.NotEqual(task1.TaskId, task2.TaskId); + Assert.NotNull(result); + Assert.NotEmpty(result.TaskId); + Assert.Equal(McpTaskStatus.Working, result.Status); + Assert.NotEqual(default, result.CreatedAt); + Assert.NotEqual(default, result.LastUpdatedAt); } [Fact] - public async Task CreateTaskAsync_AppliesTtlFromMetadata() + public async Task CreateTaskAsync_GeneratesUniqueIds() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata - { - TimeToLive = TimeSpan.FromSeconds(5) - }; + var store = new InMemoryMcpTaskStore(); - // Act - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + var task1 = await store.CreateTaskAsync(CT); + var task2 = await store.CreateTaskAsync(CT); - // Assert - Assert.Equal(TimeSpan.FromSeconds(5), task.TimeToLive); + Assert.NotEqual(task1.TaskId, task2.TaskId); } [Fact] - public async Task CreateTaskAsync_CapsMaxTtl() + public async Task CreateTaskAsync_UsesDefaultPollInterval() { - // Arrange - var maxTtl = TimeSpan.FromMinutes(5); - using var store = new InMemoryMcpTaskStore(maxTtl: maxTtl); - var metadata = new McpTaskMetadata - { - TimeToLive = TimeSpan.FromHours(1) // Request 1 hour - }; + var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 500 }; - // Act - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + var result = await store.CreateTaskAsync(CT); - // Assert - Assert.Equal(maxTtl, task.TimeToLive); - } - - [Fact] - public async Task GetTaskAsync_ReturnsTaskById() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var created = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - var retrieved = await store.GetTaskAsync(created.TaskId, null, TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(retrieved); - Assert.Equal(created.TaskId, retrieved.TaskId); - Assert.Equal(created.Status, retrieved.Status); + Assert.Equal(500, result.PollIntervalMs); } [Fact] - public async Task GetTaskAsync_ReturnsNullForNonexistentTask() + public async Task CreateTaskAsync_UsesDefaultTtl() { - // Arrange - using var store = new InMemoryMcpTaskStore(); + var store = new InMemoryMcpTaskStore { DefaultTtlMs = 30000 }; - // Act - var task = await store.GetTaskAsync("nonexistent-id", null, TestContext.Current.CancellationToken); + var result = await store.CreateTaskAsync(CT); - // Assert - Assert.Null(task); + Assert.Equal(30000, result.TtlMs); } [Fact] - public async Task GetTaskAsync_EnforcesSessionIsolation() + public async Task GetTaskAsync_ReturnsWorkingTask() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - - // Act - var sameSession = await store.GetTaskAsync(task.TaskId, "session-1", TestContext.Current.CancellationToken); - var differentSession = await store.GetTaskAsync(task.TaskId, "session-2", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(sameSession); - Assert.Null(differentSession); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - [Fact] - public async Task StoreTaskResultAsync_StoresResultForCompletedTask() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var result = new CallToolResult { Content = [new TextContentBlock { Text = "Success" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - - // Act - await store.StoreTaskResultAsync(task.TaskId, McpTaskStatus.Completed, resultElement, null, TestContext.Current.CancellationToken); - - // Assert - var retrieved = await store.GetTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Completed, retrieved!.Status); - } + var result = await store.GetTaskAsync(created.TaskId, CT); - [Fact] - public async Task StoreTaskResultAsync_EnforcesSessionIsolation() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - var result = new CallToolResult { Content = [new TextContentBlock { Text = "Success" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - - // Act & Assert - await Assert.ThrowsAsync( - () => store.StoreTaskResultAsync(task.TaskId, McpTaskStatus.Completed, resultElement, "session-2", TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task StoreTaskResultAsync_ThrowsForNonTerminalStatus() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var result = new CallToolResult { Content = [new TextContentBlock { Text = "Success" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - - // Act & Assert - await Assert.ThrowsAsync( - () => store.StoreTaskResultAsync(task.TaskId, McpTaskStatus.Working, resultElement, null, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task GetTaskResultAsync_ReturnsStoredResult() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var result = new CallToolResult { Content = [new TextContentBlock { Text = "Success" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - await store.StoreTaskResultAsync(task.TaskId, McpTaskStatus.Completed, resultElement, null, TestContext.Current.CancellationToken); - - // Act - var retrieved = await store.GetTaskResultAsync(task.TaskId, null, TestContext.Current.CancellationToken); - - // Assert - var callToolResult = retrieved.Deserialize(McpJsonUtilities.DefaultOptions)!; - Assert.Single(callToolResult.Content); - Assert.Equal("Success", ((TextContentBlock)callToolResult.Content[0]).Text); + Assert.NotNull(result); + Assert.Equal(McpTaskStatus.Working, result.Status); + Assert.Equal(created.TaskId, result.TaskId); } [Fact] - public async Task GetTaskResultAsync_EnforcesSessionIsolation() + public async Task GetTaskAsync_ReturnsNullForUnknownId() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - var result = new CallToolResult { Content = [new TextContentBlock { Text = "Success" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - await store.StoreTaskResultAsync(task.TaskId, McpTaskStatus.Completed, resultElement, "session-1", TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync( - () => store.GetTaskResultAsync(task.TaskId, "session-2", TestContext.Current.CancellationToken)); - } + var store = new InMemoryMcpTaskStore(); - [Fact] - public async Task UpdateTaskStatusAsync_UpdatesStatus() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - await store.UpdateTaskStatusAsync(task.TaskId, McpTaskStatus.Working, "Processing...", null, TestContext.Current.CancellationToken); - - // Assert - var updated = await store.GetTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Working, updated!.Status); - Assert.Equal("Processing...", updated.StatusMessage); - } + var result = await store.GetTaskAsync("nonexistent", CT); - [Fact] - public async Task UpdateTaskStatusAsync_UpdatesLastUpdatedAt() - { - // Arrange - Use FakeTimeProvider for deterministic testing - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - using var store = new TestInMemoryMcpTaskStore( - defaultTtl: null, - maxTtl: null, - pollInterval: null, - cleanupInterval: Timeout.InfiniteTimeSpan, - pageSize: 100, - maxTasks: null, - maxTasksPerSession: null, - timeProvider: fakeTime); - - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var originalTimestamp = task.LastUpdatedAt; - - // Advance time to ensure timestamp changes - fakeTime.Advance(TimeSpan.FromMilliseconds(10)); - - // Act - await store.UpdateTaskStatusAsync(task.TaskId, McpTaskStatus.Working, null, null, TestContext.Current.CancellationToken); - - // Assert - var updated = await store.GetTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - Assert.True(updated!.LastUpdatedAt > originalTimestamp); + Assert.Null(result); } - #region Input Required Status Tests - - // NOTE: The InputRequired status is automatically set by the server when a tool executing - // as a task calls SampleAsync() or ElicitAsync(). The status is set back to Working when - // the request completes. See TaskExecutionContext for implementation details. - // The tests below verify the store correctly handles status transitions. - [Fact] - public async Task InputRequiredStatus_SerializesCorrectly() + public async Task SetCompletedAsync_TransitionsToCompleted() { - // Verify the input_required status serializes as expected - var task = new McpTask - { - TaskId = "test-task", - Status = McpTaskStatus.InputRequired, - StatusMessage = "Waiting for user input", - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow - }; - - string json = JsonSerializer.Serialize(task, McpJsonUtilities.DefaultOptions); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + var resultPayload = JsonDocument.Parse("""{"answer":42}""").RootElement.Clone(); - Assert.Contains("\"status\":\"input_required\"", json); - } + await store.SetCompletedAsync(created.TaskId, resultPayload, CT); - [Fact] - public async Task InputRequiredStatus_CanTransitionToWorking() - { - // Arrange - Spec: "From input_required: may move to working, completed, failed, or cancelled" - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Transition to input_required (testing store's status transition capability) - var inputRequiredTask = await store.UpdateTaskStatusAsync( - task.TaskId, - McpTaskStatus.InputRequired, - "Waiting for user confirmation", - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal(McpTaskStatus.InputRequired, inputRequiredTask.Status); - - // Act - Transition back to working - var workingTask = await store.UpdateTaskStatusAsync( - task.TaskId, - McpTaskStatus.Working, - "Processing resumed", - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(McpTaskStatus.Working, workingTask.Status); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Completed, task.Status); + Assert.Equal(42, task.Result!.Value.GetProperty("answer").GetInt32()); } [Fact] - public async Task InputRequiredStatus_CanTransitionToCancelled() + public async Task SetFailedAsync_TransitionsToFailed() { - // Arrange - Spec: Task transitions show input_required can go to terminal states - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Transition to input_required - await store.UpdateTaskStatusAsync( - task.TaskId, - McpTaskStatus.InputRequired, - "Need input", - cancellationToken: TestContext.Current.CancellationToken); - - // Act - Transition to cancelled - var cancelledTask = await store.CancelTaskAsync( - task.TaskId, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + var errorPayload = JsonDocument.Parse("""{"message":"boom"}""").RootElement.Clone(); - #endregion + await store.SetFailedAsync(created.TaskId, errorPayload, CT); - [Fact] - public async Task ListTasksAsync_ReturnsAllTasks() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var task1 = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var task2 = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - var result = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(2, result.Tasks.Count); - Assert.Contains(result.Tasks, t => t.TaskId == task1.TaskId); - Assert.Contains(result.Tasks, t => t.TaskId == task2.TaskId); - Assert.Null(result.NextCursor); - } - - [Fact] - public async Task ListTasksAsync_FiltersBySession() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var task1 = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - var task2 = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, "session-2", TestContext.Current.CancellationToken); - - // Act - var session1Result = await store.ListTasksAsync(sessionId: "session-1", cancellationToken: TestContext.Current.CancellationToken); - var session2Result = await store.ListTasksAsync(sessionId: "session-2", cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Single(session1Result.Tasks); - Assert.Equal(task1.TaskId, session1Result.Tasks[0].TaskId); - Assert.Single(session2Result.Tasks); - Assert.Equal(task2.TaskId, session2Result.Tasks[0].TaskId); - } - - [Fact] - public async Task ListTasksAsync_SupportsPagination() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - - // Create 150 tasks (more than page size of 100) - for (int i = 0; i < 150; i++) - { - await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - } - - // Act - First page - var firstPageResult = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Act - Second page - var secondPageResult = await store.ListTasksAsync(cursor: firstPageResult.NextCursor, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(100, firstPageResult.Tasks.Count); - Assert.NotNull(firstPageResult.NextCursor); - Assert.Equal(50, secondPageResult.Tasks.Count); - Assert.Null(secondPageResult.NextCursor); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Failed, task.Status); + Assert.Equal("boom", task.Error!.Value.GetProperty("message").GetString()); } [Fact] - public async Task CancelTaskAsync_CancelsTask() + public async Task SetCancelledAsync_TransitionsToCancelled() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Act - var cancelled = await store.CancelTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); + var cancelled = await store.SetCancelledAsync(created.TaskId, CT); - // Assert - Assert.Equal(McpTaskStatus.Cancelled, cancelled.Status); - } - - [Fact] - public async Task CancelTaskAsync_IsIdempotent() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // First cancellation - await store.CancelTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - - // Act - Second cancellation - var result = await store.CancelTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - - // Assert - Should return unchanged task, not throw - Assert.Equal(McpTaskStatus.Cancelled, result.Status); + Assert.True(cancelled); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Cancelled, task.Status); } [Fact] - public async Task CancelTaskAsync_DoesNotCancelCompletedTask() + public async Task SetCancelledAsync_ReturnsFalseForTerminalTask() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var result = new CallToolResult { Content = [new TextContentBlock { Text = "Success" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - await store.StoreTaskResultAsync(task.TaskId, McpTaskStatus.Completed, resultElement, null, TestContext.Current.CancellationToken); - - // Act - var cancelResult = await store.CancelTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - - // Assert - Task remains completed - Assert.Equal(McpTaskStatus.Completed, cancelResult.Status); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + await store.SetCompletedAsync(created.TaskId, JsonSerializer.SerializeToElement("done", McpJsonUtilities.DefaultOptions), CT); - [Fact] - public async Task CancelTaskAsync_EnforcesSessionIsolation() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); + var cancelled = await store.SetCancelledAsync(created.TaskId, CT); - // Act & Assert - await Assert.ThrowsAsync( - () => store.CancelTaskAsync(task.TaskId, "session-2", TestContext.Current.CancellationToken)); + Assert.False(cancelled); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Completed, task.Status); } [Fact] - public async Task Dispose_StopsCleanupTimer() + public async Task SetCancelledAsync_ReturnsFalseForUnknownId() { - // Arrange - Use FakeTimeProvider for deterministic testing - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - var cleanupInterval = TimeSpan.FromMilliseconds(100); - - var store = new TestInMemoryMcpTaskStore( - defaultTtl: null, - maxTtl: null, - pollInterval: null, - cleanupInterval: cleanupInterval, - pageSize: 100, - maxTasks: null, - maxTasksPerSession: null, - timeProvider: fakeTime); - - var metadata = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(100) }; - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - store.Dispose(); - - // Advance time - timer should not fire after dispose - fakeTime.Advance(TimeSpan.FromTicks(cleanupInterval.Ticks * 3)); - - // Assert - Store should still be accessible after dispose (no exceptions) - // The cleanup timer should have stopped - Assert.True(true); // If we get here without exceptions, dispose worked - } + var store = new InMemoryMcpTaskStore(); - [Fact] - public async Task CleanupExpiredTasks_RemovesExpiredTasks() - { - // Arrange - Use FakeTimeProvider for deterministic testing - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - var cleanupInterval = TimeSpan.FromMilliseconds(50); - var ttl = TimeSpan.FromMilliseconds(100); - - using var store = new TestInMemoryMcpTaskStore( - defaultTtl: null, - maxTtl: null, - pollInterval: null, - cleanupInterval: cleanupInterval, - pageSize: 100, - maxTasks: null, - maxTasksPerSession: null, - timeProvider: fakeTime); - - var metadata = new McpTaskMetadata { TimeToLive = ttl }; - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Verify task exists initially - var resultBefore = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.Single(resultBefore.Tasks); - - // Advance time past the TTL to make task expired - fakeTime.Advance(ttl + TimeSpan.FromMilliseconds(1)); - - // Trigger cleanup by advancing time past cleanup interval - fakeTime.Advance(cleanupInterval); - - // Act - List tasks to verify cleanup happened - var resultAfter = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Empty(resultAfter.Tasks); // Task should be cleaned up by the timer + var cancelled = await store.SetCancelledAsync("nonexistent", CT); + + Assert.False(cancelled); } [Fact] - public async Task DefaultTtl_AppliedWhenNoTtlSpecified() + public async Task SetInputRequestsAsync_TransitionsToInputRequired() { - // Arrange - var defaultTtl = TimeSpan.FromMinutes(10); - using var store = new InMemoryMcpTaskStore(defaultTtl: defaultTtl); - var metadata = new McpTaskMetadata(); // No TTL specified + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Act - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + var requests = new Dictionary + { + ["req1"] = new InputRequest + { + Method = "elicitation/create", + Params = JsonElement.Parse("""{"message":"hello"}"""), + }, + }; + await store.SetInputRequestsAsync(created.TaskId, requests, CT); - // Assert - Assert.Equal(defaultTtl, task.TimeToLive); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.InputRequired, task.Status); + Assert.NotNull(task.InputRequests); + Assert.Single(task.InputRequests); + Assert.True(task.InputRequests.ContainsKey("req1")); } [Fact] - public async Task MultipleOperations_ConcurrentAccess() + public async Task SetInputRequestsAsync_MergesMultipleRequests() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var tasks = new List>(); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Act - Create multiple tasks concurrently - for (int i = 0; i < 10; i++) + await store.SetInputRequestsAsync(created.TaskId, new Dictionary { - int taskNum = i; - tasks.Add(Task.Run(async () => - { - var metadata = new McpTaskMetadata(); - return await store.CreateTaskAsync(metadata, new RequestId($"req-{taskNum}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - })); - } - - var createdTasks = await Task.WhenAll(tasks); + ["req1"] = MakeRequest("first") + }, CT); + await store.SetInputRequestsAsync(created.TaskId, new Dictionary + { + ["req2"] = MakeRequest("second") + }, CT); - // Assert - All tasks should be created with unique IDs - Assert.Equal(10, createdTasks.Length); - Assert.Equal(10, createdTasks.Select(t => t.TaskId).Distinct().Count()); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.InputRequired, task.Status); + Assert.NotNull(task.InputRequests); + Assert.Equal(2, task.InputRequests.Count); + Assert.True(task.InputRequests.ContainsKey("req1")); + Assert.True(task.InputRequests.ContainsKey("req2")); } [Fact] - public void Constructor_ThrowsWhenDefaultTtlExceedsMaxTtl() + public async Task ResolveInputRequestsAsync_RemovesMatchedRequests() { - // Arrange & Act & Assert - var exception = Assert.Throws(() => - new InMemoryMcpTaskStore( - defaultTtl: TimeSpan.FromHours(2), - maxTtl: TimeSpan.FromHours(1))); - - Assert.Equal("defaultTtl", exception.ParamName); - Assert.Contains("Default TTL", exception.Message); - Assert.Contains("cannot exceed maximum TTL", exception.Message); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - [Fact] - public async Task CreateTaskAsync_UsesConfiguredPollInterval() - { - // Arrange - using var store = new InMemoryMcpTaskStore(pollInterval: TimeSpan.FromMilliseconds(2500)); - var metadata = new McpTaskMetadata(); + await store.SetInputRequestsAsync(created.TaskId, new Dictionary + { + ["req1"] = MakeRequest("request1"), + ["req2"] = MakeRequest("request2"), + }, CT); - // Act - var task = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary + { + ["req1"] = MakeResponse("response1"), + }, CT); - // Assert - Assert.Equal(TimeSpan.FromMilliseconds(2500), task.PollInterval); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.InputRequired, task.Status); + Assert.NotNull(task.InputRequests); + Assert.Single(task.InputRequests); + Assert.True(task.InputRequests.ContainsKey("req2")); } [Fact] - public void Constructor_ThrowsWhenPollIntervalIsZero() + public async Task ResolveInputRequestsAsync_TransitionsToWorkingWhenAllSatisfied() { - // Arrange & Act & Assert - var exception = Assert.Throws(() => - new InMemoryMcpTaskStore(pollInterval: TimeSpan.Zero)); - - Assert.Equal("pollInterval", exception.ParamName); - Assert.Contains("Poll interval must be positive", exception.Message); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - [Fact] - public void Constructor_ThrowsWhenPollIntervalIsNegative() - { - // Arrange & Act & Assert - var exception = Assert.Throws(() => - new InMemoryMcpTaskStore(pollInterval: TimeSpan.FromMilliseconds(-100))); + await store.SetInputRequestsAsync(created.TaskId, new Dictionary + { + ["req1"] = MakeRequest("request1"), + }, CT); - Assert.Equal("pollInterval", exception.ParamName); - Assert.Contains("Poll interval must be positive", exception.Message); - } + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary + { + ["req1"] = MakeResponse("response1"), + }, CT); - [Fact] - public async Task GetTaskAsync_ReturnsDefensiveCopy() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var createdTask = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - Get the task and modify the returned copy - var retrievedTask = await store.GetTaskAsync(createdTask.TaskId, null, TestContext.Current.CancellationToken); - var originalStatus = retrievedTask!.Status; - retrievedTask.Status = McpTaskStatus.Completed; - retrievedTask.StatusMessage = "Modified externally"; - - // Assert - Get the task again and verify the stored state wasn't affected - var taskAgain = await store.GetTaskAsync(createdTask.TaskId, null, TestContext.Current.CancellationToken); - Assert.Equal(originalStatus, taskAgain!.Status); - Assert.Null(taskAgain.StatusMessage); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Working, task.Status); } [Fact] - public async Task ListTasksAsync_ReturnsDefensiveCopies() + public async Task SetCompletedAsync_ThrowsForUnknownTask() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - List tasks and modify the returned copies - var result = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - var firstTask = result.Tasks[0]; - var originalTaskId = firstTask.TaskId; - firstTask.Status = McpTaskStatus.Failed; - firstTask.StatusMessage = "Modified in list"; - - // Assert - Get the task directly and verify the stored state wasn't affected - var directTask = await store.GetTaskAsync(originalTaskId, null, TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Working, directTask!.Status); - Assert.Null(directTask.StatusMessage); - } + var store = new InMemoryMcpTaskStore(); - [Fact] - public async Task CancelTaskAsync_ReturnsDefensiveCopy() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var metadata = new McpTaskMetadata(); - var createdTask = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Act - Cancel the task and modify the returned copy - var cancelledTask = await store.CancelTaskAsync(createdTask.TaskId, null, TestContext.Current.CancellationToken); - cancelledTask.StatusMessage = "Modified after cancel"; - cancelledTask.Status = McpTaskStatus.Completed; - - // Assert - Get the task again and verify it's still cancelled with no message - var taskAgain = await store.GetTaskAsync(createdTask.TaskId, null, TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Cancelled, taskAgain!.Status); - Assert.Null(taskAgain.StatusMessage); + await Assert.ThrowsAsync( + () => store.SetCompletedAsync("nonexistent", JsonSerializer.SerializeToElement("x", McpJsonUtilities.DefaultOptions), CT)); } [Fact] - public async Task ConcurrentUpdates_HandlesContentionCorrectly() + public async Task ConcurrentUpdates_DoNotLoseData() { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var task = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Act - Launch 100 concurrent updates to the same task - var updateTasks = Enumerable.Range(0, 100).Select(i => - Task.Run(async () => + var tasks = Enumerable.Range(0, 50).Select(i => + store.SetInputRequestsAsync(created.TaskId, new Dictionary { - try - { - await store.UpdateTaskStatusAsync(task.TaskId, McpTaskStatus.Working, $"Update {i}", null, TestContext.Current.CancellationToken); - return true; - } - catch - { - return false; - } - })); - - var results = await Task.WhenAll(updateTasks); - - // Assert - All updates should succeed (retry loop handles contention) - Assert.All(results, success => Assert.True(success)); - - // Verify task is still in valid state (one of the updates won) - var finalTask = await store.GetTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - Assert.NotNull(finalTask); - Assert.Equal(McpTaskStatus.Working, finalTask.Status); - Assert.Matches(@"Update \d+", finalTask.StatusMessage!); - } + [$"req{i}"] = MakeRequest($"value{i}") + }, CT)); - [Fact] - public async Task ConcurrentStoreResult_OnlyFirstWins() - { - // Arrange - using var store = new InMemoryMcpTaskStore(); - var task = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); + await Task.WhenAll(tasks); - // Act - Try to store results concurrently (only first should succeed) - var storeTasks = Enumerable.Range(0, 10).Select(i => - Task.Run(async () => - { - try - { - var result = new CallToolResult { Content = [new TextContentBlock { Text = $"Result {i}" }] }; - var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.DefaultOptions); - await store.StoreTaskResultAsync( - task.TaskId, - McpTaskStatus.Completed, - resultElement, - null, - TestContext.Current.CancellationToken); - return i; - } - catch (InvalidOperationException) - { - // Expected: task already in terminal state - return -1; - } - })); - - var results = await Task.WhenAll(storeTasks); - var successfulUpdates = results.Where(r => r >= 0).ToList(); - - // Assert - Exactly one update should succeed, others should fail - Assert.Single(successfulUpdates); - - // Verify the winning result is stored - var finalTask = await store.GetTaskAsync(task.TaskId, null, TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Completed, finalTask!.Status); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.InputRequired, task.Status); + Assert.NotNull(task.InputRequests); + Assert.Equal(50, task.InputRequests.Count); } [Fact] - public async Task ListTasksAsync_PaginationWithCustomPageSize() + public async Task ResolveInputRequestsAsync_ForExtraKeys_DoesNotThrow() { - // Arrange - Use small page size for testing - using var store = new InMemoryMcpTaskStore(pageSize: 10); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Create 25 tasks - for (int i = 0; i < 25; i++) + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary { - await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - } - - // Act - Paginate through all tasks - var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken); - var result3 = await store.ListTasksAsync(cursor: result2.NextCursor, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(10, result1.Tasks.Count); - Assert.NotNull(result1.NextCursor); - Assert.Equal(10, result2.Tasks.Count); - Assert.NotNull(result2.NextCursor); - Assert.Equal(5, result3.Tasks.Count); - Assert.Null(result3.NextCursor); - - // Verify no duplicates across pages - var allTaskIds = result1.Tasks.Concat(result2.Tasks).Concat(result3.Tasks).Select(t => t.TaskId).ToList(); - Assert.Equal(25, allTaskIds.Distinct().Count()); + ["unknown-key"] = MakeResponse("response"), + }, CT); + + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Working, task.Status); } [Fact] - public async Task ListTasksAsync_NoDuplicatesWithIdenticalTimestamps() + public async Task ResolveInputRequestsAsync_AlreadyResolvedKey_IsNoOp() { - // Arrange - using var store = new InMemoryMcpTaskStore(pageSize: 5); - - // Create tasks with identical metadata to increase chance of timestamp collision - var createTasks = Enumerable.Range(0, 20).Select(i => - store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken)); - - await Task.WhenAll(createTasks); + // SEP-2663: "Each entry key SHOULD be unique across the lifetime of a given task" and + // servers should tolerate clients re-sending an inputResponse for an already-resolved key. + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + await store.SetInputRequestsAsync(created.TaskId, new Dictionary + { + ["a"] = MakeRequest("ask-a"), + ["b"] = MakeRequest("ask-b"), + }, CT); - // Act - Collect all tasks through pagination - var allTasks = new List(); - string? cursor = null; - do + // First resolve "a" — task should still be InputRequired because "b" remains. + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary + { + ["a"] = MakeResponse("answer-a"), + }, CT); + + var afterFirst = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(afterFirst); + Assert.Equal(McpTaskStatus.InputRequired, afterFirst.Status); + Assert.NotNull(afterFirst.InputRequests); + Assert.Single(afterFirst.InputRequests); + Assert.Contains("b", afterFirst.InputRequests.Keys); + + // Re-send "a" — should be a no-op (no exception, no state change). + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary { - var result = await store.ListTasksAsync(cursor: cursor, cancellationToken: TestContext.Current.CancellationToken); - allTasks.AddRange(result.Tasks); - cursor = result.NextCursor; - } while (cursor != null); - - // Assert - No duplicates - var taskIds = allTasks.Select(t => t.TaskId).ToList(); - Assert.Equal(20, taskIds.Count); - Assert.Equal(20, taskIds.Distinct().Count()); - - // Verify tasks are properly ordered - Assert.Equal(allTasks.OrderBy(t => t.CreatedAt).ThenBy(t => t.TaskId).Select(t => t.TaskId), taskIds); + ["a"] = MakeResponse("answer-a-again"), + }, CT); + + var afterDup = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(afterDup); + Assert.Equal(McpTaskStatus.InputRequired, afterDup.Status); + Assert.NotNull(afterDup.InputRequests); + Assert.Single(afterDup.InputRequests); + Assert.Contains("b", afterDup.InputRequests.Keys); + + // Resolve the remaining "b" — task should transition back to Working with an empty inputRequests. + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary + { + ["b"] = MakeResponse("answer-b"), + }, CT); + + var final = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(final); + Assert.Equal(McpTaskStatus.Working, final.Status); + Assert.True(final.InputRequests is null || final.InputRequests.Count == 0); } [Fact] - public async Task ListTasksAsync_ConsistentWithExpiredTasksRemovedBetweenPages() + public async Task ConcurrentResolveInputRequests_OnDisjointKeys_AllResolveCorrectly() { - // Arrange - Use FakeTimeProvider for deterministic testing - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - var ttl = TimeSpan.FromSeconds(1); - using var store = new TestInMemoryMcpTaskStore( - defaultTtl: ttl, - maxTtl: null, - pollInterval: null, - cleanupInterval: Timeout.InfiniteTimeSpan, - pageSize: 5, - maxTasks: null, - maxTasksPerSession: null, - timeProvider: fakeTime); - - // Create 15 tasks - for (int i = 0; i < 15; i++) - { - await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - } + // Verifies the optimistic-concurrency loop in InMemoryMcpTaskStore handles parallel + // tasks/update calls that each resolve a distinct subset of pending input requests. + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Act - Get first page immediately - var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); + var seed = Enumerable.Range(0, 20).ToDictionary( + i => $"req{i}", + i => MakeRequest($"ask{i}")); + await store.SetInputRequestsAsync(created.TaskId, seed, CT); - // Advance time past TTL to make tasks expire - fakeTime.Advance(ttl + TimeSpan.FromMilliseconds(500)); + var resolveTasks = Enumerable.Range(0, 20).Select(i => + store.ResolveInputRequestsAsync(created.TaskId, new Dictionary + { + [$"req{i}"] = MakeResponse($"answer{i}"), + }, CT)); - // Get second page after expiration - var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken); + await Task.WhenAll(resolveTasks); - // Assert - First page should have 5 tasks, second page should have 0 (all expired) - Assert.Equal(5, result1.Tasks.Count); - Assert.NotNull(result1.NextCursor); - Assert.Empty(result2.Tasks); - Assert.Null(result2.NextCursor); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Working, task.Status); + Assert.True(task.InputRequests is null || task.InputRequests.Count == 0); } [Fact] - public async Task ListTasksAsync_KeysetPaginationMaintainsConsistencyWithNewTasks() + public async Task SetCompletedAsync_DoesNotOverwriteCancelledTask() { - // Arrange - using var store = new InMemoryMcpTaskStore(pageSize: 5); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Create 10 initial tasks - for (int i = 0; i < 10; i++) - { - await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - } + var cancelled = await store.SetCancelledAsync(created.TaskId, CT); + Assert.True(cancelled); - // Get first page - var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(5, result1.Tasks.Count); + // Background worker finishing after cancellation must not flip the task back to Completed. + await store.SetCompletedAsync( + created.TaskId, + JsonSerializer.SerializeToElement("late-result", McpJsonUtilities.DefaultOptions), + CT); - // Add more tasks between pages (these should appear in later queries, not retroactively in page 2) - for (int i = 10; i < 15; i++) - { - await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - } - - // Get second page using cursor from before new tasks were added - var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Second page should have 5 tasks from original set - Assert.Equal(5, result2.Tasks.Count); - Assert.NotNull(result2.NextCursor); - - // Verify no overlap between pages - var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet(); - var page2Ids = result2.Tasks.Select(t => t.TaskId).ToHashSet(); - Assert.Empty(page1Ids.Intersect(page2Ids)); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Cancelled, task.Status); + Assert.Null(task.Result); } [Fact] - public async Task UpdateTaskStatusAsync_ConcurrentWithList_NoCorruption() + public async Task SetFailedAsync_DoesNotOverwriteCancelledTask() { - // Arrange - using var store = new InMemoryMcpTaskStore(pageSize: 10); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Create 20 tasks - var tasks = new List(); - for (int i = 0; i < 20; i++) - { - var task = await store.CreateTaskAsync(new McpTaskMetadata(), new RequestId($"req-{i}"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - tasks.Add(task); - } - - // Act - Concurrently list and update tasks - var ct = TestContext.Current.CancellationToken; - var listTask = Task.Run(async () => - { - var allTasks = new List(); - string? cursor = null; - do - { - var result = await store.ListTasksAsync(cursor: cursor, cancellationToken: TestContext.Current.CancellationToken); - allTasks.AddRange(result.Tasks); - cursor = result.NextCursor; - await Task.Delay(10, ct); // Small delay to increase chance of interleaving - } while (cursor != null); - return allTasks; - }, ct); - - var updateTask = Task.Run(async () => - { - foreach (var task in tasks) - { - await store.UpdateTaskStatusAsync(task.TaskId, McpTaskStatus.Working, "Updated", null, TestContext.Current.CancellationToken); - await Task.Delay(5, ct); // Small delay - } - }, ct); + await store.SetCancelledAsync(created.TaskId, CT); - await Task.WhenAll(listTask, updateTask); - var listedTasks = await listTask; + await store.SetFailedAsync( + created.TaskId, + JsonElement.Parse("""{"message":"boom"}"""), + CT); - // Assert - Should have listed all tasks without duplicates or corruption - Assert.Equal(20, listedTasks.Count); - Assert.Equal(20, listedTasks.Select(t => t.TaskId).Distinct().Count()); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Cancelled, task.Status); + Assert.Null(task.Error); } [Fact] - public void Constructor_ThrowsForInvalidMaxTasks() + public async Task SetCompletedAsync_DoesNotOverwriteCompletedTask() { - // Assert - Assert.Throws(() => new InMemoryMcpTaskStore(maxTasks: 0)); - Assert.Throws(() => new InMemoryMcpTaskStore(maxTasks: -1)); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - [Fact] - public void Constructor_ThrowsForInvalidMaxTasksPerSession() - { - // Assert - Assert.Throws(() => new InMemoryMcpTaskStore(maxTasksPerSession: 0)); - Assert.Throws(() => new InMemoryMcpTaskStore(maxTasksPerSession: -1)); - } + var first = JsonSerializer.SerializeToElement("first", McpJsonUtilities.DefaultOptions); + await store.SetCompletedAsync(created.TaskId, first, CT); - [Fact] - public async Task CreateTaskAsync_EnforcesMaxTasksLimit() - { - // Arrange - using var store = new InMemoryMcpTaskStore(maxTasks: 3); - var metadata = new McpTaskMetadata(); - - // Act - Create up to the limit - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - await store.CreateTaskAsync(metadata, new RequestId("req-3"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Assert - Fourth task should throw - var ex = await Assert.ThrowsAsync(() => - store.CreateTaskAsync(metadata, new RequestId("req-4"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken)); - Assert.Contains("Maximum number of tasks (3) has been reached", ex.Message); - } + // A second completion attempt must not replace the original result. + var second = JsonSerializer.SerializeToElement("second", McpJsonUtilities.DefaultOptions); + await store.SetCompletedAsync(created.TaskId, second, CT); - [Fact] - public async Task CreateTaskAsync_EnforcesMaxTasksPerSessionLimit() - { - // Arrange - using var store = new InMemoryMcpTaskStore(maxTasksPerSession: 2); - var metadata = new McpTaskMetadata(); - - // Act - Create up to the limit for session-1 - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - - // Assert - Third task for session-1 should throw - var ex = await Assert.ThrowsAsync(() => - store.CreateTaskAsync(metadata, new RequestId("req-3"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken)); - Assert.Contains("Maximum number of tasks per session (2) has been reached", ex.Message); - Assert.Contains("session-1", ex.Message); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Completed, task.Status); + Assert.Equal("first", task.Result!.Value.GetString()); } [Fact] - public async Task CreateTaskAsync_MaxTasksPerSession_AllowsDifferentSessions() + public async Task ResolveInputRequestsAsync_OnTerminalTask_DoesNotResurrect() { - // Arrange - using var store = new InMemoryMcpTaskStore(maxTasksPerSession: 2); - var metadata = new McpTaskMetadata(); + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - // Act - Create 2 tasks for session-1 - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); + await store.SetCompletedAsync( + created.TaskId, + JsonSerializer.SerializeToElement("done", McpJsonUtilities.DefaultOptions), + CT); - // Should still be able to create tasks for session-2 - var task3 = await store.CreateTaskAsync(metadata, new RequestId("req-3"), new JsonRpcRequest { Method = "test" }, "session-2", TestContext.Current.CancellationToken); - var task4 = await store.CreateTaskAsync(metadata, new RequestId("req-4"), new JsonRpcRequest { Method = "test" }, "session-2", TestContext.Current.CancellationToken); + // A client tasks/update against a Completed task must not flip it back to Working. + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary + { + ["req1"] = MakeResponse("response"), + }, CT); - // Assert - Assert.NotNull(task3); - Assert.NotNull(task4); + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Completed, task.Status); } [Fact] - public async Task CreateTaskAsync_MaxTasksPerSession_DoesNotApplyToNullSession() + public async Task ResolveInputRequestsAsync_OnTerminalTask_DoesNotFireEvent() { - // Arrange - using var store = new InMemoryMcpTaskStore(maxTasksPerSession: 1); - var metadata = new McpTaskMetadata(); - - // Act - Create multiple tasks with null session (should not be limited) - var task1 = await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var task2 = await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - var task3 = await store.CreateTaskAsync(metadata, new RequestId("req-3"), new JsonRpcRequest { Method = "test" }, null, TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task1); - Assert.NotNull(task2); - Assert.NotNull(task3); - } + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); - [Fact] - public async Task CreateTaskAsync_CombinesMaxTasksAndMaxTasksPerSession() - { - // Arrange - Global limit of 5, per-session limit of 2 - using var store = new InMemoryMcpTaskStore(maxTasks: 5, maxTasksPerSession: 2); - var metadata = new McpTaskMetadata(); - - // Create 2 tasks for session-1 (hits per-session limit) - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - - // session-1 is at its limit - await Assert.ThrowsAsync(() => - store.CreateTaskAsync(metadata, new RequestId("req-3"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken)); - - // But session-2 can still create tasks - await store.CreateTaskAsync(metadata, new RequestId("req-4"), new JsonRpcRequest { Method = "test" }, "session-2", TestContext.Current.CancellationToken); - await store.CreateTaskAsync(metadata, new RequestId("req-5"), new JsonRpcRequest { Method = "test" }, "session-2", TestContext.Current.CancellationToken); - - // Now global limit is reached (4 tasks total, but 5th would be 5) - // Wait, we have 4 tasks, should be able to create one more - await store.CreateTaskAsync(metadata, new RequestId("req-6"), new JsonRpcRequest { Method = "test" }, "session-3", TestContext.Current.CancellationToken); - - // Now at 5 tasks (global limit), should throw - var ex = await Assert.ThrowsAsync(() => - store.CreateTaskAsync(metadata, new RequestId("req-7"), new JsonRpcRequest { Method = "test" }, "session-3", TestContext.Current.CancellationToken)); - Assert.Contains("Maximum number of tasks (5) has been reached", ex.Message); - } + await store.SetCancelledAsync(created.TaskId, CT); - [Fact] - public async Task CreateTaskAsync_MaxTasksPerSession_ExcludesExpiredTasks() - { - // Arrange - Use FakeTimeProvider for deterministic testing - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - var shortTtl = TimeSpan.FromMilliseconds(50); - using var store = new TestInMemoryMcpTaskStore( - defaultTtl: shortTtl, - maxTtl: null, - pollInterval: null, - cleanupInterval: Timeout.InfiniteTimeSpan, - pageSize: 100, - maxTasks: null, - maxTasksPerSession: 1, - timeProvider: fakeTime); - - var metadata = new McpTaskMetadata(); - - // Create first task - await store.CreateTaskAsync(metadata, new RequestId("req-1"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - - // Advance time past TTL to make the first task expire - fakeTime.Advance(shortTtl + TimeSpan.FromMilliseconds(1)); - - // Should be able to create another task since the first one expired - var task2 = await store.CreateTaskAsync(metadata, new RequestId("req-2"), new JsonRpcRequest { Method = "test" }, "session-1", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task2); - } + int eventCount = 0; + store.InputResponseReceived += _ => Interlocked.Increment(ref eventCount); - [Fact] - public async Task ListTasksAsync_KeysetPaginationWorksWithIdenticalTimestamps() - { - // Arrange - Use a fake time provider to create tasks with identical timestamps - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - using var store = new TestInMemoryMcpTaskStore( - defaultTtl: null, - maxTtl: null, - pollInterval: null, - cleanupInterval: Timeout.InfiniteTimeSpan, - pageSize: 5, - maxTasks: null, - maxTasksPerSession: null, - timeProvider: fakeTime); - - // Create 10 tasks - all with the EXACT same timestamp - var createdTasks = new List(); - for (int i = 0; i < 10; i++) + await store.ResolveInputRequestsAsync(created.TaskId, new Dictionary { - var task = await store.CreateTaskAsync( - new McpTaskMetadata(), - new RequestId($"req-{i}"), - new JsonRpcRequest { Method = "test" }, - null, - TestContext.Current.CancellationToken); - createdTasks.Add(task); - } - - // Verify all tasks have the same CreatedAt timestamp - var firstTimestamp = createdTasks[0].CreatedAt; - Assert.All(createdTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt)); - - // Act - Get first page - var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - First page should have 5 tasks - Assert.Equal(5, result1.Tasks.Count); - Assert.NotNull(result1.NextCursor); - - // Get second page using cursor - var result2 = await store.ListTasksAsync(cursor: result1.NextCursor, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Second page should have 5 tasks - Assert.Equal(5, result2.Tasks.Count); - Assert.Null(result2.NextCursor); // No more pages - - // Verify no overlap between pages - var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet(); - var page2Ids = result2.Tasks.Select(t => t.TaskId).ToHashSet(); - Assert.Empty(page1Ids.Intersect(page2Ids)); - - // Verify we got all 10 tasks exactly once - var allReturnedIds = page1Ids.Union(page2Ids).ToHashSet(); - var allCreatedIds = createdTasks.Select(t => t.TaskId).ToHashSet(); - Assert.Equal(allCreatedIds, allReturnedIds); + ["req1"] = MakeResponse("response"), + }, CT); + + Assert.Equal(0, eventCount); } [Fact] - public async Task ListTasksAsync_TasksCreatedAfterFirstPageWithSameTimestampAppearInSecondPage() + public async Task SetInputRequestsAsync_OnTerminalTask_NoOps() { - // Arrange - Use a fake time provider so we can control timestamps precisely - var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); - using var store = new TestInMemoryMcpTaskStore( - defaultTtl: null, - maxTtl: null, - pollInterval: null, - cleanupInterval: Timeout.InfiniteTimeSpan, - pageSize: 5, - maxTasks: null, - maxTasksPerSession: null, - timeProvider: fakeTime); - - // Create initial 6 tasks - all with the same timestamp - // (6 so that first page has 5 and cursor points to task 5) - var initialTasks = new List(); - for (int i = 0; i < 6; i++) - { - var task = await store.CreateTaskAsync( - new McpTaskMetadata(), - new RequestId($"req-initial-{i}"), - new JsonRpcRequest { Method = "test" }, - null, - TestContext.Current.CancellationToken); - initialTasks.Add(task); - } - - // Get first page - should have 5 tasks with a cursor - var result1 = await store.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(5, result1.Tasks.Count); - Assert.NotNull(result1.NextCursor); - - // Now create 5 more tasks AFTER we got the first page cursor - // These tasks have the SAME timestamp as the cursor (time hasn't moved) - // Due to monotonic UUID v7 with counter, they should sort AFTER the cursor - var laterTasks = new List(); - for (int i = 0; i < 5; i++) - { - var task = await store.CreateTaskAsync( - new McpTaskMetadata(), - new RequestId($"req-later-{i}"), - new JsonRpcRequest { Method = "test" }, - null, - TestContext.Current.CancellationToken); - laterTasks.Add(task); - } - - // Verify all tasks have the same timestamp - var allTasks = initialTasks.Concat(laterTasks).ToList(); - var firstTimestamp = allTasks[0].CreatedAt; - Assert.All(allTasks, task => Assert.Equal(firstTimestamp, task.CreatedAt)); - - // Get ALL remaining pages - var allSubsequentTasks = new List(); - string? cursor = result1.NextCursor; - while (cursor != null) + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + + await store.SetCancelledAsync(created.TaskId, CT); + + await store.SetInputRequestsAsync(created.TaskId, new Dictionary { - var result = await store.ListTasksAsync(cursor: cursor, cancellationToken: TestContext.Current.CancellationToken); - allSubsequentTasks.AddRange(result.Tasks); - cursor = result.NextCursor; - } - - // Verify no overlap between first page and subsequent - var page1Ids = result1.Tasks.Select(t => t.TaskId).ToHashSet(); - var subsequentIds = allSubsequentTasks.Select(t => t.TaskId).ToHashSet(); - Assert.Empty(page1Ids.Intersect(subsequentIds)); - - // Verify we got all tasks - var allReturnedIds = page1Ids.Union(subsequentIds).ToHashSet(); - var allCreatedIds = allTasks.Select(t => t.TaskId).ToHashSet(); - Assert.Equal(allCreatedIds, allReturnedIds); - - // Most importantly: verify ALL the later tasks (created after first page) are surfaced - // in the subsequent pages - var laterTaskIds = laterTasks.Select(t => t.TaskId).ToHashSet(); - Assert.Superset(laterTaskIds, subsequentIds); + ["req1"] = MakeRequest("payload"), + }, CT); + + var task = await store.GetTaskAsync(created.TaskId, CT); + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Cancelled, task.Status); + Assert.True(task.InputRequests is null || task.InputRequests.Count == 0); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs deleted file mode 100644 index 4c045cb21..000000000 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs +++ /dev/null @@ -1,1012 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for validation of task-augmented tool call requests. -/// -public class McpServerTaskAugmentedValidationTests : LoggedTest -{ - public McpServerTaskAugmentedValidationTests(ITestOutputHelper outputHelper) - : base(outputHelper) - { - } - - private static IDictionary CreateArguments(string key, object? value) - { - return new Dictionary - { - [key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone() - }; - } - - [Fact] - public async Task CallToolAsTask_ThrowsError_WhenNoTaskStoreConfigured() - { - // Arrange - Server WITHOUT task store, but with an async tool (auto-marked as taskSupport: optional) - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - // Note: NOT configuring a task store - builder.WithTools([McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(10, ct); - return $"Result: {input}"; - }, - new McpServerToolCreateOptions - { - Name = "async-tool", - Description = "An async tool" - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act & Assert - Calling with task metadata should fail - var exception = await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "async-tool", - Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken)); - - Assert.Contains("not supported", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CallToolAsTask_ThrowsError_WhenToolHasForbiddenTaskSupport() - { - // Arrange - Server with task store, but tool has taskSupport: forbidden (sync tool) - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - // Create a synchronous tool - which will have taskSupport: forbidden (default) - builder.WithTools([McpServerTool.Create( - (string input) => $"Result: {input}", - new McpServerToolCreateOptions - { - Name = "sync-tool", - Description = "A synchronous tool that does not support tasks" - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act & Assert - Calling with task metadata should fail because tool doesn't support it - var exception = await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "sync-tool", - Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken)); - - Assert.Contains("does not support task-augmented execution", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - } - - [Fact] - public async Task CallToolAsTask_Succeeds_WhenToolHasOptionalTaskSupport() - { - // Arrange - Server with task store and async tool (auto-marked as taskSupport: optional) - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - builder.WithTools([McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(10, ct); - return $"Result: {input}"; - }, - new McpServerToolCreateOptions - { - Name = "async-tool", - Description = "An async tool with optional task support" - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - Calling with task metadata should succeed - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "async-tool", - Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - // Assert - Should return a task - Assert.NotNull(result.Task); - Assert.NotNull(result.Task.TaskId); - } - - [Fact] - public async Task CallToolNormally_Succeeds_WhenToolHasForbiddenTaskSupport() - { - // Arrange - Server with task store, but calling without task metadata - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - builder.WithTools([McpServerTool.Create( - (string input) => $"Result: {input}", - new McpServerToolCreateOptions - { - Name = "sync-tool", - Description = "A synchronous tool" - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - Calling WITHOUT task metadata should succeed - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "sync-tool", - Arguments = CreateArguments("input", "test"), - }, - TestContext.Current.CancellationToken); - - // Assert - Should return normal result - Assert.NotNull(result.Content); - Assert.Null(result.Task); - } - - [Fact] - public async Task CallToolNormally_ThrowsError_WhenToolHasRequiredTaskSupport() - { - // Arrange - Server with task store and tool with taskSupport: required - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - builder.WithTools([McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(100, ct); - return $"Result: {input}"; - }, - new McpServerToolCreateOptions - { - Name = "required-task-tool", - Description = "A tool that requires task-augmented execution", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act & Assert - Calling WITHOUT task metadata should fail - var exception = await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams - { - Name = "required-task-tool", - Arguments = CreateArguments("input", "test"), - }, - TestContext.Current.CancellationToken)); - - Assert.Contains("requires task-augmented execution", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - } - - [Fact] - public async Task CallToolAsTask_Succeeds_WhenToolHasRequiredTaskSupport() - { - // Arrange - Server with task store and tool with taskSupport: required - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - builder.WithTools([McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(10, ct); - return $"Result: {input}"; - }, - new McpServerToolCreateOptions - { - Name = "required-task-tool", - Description = "A tool that requires task-augmented execution", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - Calling WITH task metadata should succeed - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "required-task-tool", - Arguments = CreateArguments("input", "test"), - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - // Assert - Should return a task - Assert.NotNull(result.Task); - Assert.NotNull(result.Task.TaskId); - } - - [Fact] - public async Task CallToolAsTask_WithRequiredTaskSupport_CanResolveScopedServicesFromDI() - { - // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1430: - // ExecuteToolAsTaskAsync fires Task.Run and returns immediately, so the request-scoped - // IServiceProvider owned by InvokeHandlerAsync is disposed before the background task - // calls tool.InvokeAsync. The fix creates a fresh scope inside the Task.Run body so the - // tool can resolve DI services without hitting ObjectDisposedException. - var taskStore = new InMemoryMcpTaskStore(); - string? capturedValue = null; - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - - // Register a scoped service; resolving it through a disposed scope was the bug. - services.AddScoped(); - - // Register the tool via the factory pattern so that Services = sp is threaded - // through, enabling DI parameter binding at tool-creation time. - builder.Services.AddSingleton(sp => McpServerTool.Create( - async (ITaskToolDiService svc, CancellationToken ct) => - { - await Task.Delay(10, ct); - capturedValue = svc.GetValue(); - return capturedValue; - }, - new McpServerToolCreateOptions - { - Name = "di-required-task-tool", - Services = sp, - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - })); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "di-required-task-tool", - Task = new McpTaskMetadata() - }, - TestContext.Current.CancellationToken); - - Assert.NotNull(result.Task); - string taskId = result.Task.TaskId; - - // Poll until the background task reaches a terminal state. - McpTask taskStatus; - int attempts = 0; - do - { - await Task.Delay(50, TestContext.Current.CancellationToken); - taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - attempts++; - } - while (taskStatus.Status == McpTaskStatus.Working && attempts < 50); - - // Without the fix, the background task would fail with ObjectDisposedException when - // resolving ITaskToolDiService, causing the task to reach McpTaskStatus.Failed. - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - Assert.Equal("hello-from-di", capturedValue); - } - - [Fact] - public async Task CallToolAsTaskAsync_WithProgress_CreatesTaskSuccessfully() - { - // Arrange - Server with task store and a tool that reports progress - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - builder.WithTools([McpServerTool.Create( - async (IProgress progress, CancellationToken ct) => - { - // Report progress - progress.Report(new ProgressNotificationValue - { - Progress = 50, - Total = 100, - Message = "Halfway done" - }); - await Task.Delay(10, ct); - return "Completed with progress"; - }, - new McpServerToolCreateOptions - { - Name = "progress-task-tool", - Description = "A tool that reports progress during task execution" - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Track progress notifications received by client - var receivedProgressValues = new List(); - IProgress progress = new SynchronousProgress(value => - { - lock (receivedProgressValues) - { - receivedProgressValues.Add(value); - } - }); - - // Act - Call tool as task with progress tracking - var mcpTask = await client.CallToolAsTaskAsync( - "progress-task-tool", - arguments: null, - taskMetadata: new McpTaskMetadata(), - progress: progress, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Task was created successfully - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - - // Note: Progress notifications may not be received for task-augmented calls - // because the notification handler is disposed when the task creation response returns. - // This test verifies the code path executes without errors. - } - - [Fact] - public async Task CallToolAsTaskAsync_WithoutProgress_DoesNotRequireProgressHandler() - { - // Arrange - Server with task store and a tool that reports progress - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - }); - - builder.WithTools([McpServerTool.Create( - async (IProgress progress, CancellationToken ct) => - { - // Tool reports progress but client doesn't listen - progress.Report(new ProgressNotificationValue { Progress = 50, Message = "Halfway" }); - await Task.Delay(10, ct); - return "Done"; - }, - new McpServerToolCreateOptions - { - Name = "progress-tool", - Description = "A tool that reports progress" - })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - Call tool as task WITHOUT progress tracking (progress: null) - var mcpTask = await client.CallToolAsTaskAsync( - "progress-tool", - arguments: null, - taskMetadata: new McpTaskMetadata(), - progress: null, // No progress handler - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Task was still created successfully - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - } - - private sealed class SynchronousProgress(Action callback) : IProgress - { - public void Report(ProgressNotificationValue value) => callback(value); - } - - #region Error Code Tests for Invalid/Nonexistent TaskId - - [Fact] - public async Task GetTaskAsync_WithNonexistentTaskId_ReturnsInvalidParamsError() - { - // Arrange - Spec: "Invalid or nonexistent taskId in tasks/get: -32602 (Invalid params)" - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await client.GetTaskAsync("nonexistent-task-id-12345", cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task GetTaskResultAsync_WithNonexistentTaskId_ReturnsInvalidParamsError() - { - // Arrange - Spec: "Invalid or nonexistent taskId in tasks/result: -32602 (Invalid params)" - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await client.GetTaskResultAsync("nonexistent-task-id-12345", cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - Assert.Contains("not found", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task CancelTaskAsync_WithNonexistentTaskId_ReturnsError() - { - // Arrange - Spec: "Invalid or nonexistent taskId in tasks/cancel: -32602 (Invalid params)" - // NOTE: Current implementation throws InternalError; this documents actual behavior - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await client.CancelTaskAsync("nonexistent-task-id-12345", cancellationToken: TestContext.Current.CancellationToken)); - - Assert.NotNull(exception); - } - - [Fact] - public async Task ListTasksAsync_WithInvalidCursor_HandlesGracefully() - { - // Arrange - Spec says: "Invalid or nonexistent cursor in tasks/list: -32602 (Invalid params)" - // Current implementation ignores invalid cursors gracefully - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - Pass invalid cursor - var result = await client.ListTasksAsync( - new ListTasksRequestParams { Cursor = "invalid-cursor-that-does-not-exist" }, - TestContext.Current.CancellationToken); - - // Assert - Should return valid (possibly empty) result - Assert.NotNull(result.Tasks); - } - - #endregion - - #region Blocking Behavior Tests - - [Fact] - public async Task GetTaskResultAsync_ReturnsImmediately_WhenTaskAlreadyComplete() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "quick result"; }, - new McpServerToolCreateOptions { Name = "quick-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Create and wait for task to complete - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "quick-tool", - Arguments = new Dictionary(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; - - // Wait for task to complete - McpTask taskStatus; - do - { - await Task.Delay(50, TestContext.Current.CancellationToken); - taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - // Act - Get result (should return since task is complete) - var result = await client.GetTaskResultAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Should get valid result - Assert.NotEqual(default, result); - } - - [Fact] - public async Task GetTaskResultAsync_ForFailedTask_ReturnsErrorResult() - { - // Arrange - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => - { - await Task.Delay(10, ct); - throw new InvalidOperationException("Tool execution failed intentionally"); -#pragma warning disable CS0162 // Unreachable code detected - return "never"; -#pragma warning restore CS0162 - }, - new McpServerToolCreateOptions { Name = "failable-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Create a failing task - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "failable-tool", - Arguments = new Dictionary(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; - - // Wait for task to fail - McpTask taskStatus; - int attempts = 0; - do - { - await Task.Delay(50, TestContext.Current.CancellationToken); - taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - attempts++; - } - while (taskStatus.Status == McpTaskStatus.Working && attempts < 50); - - Assert.Equal(McpTaskStatus.Failed, taskStatus.Status); - - // Act - Get result for failed task - var result = await client.GetTaskResultAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - var toolResult = result.Deserialize(McpJsonUtilities.DefaultOptions); - - // Assert - Failed task should have isError=true - Assert.NotNull(toolResult); - Assert.True(toolResult.IsError, "Failed task should have isError=true in the result"); - } - - #endregion - - #region Task Consistency and Lifecycle Tests - - [Fact] - public async Task ListTasksAsync_ContainsAllTasksRetrievableByGet() - { - // Arrange - Spec: "If a task is retrievable via tasks/get, it MUST be retrievable via tasks/list" - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (string input, CancellationToken ct) => { await Task.Delay(10, ct); return $"Result: {input}"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Create multiple tasks - var createdTaskIds = new List(); - for (int i = 0; i < 3; i++) - { - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "test-tool", - Arguments = new Dictionary - { - ["input"] = JsonDocument.Parse($"\"task-{i}\"").RootElement.Clone() - }, - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result.Task); - createdTaskIds.Add(result.Task.TaskId); - } - - // Verify each task is retrievable via get - foreach (var taskId in createdTaskIds) - { - var task = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(task); - } - - // Act - List all tasks - var allTasks = await client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - All tasks must be in the list - foreach (var taskId in createdTaskIds) - { - Assert.Contains(allTasks, t => t.TaskId == taskId); - } - } - - [Fact] - public async Task NewTask_StartsInWorkingStatus() - { - // Arrange - Spec: "Tasks MUST begin in the working status when created." - var taskStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var taskCanComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => - { - taskStarted.TrySetResult(true); - await taskCanComplete.Task.WaitAsync(ct); - return "done"; - }, - new McpServerToolCreateOptions { Name = "controllable-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - Create a task - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "controllable-tool", - Arguments = new Dictionary(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(callResult.Task); - Assert.Equal(McpTaskStatus.Working, callResult.Task.Status); - - // Cleanup - taskCanComplete.TrySetResult(true); - } - - [Fact] - public async Task Task_ContainsRequiredTimestamps() - { - // Arrange - Spec: "Receivers MUST include createdAt and lastUpdatedAt timestamps" - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - var beforeCreation = DateTimeOffset.UtcNow; - - // Act - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "test-tool", - Arguments = new Dictionary(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - var afterCreation = DateTimeOffset.UtcNow; - - // Assert - Assert.NotNull(callResult.Task); - Assert.NotEqual(default, callResult.Task.CreatedAt); - Assert.NotEqual(default, callResult.Task.LastUpdatedAt); - Assert.True(callResult.Task.CreatedAt >= beforeCreation.AddSeconds(-1)); - Assert.True(callResult.Task.CreatedAt <= afterCreation.AddSeconds(1)); - } - - [Fact] - public async Task Task_IncludesTtlInResponse() - { - // Arrange - Spec: "Receivers MUST include the actual ttl duration in tasks/get responses." - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "test-tool", - Arguments = new Dictionary(), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(30) } - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(callResult.Task); - Assert.NotNull(callResult.Task.TimeToLive); - - var taskStatus = await client.GetTaskAsync(callResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(taskStatus.TimeToLive); - } - - [Fact] - public async Task Task_IncludesPollIntervalInResponse() - { - // Arrange - Spec: "Receivers MAY include a pollInterval value" - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "test-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "test-tool", - Arguments = new Dictionary(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(callResult.Task); - Assert.NotNull(callResult.Task.PollInterval); - } - - #endregion - - #region Server Without Tasks Capability Tests - - [Fact] - public async Task ServerCapabilities_DoNotIncludeTasks_WhenNoTaskStore() - { - // Arrange - Spec: "If capabilities.tasks is not defined, the peer SHOULD NOT attempt to create tasks" - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - // NOT configuring a task store - builder.WithTools([McpServerTool.Create( - async (CancellationToken ct) => { await Task.Delay(10, ct); return "ok"; }, - new McpServerToolCreateOptions { Name = "async-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Assert - Assert.Null(client.ServerCapabilities?.Tasks); - } - - [Fact] - public async Task NormalRequest_Succeeds_WhenTasksNotSupported() - { - // Arrange - Normal requests should work without task support - await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => - { - builder.WithTools([McpServerTool.Create( - (string input) => $"Sync result: {input}", - new McpServerToolCreateOptions { Name = "sync-tool" })]); - }); - - await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); - - // Act - var result = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "sync-tool", - Arguments = CreateArguments("input", "test") - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(result.Content); - Assert.Null(result.Task); - } - - #endregion - - private interface ITaskToolDiService - { - string GetValue(); - } - - private sealed class TaskToolDiService : ITaskToolDiService - { - public string GetValue() => "hello-from-di"; - } - - /// - /// Helper fixture for creating server-client pairs with custom configuration. - /// - private sealed class ServerClientFixture : IAsyncDisposable - { - private readonly System.IO.Pipelines.Pipe _clientToServerPipe = new(); - private readonly System.IO.Pipelines.Pipe _serverToClientPipe = new(); - private readonly IServiceProvider _serviceProvider; - private readonly McpServer _server; - private readonly Task _serverTask; - private readonly CancellationTokenSource _cts; - private readonly ILoggerFactory _loggerFactory; - - public ServerClientFixture( - ILoggerFactory loggerFactory, - Action? configureServer = null) - { - _loggerFactory = loggerFactory; - _cts = new CancellationTokenSource(); - - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(loggerFactory); - - var builder = services - .AddMcpServer() - .WithStreamServerTransport( - _clientToServerPipe.Reader.AsStream(), - _serverToClientPipe.Writer.AsStream()); - - configureServer?.Invoke(services, builder); - - _serviceProvider = services.BuildServiceProvider(validateScopes: true); - _server = _serviceProvider.GetRequiredService(); - _serverTask = _server.RunAsync(_cts.Token); - } - - public async Task CreateClientAsync(CancellationToken cancellationToken) - { - return await McpClient.CreateAsync( - new StreamClientTransport( - serverInput: _clientToServerPipe.Writer.AsStream(), - _serverToClientPipe.Reader.AsStream(), - _loggerFactory), - loggerFactory: _loggerFactory, - cancellationToken: cancellationToken); - } - - public async ValueTask DisposeAsync() - { - await _cts.CancelAsync(); - - _clientToServerPipe.Writer.Complete(); - _serverToClientPipe.Writer.Complete(); - - try - { - await _serverTask; - } - catch (OperationCanceledException) - { - // Expected - } - - if (_serviceProvider is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - else if (_serviceProvider is IDisposable disposable) - { - disposable.Dispose(); - } - - _cts.Dispose(); - } - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs deleted file mode 100644 index d908bbb7f..000000000 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskMethodsTests.cs +++ /dev/null @@ -1,762 +0,0 @@ -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Runtime.InteropServices; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for McpServer methods that query tasks on the client (Phase 4 implementation). -/// -public class McpServerTaskMethodsTests : LoggedTest -{ - private readonly McpServerOptions _options; - - public McpServerTaskMethodsTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) - { -#if !NET - Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); -#endif - _options = CreateOptions(); - } - - private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = null) - { - return new McpServerOptions - { - ProtocolVersion = "2024", - InitializationTimeout = TimeSpan.FromSeconds(30), - Capabilities = capabilities, - }; - } - - #region SampleAsTaskAsync Tests - - [Fact] - public async Task SampleAsTaskAsync_ThrowsException_WhenClientDoesNotSupportSampling() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 }, - new McpTaskMetadata(), - CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task SampleAsTaskAsync_ThrowsException_WhenClientDoesNotSupportTaskAugmentedSampling() - { - // Arrange - Client supports sampling but NOT task-augmented sampling - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Sampling = new SamplingCapability(), - // Note: No Tasks capability - }, TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 }, - new McpTaskMetadata(), - CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task SampleAsTaskAsync_SendsRequest_WhenClientSupportsTaskAugmentedSampling() - { - // Arrange - await using var transport = new TestServerTransport(); - - // Configure transport to return a task result for sampling - transport.MockTask = new McpTask - { - TaskId = "sample-task-123", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Sampling = new SamplingCapability(), - Tasks = new McpTasksCapability - { - Requests = new RequestMcpTasksCapability - { - Sampling = new SamplingMcpTasksCapability - { - CreateMessage = new CreateMessageMcpTasksCapability() - } - } - } - }, TestContext.Current.CancellationToken); - - // Act - var task = await server.SampleAsTaskAsync( - new CreateMessageRequestParams { Messages = [], MaxTokens = 1000 }, - new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(5) }, - TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("sample-task-123", task.TaskId); - Assert.Equal(McpTaskStatus.Working, task.Status); - - // Verify the request was sent with task metadata - var samplingRequest = transport.SentMessages.OfType() - .FirstOrDefault(r => r.Method == RequestMethods.SamplingCreateMessage); - Assert.NotNull(samplingRequest); - var requestParams = JsonSerializer.Deserialize( - samplingRequest.Params, McpJsonUtilities.DefaultOptions); - Assert.NotNull(requestParams?.Task); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region ElicitAsTaskAsync Tests - - [Fact] - public async Task ElicitAsTaskAsync_ThrowsException_WhenClientDoesNotSupportElicitation() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.ElicitAsTaskAsync( - new ElicitRequestParams { Message = "test", RequestedSchema = new() }, - new McpTaskMetadata(), - CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task ElicitAsTaskAsync_ThrowsException_WhenClientDoesNotSupportTaskAugmentedElicitation() - { - // Arrange - Client supports elicitation but NOT task-augmented elicitation - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Elicitation = new ElicitationCapability { Form = new() }, - // Note: No Tasks capability - }, TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.ElicitAsTaskAsync( - new ElicitRequestParams { Message = "test", RequestedSchema = new() }, - new McpTaskMetadata(), - CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task ElicitAsTaskAsync_SendsRequest_WhenClientSupportsTaskAugmentedElicitation() - { - // Arrange - await using var transport = new TestServerTransport(); - - // Configure transport to return a task result for elicitation - transport.MockTask = new McpTask - { - TaskId = "elicit-task-456", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Elicitation = new ElicitationCapability { Form = new() }, - Tasks = new McpTasksCapability - { - Requests = new RequestMcpTasksCapability - { - Elicitation = new ElicitationMcpTasksCapability - { - Create = new CreateElicitationMcpTasksCapability() - } - } - } - }, TestContext.Current.CancellationToken); - - // Act - var task = await server.ElicitAsTaskAsync( - new ElicitRequestParams { Message = "Please provide input", RequestedSchema = new() }, - new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }, - TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("elicit-task-456", task.TaskId); - Assert.Equal(McpTaskStatus.Working, task.Status); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region GetTaskAsync Tests - - [Fact] - public async Task GetTaskAsync_ThrowsException_WhenClientDoesNotSupportTasks() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.GetTaskAsync("task-id", CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task GetTaskAsync_SendsRequest_AndReturnsTask() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "client-task-789", - Status = McpTaskStatus.Completed, - StatusMessage = "Task completed successfully", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act - var task = await server.GetTaskAsync("client-task-789", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("client-task-789", task.TaskId); - Assert.Equal(McpTaskStatus.Completed, task.Status); - - // Verify the request was sent - var taskRequest = transport.SentMessages.OfType() - .FirstOrDefault(r => r.Method == RequestMethods.TasksGet); - Assert.NotNull(taskRequest); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task GetTaskAsync_ThrowsArgumentException_WhenTaskIdIsEmpty() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.GetTaskAsync("", CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region GetTaskResultAsync Tests - - [Fact] - public async Task GetTaskResultAsync_ThrowsException_WhenClientDoesNotSupportTasks() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.GetTaskResultAsync("task-id", cancellationToken: CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task GetTaskResultAsync_ReturnsDeserializedResult() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTaskResult = new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Hello from task result!" }], - Model = "gpt-4" - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act - var result = await server.GetTaskResultAsync( - "task-id", cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(result); - Assert.Equal("gpt-4", result.Model); - Assert.Single(result.Content); - var textContent = Assert.IsType(result.Content[0]); - Assert.Equal("Hello from task result!", textContent.Text); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region ListTasksAsync Tests - - [Fact] - public async Task ListTasksAsync_ThrowsException_WhenClientDoesNotSupportTasks() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.ListTasksAsync(CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task ListTasksAsync_ThrowsException_WhenClientDoesNotSupportTaskListing() - { - // Arrange - Client supports tasks but NOT task listing - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability - { - // Note: No List capability - } - }, TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.ListTasksAsync(CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task ListTasksAsync_ReturnsTaskList() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTaskList = - [ - new McpTask - { - TaskId = "task-a", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10), - LastUpdatedAt = DateTimeOffset.UtcNow, - }, - new McpTask - { - TaskId = "task-b", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }, - new McpTask - { - TaskId = "task-c", - Status = McpTaskStatus.Failed, - StatusMessage = "Task failed", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-2), - LastUpdatedAt = DateTimeOffset.UtcNow, - } - ]; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability - { - List = new ListMcpTasksCapability() - } - }, TestContext.Current.CancellationToken); - - // Act - var tasks = await server.ListTasksAsync(TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(tasks); - Assert.Equal(3, tasks.Count); - Assert.Equal("task-a", tasks[0].TaskId); - Assert.Equal("task-b", tasks[1].TaskId); - Assert.Equal("task-c", tasks[2].TaskId); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region CancelTaskAsync Tests - - [Fact] - public async Task CancelTaskAsync_ThrowsException_WhenClientDoesNotSupportTasks() - { - // Arrange - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.CancelTaskAsync("task-id", CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task CancelTaskAsync_ThrowsException_WhenClientDoesNotSupportTaskCancellation() - { - // Arrange - Client supports tasks but NOT task cancellation - await using var transport = new TestServerTransport(); - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability - { - // Note: No Cancel capability - } - }, TestContext.Current.CancellationToken); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await server.CancelTaskAsync("task-id", CancellationToken.None)); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task CancelTaskAsync_SendsRequest_AndReturnsCancelledTask() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "task-to-cancel", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-3), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability - { - Cancel = new CancelMcpTasksCapability() - } - }, TestContext.Current.CancellationToken); - - // Act - var task = await server.CancelTaskAsync("task-to-cancel", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("task-to-cancel", task.TaskId); - Assert.Equal(McpTaskStatus.Cancelled, task.Status); - - // Verify the request was sent - var cancelRequest = transport.SentMessages.OfType() - .FirstOrDefault(r => r.Method == RequestMethods.TasksCancel); - Assert.NotNull(cancelRequest); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region PollTaskUntilCompleteAsync Tests - - [Fact] - public async Task PollTaskUntilCompleteAsync_ReturnsImmediately_WhenTaskIsAlreadyComplete() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "completed-task", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act - var task = await server.PollTaskUntilCompleteAsync("completed-task", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("completed-task", task.TaskId); - Assert.Equal(McpTaskStatus.Completed, task.Status); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task PollTaskUntilCompleteAsync_ReturnsTask_WhenTaskFails() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "failed-task", - Status = McpTaskStatus.Failed, - StatusMessage = "Task execution failed", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act - var task = await server.PollTaskUntilCompleteAsync("failed-task", TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("failed-task", task.TaskId); - Assert.Equal(McpTaskStatus.Failed, task.Status); - Assert.Equal("Task execution failed", task.StatusMessage); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region WaitForTaskResultAsync Tests - - [Fact] - public async Task WaitForTaskResultAsync_ReturnsTaskAndResult_WhenTaskCompletes() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "task-with-result", - Status = McpTaskStatus.Completed, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - transport.MockTaskResult = new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Final result from task" }], - Model = "test-model" - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act - var (task, result) = await server.WaitForTaskResultAsync( - "task-with-result", cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(task); - Assert.Equal("task-with-result", task.TaskId); - Assert.Equal(McpTaskStatus.Completed, task.Status); - - Assert.NotNull(result); - Assert.Equal("test-model", result.Model); - var textContent = Assert.IsType(Assert.Single(result.Content)); - Assert.Equal("Final result from task", textContent.Text); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task WaitForTaskResultAsync_ThrowsException_WhenTaskFails() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "failed-task", - Status = McpTaskStatus.Failed, - StatusMessage = "Something went wrong", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act & Assert - var ex = await Assert.ThrowsAsync(async () => - await server.WaitForTaskResultAsync( - "failed-task", cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains("failed", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Something went wrong", ex.Message); - - await transport.DisposeAsync(); - await runTask; - } - - [Fact] - public async Task WaitForTaskResultAsync_ThrowsException_WhenTaskIsCancelled() - { - // Arrange - await using var transport = new TestServerTransport(); - transport.MockTask = new McpTask - { - TaskId = "cancelled-task", - Status = McpTaskStatus.Cancelled, - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), - LastUpdatedAt = DateTimeOffset.UtcNow, - }; - - await using var server = McpServer.Create(transport, _options, LoggerFactory); - var runTask = server.RunAsync(TestContext.Current.CancellationToken); - await InitializeServerAsync(transport, new ClientCapabilities - { - Tasks = new McpTasksCapability() - }, TestContext.Current.CancellationToken); - - // Act & Assert - var ex = await Assert.ThrowsAsync(async () => - await server.WaitForTaskResultAsync( - "cancelled-task", cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains("cancelled", ex.Message, StringComparison.OrdinalIgnoreCase); - - await transport.DisposeAsync(); - await runTask; - } - - #endregion - - #region Helper Methods - - private static async Task InitializeServerAsync(TestServerTransport transport, ClientCapabilities capabilities, CancellationToken cancellationToken = default) - { - var initializeRequest = new JsonRpcRequest - { - Id = new RequestId("init-1"), - Method = RequestMethods.Initialize, - Params = JsonSerializer.SerializeToNode(new InitializeRequestParams - { - ProtocolVersion = "2024-11-05", - Capabilities = capabilities, - ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" } - }, McpJsonUtilities.DefaultOptions) - }; - - var tcs = new TaskCompletionSource(); - transport.OnMessageSent = (message) => - { - if (message is JsonRpcResponse response && response.Id == initializeRequest.Id) - { - tcs.TrySetResult(true); - } - }; - - await transport.SendClientMessageAsync(initializeRequest, cancellationToken); - - // Wait for the initialize response to be sent - await tcs.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); - } - - #endregion -} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskNotificationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskNotificationTests.cs deleted file mode 100644 index aa8941864..000000000 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskNotificationTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.Collections.Concurrent; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for task status notification functionality in McpServer. -/// -public class McpServerTaskNotificationTests : ClientServerTestBase -{ - public McpServerTaskNotificationTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) - { - } - - [Fact] - public async Task NotifyTaskStatusAsync_SendsNotificationWithTaskDetails() - { - // Arrange - var client = await CreateMcpClientForServer(); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, - (notification, cancellationToken) => - { - if (notification.Params is { } paramsNode) - { - var notificationParams = JsonSerializer.Deserialize(paramsNode, McpJsonUtilities.DefaultOptions); - if (notificationParams is not null) - { - tcs.TrySetResult(notificationParams); - } - } - return default; - }); - - var mcpTask = new McpTask - { - TaskId = "task-123", - Status = McpTaskStatus.Working, - StatusMessage = "Processing request", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromMinutes(10), - PollInterval = TimeSpan.FromSeconds(1) - }; - - // Act - await Server.NotifyTaskStatusAsync(mcpTask, TestContext.Current.CancellationToken); - var notification = await tcs.Task.WaitAsync(TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(mcpTask.TaskId, notification.TaskId); - Assert.Equal(mcpTask.Status, notification.Status); - Assert.Equal(mcpTask.StatusMessage, notification.StatusMessage); - Assert.Equal(mcpTask.CreatedAt, notification.CreatedAt); - Assert.Equal(mcpTask.LastUpdatedAt, notification.LastUpdatedAt); - Assert.Equal(mcpTask.TimeToLive, notification.TimeToLive); - Assert.Equal(mcpTask.PollInterval, notification.PollInterval); - } - - [Fact] - public async Task NotifyTaskStatusAsync_ThrowsOnNullTask() - { - // Arrange - await CreateMcpClientForServer(); - - // Act & Assert - await Assert.ThrowsAsync( - () => Server.NotifyTaskStatusAsync(null!, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task NotifyTaskStatusAsync_SendsMultipleNotificationsForDifferentStatuses() - { - // Arrange - var client = await CreateMcpClientForServer(); - var receivedNotifications = new ConcurrentBag(); - int expectedCount = 3; - var allReceivedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, - (notification, cancellationToken) => - { - if (notification.Params is { } paramsNode) - { - var notificationParams = JsonSerializer.Deserialize(paramsNode, McpJsonUtilities.DefaultOptions); - if (notificationParams is not null) - { - receivedNotifications.Add(notificationParams); - if (receivedNotifications.Count >= expectedCount) - { - allReceivedTcs.TrySetResult(true); - } - } - } - return default; - }); - - // Act - Send notifications for different statuses - var task1 = new McpTask - { - TaskId = "task-456", - Status = McpTaskStatus.Working, - StatusMessage = "Starting", - CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1), - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromMinutes(10), - PollInterval = TimeSpan.FromSeconds(1) - }; - - var task2 = new McpTask - { - TaskId = "task-456", - Status = McpTaskStatus.Working, - StatusMessage = "Processing", - CreatedAt = task1.CreatedAt, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromMinutes(10), - PollInterval = TimeSpan.FromSeconds(1) - }; - - var task3 = new McpTask - { - TaskId = "task-456", - Status = McpTaskStatus.Completed, - StatusMessage = "Done", - CreatedAt = task1.CreatedAt, - LastUpdatedAt = DateTimeOffset.UtcNow, - TimeToLive = TimeSpan.FromMinutes(10), - PollInterval = TimeSpan.FromSeconds(1) - }; - - await Server.NotifyTaskStatusAsync(task1, TestContext.Current.CancellationToken); - await Server.NotifyTaskStatusAsync(task2, TestContext.Current.CancellationToken); - await Server.NotifyTaskStatusAsync(task3, TestContext.Current.CancellationToken); - - // Wait for all notifications to be received - await allReceivedTcs.Task.WaitAsync(TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(3, receivedNotifications.Count); - Assert.Contains(receivedNotifications, n => n.Status == McpTaskStatus.Working && n.StatusMessage == "Starting"); - Assert.Contains(receivedNotifications, n => n.Status == McpTaskStatus.Working && n.StatusMessage == "Processing"); - Assert.Contains(receivedNotifications, n => n.Status == McpTaskStatus.Completed && n.StatusMessage == "Done"); - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs new file mode 100644 index 000000000..7166e1f25 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -0,0 +1,655 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the MCP tasks extension (SEP-2663) end-to-end using a simple in-memory task store. +/// +public class McpServerTaskTests : ClientServerTestBase +{ + private readonly InMemoryTaskStore _taskStore = new(); + private JsonObject? _capturedMeta; + + public McpServerTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddSingleton(_taskStore); + + mcpServerBuilder.Services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + + options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) => + { + _capturedMeta = context.Params?.Meta; + var store = context.Server.Services!.GetRequiredService(); + var toolName = context.Params!.Name; + + if (toolName == "immediate-tool") + { + return new CallToolResult() + { + Content = [new TextContentBlock { Text = "immediate result" }], + }; + } + + if (toolName == "async-tool") + { + var taskId = store.CreateTask(); + return new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }; + } + + if (toolName == "input-required-tool") + { + var taskId = store.CreateTask(McpTaskStatus.InputRequired); + return new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }; + } + + throw new McpException($"Unknown tool: {toolName}"); + }; + + options.Handlers.GetTaskHandler = async (context, cancellationToken) => + { + var store = context.Server.Services!.GetRequiredService(); + var taskId = context.Params!.TaskId; + return store.GetTask(taskId); + }; + + options.Handlers.UpdateTaskHandler = async (context, cancellationToken) => + { + var store = context.Server.Services!.GetRequiredService(); + var taskId = context.Params!.TaskId; + store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary()); + return new UpdateTaskResult(); + }; + + options.Handlers.CancelTaskHandler = async (context, cancellationToken) => + { + var store = context.Server.Services!.GetRequiredService(); + var taskId = context.Params!.TaskId; + store.CancelTask(taskId); + return new CancelTaskResult(); + }; + }); + } + + [Fact] + public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "immediate-tool" }, + TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Single(result.Content); + Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "immediate-tool" }, + TestContext.Current.CancellationToken); + + Assert.False(augmented.IsTask); + Assert.NotNull(augmented.Result); + Assert.Null(augmented.TaskCreated); + Assert.Equal("immediate result", Assert.IsType(augmented.Result.Content[0]).Text); + } + + [Fact] + public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, + TestContext.Current.CancellationToken); + + Assert.True(augmented.IsTask); + Assert.NotNull(augmented.TaskCreated); + Assert.Null(augmented.Result); + Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status); + Assert.Equal("task", augmented.TaskCreated.ResultType); + } + + [Fact] + public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + // Complete the task after a brief delay so polling finds it. + _ = Task.Run(async () => + { + await Task.Delay(100, ct); + // The store should have exactly one task by now + var taskId = _taskStore.GetAllTaskIds().Single(); + _taskStore.CompleteTask(taskId, new CallToolResult + { + Content = [new TextContentBlock { Text = "async result" }], + }); + }, ct); + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "async-tool" }, + ct); + + Assert.NotNull(result); + Assert.Single(result.Content); + Assert.Equal("async result", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + _ = Task.Run(async () => + { + await Task.Delay(100, ct); + var taskId = _taskStore.GetAllTaskIds().Single(); + _taskStore.FailTask(taskId, JsonElement.Parse("""{"code":-32000,"message":"something went wrong"}""")); + }, ct); + + await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + new CallToolRequestParams { Name = "async-tool" }, + ct)); + } + + [Fact] + public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + _ = Task.Run(async () => + { + await Task.Delay(100, ct); + var taskId = _taskStore.GetAllTaskIds().Single(); + _taskStore.CancelTask(taskId); + }, ct); + + await Assert.ThrowsAsync(async () => + await client.CallToolAsync( + new CallToolRequestParams { Name = "async-tool" }, + ct)); + } + + [Fact] + public async Task GetTaskAsync_ReturnsCurrentState() + { + await using var client = await CreateMcpClientForServer(); + + // Create a task via CallToolRawAsync + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, + TestContext.Current.CancellationToken); + + var taskId = augmented.TaskCreated!.TaskId; + + // Should be working + var taskResult = await client.GetTaskAsync(taskId, TestContext.Current.CancellationToken); + Assert.IsType(taskResult); + Assert.Equal(taskId, taskResult.TaskId); + Assert.Equal(McpTaskStatus.Working, taskResult.Status); + } + + [Fact] + public async Task CancelTaskAsync_CancelsTask() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, + TestContext.Current.CancellationToken); + + var taskId = augmented.TaskCreated!.TaskId; + + // Cancel via client + var cancelResult = await client.CancelTaskAsync(taskId, TestContext.Current.CancellationToken); + Assert.NotNull(cancelResult); + + // Verify state changed + var taskResult = await client.GetTaskAsync(taskId, TestContext.Current.CancellationToken); + Assert.IsType(taskResult); + } + + [Fact] + public async Task ConfigureTasks_AdvertisesExtensionInCapabilities() + { + await using var client = await CreateMcpClientForServer(); + + // The server advertises the tasks extension during initialize. + // The client should see it in server capabilities after the handshake. + #pragma warning disable MCP_EXTENSIONS + var extensions = client.ServerCapabilities.Extensions; + #pragma warning restore MCP_EXTENSIONS + Assert.NotNull(extensions); + Assert.True(extensions.ContainsKey(McpExtensions.Tasks)); + } + + [Fact] + public async Task CreateTaskResult_HasResultTypeTask() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, + TestContext.Current.CancellationToken); + + Assert.True(augmented.IsTask); + Assert.Equal("task", augmented.TaskCreated!.ResultType); + } + + [Fact] + public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves() + { + // Strong consistency: tasks/get immediately after CreateTaskResult must resolve. + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, + TestContext.Current.CancellationToken); + + var taskId = augmented.TaskCreated!.TaskId; + + // No delay — immediate get + var taskResult = await client.GetTaskAsync(taskId, TestContext.Current.CancellationToken); + Assert.NotNull(taskResult); + Assert.Equal(taskId, taskResult.TaskId); + } + + [Fact] + public async Task GetTaskAsync_UnknownTaskId_ThrowsWithInvalidParams() + { + await using var client = await CreateMcpClientForServer(); + + var ex = await Assert.ThrowsAsync(async () => + await client.GetTaskAsync("nonexistent-task-id-12345", TestContext.Current.CancellationToken)); + + // The server should reject with an error referencing the unknown task + Assert.Contains("Unknown task", ex.Message); + } + + [Fact] + public async Task CancelTask_AlreadyTerminal_AcknowledgesIdempotently() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, ct); + var taskId = augmented.TaskCreated!.TaskId; + + // Cancel once + await client.CancelTaskAsync(taskId, ct); + + // Cancel again on terminal task — should not throw, returns ack + var ack = await client.CancelTaskAsync(taskId, ct); + Assert.NotNull(ack); + } + + [Fact] + public async Task UpdateTaskAsync_TransitionsFromInputRequired() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + // Create an input-required task + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "input-required-tool" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + // Verify it's input_required + var taskResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(taskResult); + + // Provide input + var inputResponses = new Dictionary + { + ["resp-1"] = new InputResponse { RawValue = JsonElement.Parse("""{"answer":"yes"}""") } + }; + await client.UpdateTaskAsync(new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, ct); + + // Verify it transitioned back to working + taskResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(taskResult); + } + + [Fact] + public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() + { + // Verify the server receives the task extension in _meta by intercepting + // the handler. The CallToolWithTaskHandler already receives the request, + // so we can observe the meta there. We test the client-side injection indirectly + // by confirming the server returns a task result (which requires the capability signal). + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool" }, + TestContext.Current.CancellationToken); + + // If the capability wasn't injected, the server couldn't have returned a task + Assert.True(augmented.IsTask); + } + + [Fact] + public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope() + { + // SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope: + // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} + // This test pins the literal wire path so future refactors can't regress. + await using var client = await CreateMcpClientForServer(); + + await client.CallToolRawAsync( + new CallToolRequestParams { Name = "immediate-tool" }, + TestContext.Current.CancellationToken); + + Assert.NotNull(_capturedMeta); + + var caps = Assert.IsType(_capturedMeta!["io.modelcontextprotocol/clientCapabilities"]); + var extensions = Assert.IsType(caps["extensions"]); + Assert.True(extensions.ContainsKey("io.modelcontextprotocol/tasks"), + "Expected _meta to contain io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks (SEP-2575 envelope)."); + + // The opt-in value is an empty object per SEP-2575. + Assert.IsType(extensions["io.modelcontextprotocol/tasks"]); + } + + [Fact] + public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() + { + // User-supplied _meta entries at the root must not be clobbered, and the SEP-2575 + // envelope must be added alongside them, not in place of them. + await using var client = await CreateMcpClientForServer(); + + var userMeta = new JsonObject + { + ["customKey"] = "customValue", + ["io.modelcontextprotocol/clientCapabilities"] = new JsonObject + { + ["extensions"] = new JsonObject + { + ["some.other/extension"] = new JsonObject(), + }, + }, + }; + + await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "immediate-tool", + Meta = userMeta, + }, + TestContext.Current.CancellationToken); + + Assert.NotNull(_capturedMeta); + + // User's sibling root entry is preserved. + Assert.Equal("customValue", (string?)_capturedMeta!["customKey"]); + + // User's pre-existing nested extension is preserved next to the tasks opt-in. + var caps = Assert.IsType(_capturedMeta["io.modelcontextprotocol/clientCapabilities"]); + var extensions = Assert.IsType(caps["extensions"]); + Assert.True(extensions.ContainsKey("some.other/extension")); + Assert.True(extensions.ContainsKey("io.modelcontextprotocol/tasks")); + } + + [Fact] + public async Task CallToolRawAsync_PreservesExistingUserMeta() + { + // Verify that user-supplied meta fields are not clobbered + await using var client = await CreateMcpClientForServer(); + + var userMeta = new JsonObject { ["customKey"] = "customValue" }; + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "immediate-tool", + Meta = userMeta, + }, + TestContext.Current.CancellationToken); + + // Should still work — the meta was cloned, not destructively modified + Assert.False(augmented.IsTask); + Assert.Equal("immediate result", Assert.IsType(augmented.Result!.Content[0]).Text); + + // Original user meta should not be mutated + Assert.Single(userMeta); + Assert.Equal("customValue", (string)userMeta["customKey"]!); + } + + [Fact] + public async Task CallToolAsync_RespectsServerPollInterval() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var startTime = DateTime.UtcNow; + + // Complete the task after a brief delay + _ = Task.Run(async () => + { + await Task.Delay(200, ct); + var taskId = _taskStore.GetAllTaskIds().Single(); + _taskStore.CompleteTask(taskId, new CallToolResult + { + Content = [new TextContentBlock { Text = "polled" }], + }); + }, ct); + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "async-tool" }, ct); + + var elapsed = DateTime.UtcNow - startTime; + + // The server sets pollIntervalMs=50. The task completes after 200ms. + // So we expect at least 1 poll interval to have passed. + Assert.True(elapsed.TotalMilliseconds >= 50, $"Expected at least 50ms, got {elapsed.TotalMilliseconds}ms"); + Assert.Equal("polled", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult() + { + // Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works + // in the handler context — this is already tested by "immediate-tool" working correctly. + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "immediate-tool" }, + TestContext.Current.CancellationToken); + + Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive() + { + var handlers = new McpServerHandlers(); + + handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); + Assert.Throws(() => + handlers.CallToolHandler = async (ctx, ct) => new CallToolResult()); + + handlers = new McpServerHandlers(); + + handlers.CallToolHandler = async (ctx, ct) => new CallToolResult(); + Assert.Throws(() => + handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult()); + } + + [Fact] + public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet() + { + var handlers = new McpServerHandlers(); + + handlers.CallToolHandler = async (ctx, ct) => new CallToolResult(); + handlers.CallToolHandler = null; + + // Now setting the other should work + handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); + Assert.NotNull(handlers.CallToolWithTaskHandler); + } + + /// + /// Simple in-memory task store for testing. + /// + private sealed class InMemoryTaskStore + { + private readonly ConcurrentDictionary _tasks = new(); + + public string CreateTask(McpTaskStatus initialStatus = McpTaskStatus.Working) + { + var taskId = Guid.NewGuid().ToString("N"); + _tasks[taskId] = new TaskEntry + { + Status = initialStatus, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }; + return taskId; + } + + public IEnumerable GetAllTaskIds() => _tasks.Keys; + + public GetTaskResult GetTask(string taskId) + { + if (!_tasks.TryGetValue(taskId, out var entry)) + { + throw new McpException($"Unknown task: '{taskId}'"); + } + + return entry.Status switch + { + McpTaskStatus.Working => new WorkingTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + PollIntervalMs = 50, + }, + McpTaskStatus.Completed => new CompletedTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + Result = JsonSerializer.SerializeToElement(entry.Result, McpJsonUtilities.DefaultOptions), + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + Error = entry.Error!.Value, + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + InputRequests = entry.InputRequests ?? new Dictionary(), + }, + _ => throw new InvalidOperationException($"Unexpected status: {entry.Status}") + }; + } + + public void CompleteTask(string taskId, CallToolResult result) + { + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.Status = McpTaskStatus.Completed; + entry.Result = result; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + } + } + + public void FailTask(string taskId, JsonElement error) + { + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.Status = McpTaskStatus.Failed; + entry.Error = error; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + } + } + + public void CancelTask(string taskId) + { + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.Status = McpTaskStatus.Cancelled; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + } + } + + public void ProvideInput(string taskId, IDictionary inputResponses) + { + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.InputResponses = inputResponses; + // Transition back to working after receiving input + entry.Status = McpTaskStatus.Working; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + } + } + + private sealed class TaskEntry + { + public McpTaskStatus Status { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset LastUpdatedAt { get; set; } + public CallToolResult? Result { get; set; } + public JsonElement? Error { get; set; } + public IDictionary? InputRequests { get; set; } + public IDictionary? InputResponses { get; set; } + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs new file mode 100644 index 000000000..9e264af78 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Runtime.InteropServices; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server +/// has neither an nor a +/// configured. The expected behavior is a silent synchronous fallback: the server returns the normal +/// with no Task envelope and no exception. +/// +public class McpServerTasksNoStoreTests : ClientServerTestBase +{ + public McpServerTasksNoStoreTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // Intentionally do NOT configure TaskStore or CallToolWithTaskHandler. + mcpServerBuilder.WithTools(); + } + + [Fact] + public async Task ClientOptIn_NoTaskStore_FallsBackToSyncResult() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + // CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta. + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "sync-tool" }, ct); + + // With no task store configured, the server must complete synchronously and return + // the standard CallToolResult — not a task envelope. + Assert.False(augmented.IsTask); + Assert.NotNull(augmented.Result); + Assert.Equal("sync result", Assert.IsType(augmented.Result!.Content[0]).Text); + } + + [Fact] + public async Task ClientOptIn_NoTaskStore_CallToolAsync_StillReturnsResult() + { + // CallToolAsync (the higher-level convenience) must also work in the no-store case, + // delegating to the underlying sync path without throwing. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "sync-tool" }, ct); + + Assert.NotNull(result); + Assert.Equal("sync result", Assert.IsType(result.Content[0]).Text); + } + + [McpServerToolType] + private sealed class NoStoreTools + { + [McpServerTool(Name = "sync-tool"), System.ComponentModel.Description("A plain sync tool")] + public static string SyncTool() => "sync result"; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index b8bd57b02..7ed3c35e3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -343,18 +343,13 @@ public async Task Initialize_CopiesAllCapabilityProperties() Resources = new ResourcesCapability(), Tools = new ToolsCapability(), Completions = new CompletionsCapability(), - Tasks = new McpTasksCapability(), Extensions = new Dictionary { ["io.test"] = new JsonObject() }, }; await Can_Handle_Requests( serverCapabilities: inputCapabilities, method: RequestMethods.Initialize, - configureOptions: options => - { - // Tasks capability requires a TaskStore - options.TaskStore = new InMemoryMcpTaskStore(); - }, + configureOptions: _ => { }, assertResult: (_, response) => { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 77b5be42d..a499b833b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1283,82 +1283,6 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo Assert.Contains("Streamable HTTP", exception.Message); } - [Fact] - public void AsyncTool_AutomaticallyMarkedWithTaskSupport() - { - // Async tools should automatically get TaskSupport = Optional - McpServerTool tool = McpServerTool.Create(AsyncToolReturningTask); - - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void AsyncTool_ValueTask_AutomaticallyMarkedWithTaskSupport() - { - // Async tools returning ValueTask should also get TaskSupport = Optional - McpServerTool tool = McpServerTool.Create(AsyncToolReturningValueTask); - - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void AsyncTool_TaskOfT_AutomaticallyMarkedWithTaskSupport() - { - // Async tools returning Task should get TaskSupport = Optional - McpServerTool tool = McpServerTool.Create(AsyncToolReturningTaskOfT); - - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void AsyncTool_ValueTaskOfT_AutomaticallyMarkedWithTaskSupport() - { - // Async tools returning ValueTask should get TaskSupport = Optional - McpServerTool tool = McpServerTool.Create(AsyncToolReturningValueTaskOfT); - - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void SyncTool_NotMarkedWithTaskSupport() - { - // Synchronous tools should not have TaskSupport set - McpServerTool tool = McpServerTool.Create(SyncTool); - - Assert.Null(tool.ProtocolTool.Execution); - } - - private static async Task AsyncToolReturningTask() - { - await Task.Yield(); - } - - private static async ValueTask AsyncToolReturningValueTask() - { - await Task.Yield(); - } - - private static async Task AsyncToolReturningTaskOfT() - { - await Task.Yield(); - return "result"; - } - - private static async ValueTask AsyncToolReturningValueTaskOfT() - { - await Task.Yield(); - return "result"; - } - - private static string SyncTool() - { - return "sync result"; - } - [Description("Tool that returns data.")] [return: Description("The computed result")] private static string ToolWithReturnDescription() => "result"; diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs new file mode 100644 index 000000000..09a501ab8 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -0,0 +1,742 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Microsoft.Extensions.DependencyInjection; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading.Channels; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the -based auto-wiring of tools/call into tasks. +/// Verifies that setting enables task support +/// for -based tools. +/// +public class McpTaskStoreTests : ClientServerTestBase +{ + public McpTaskStoreTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools(); + + mcpServerBuilder.Services.Configure(options => + { + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + }; + }); + } + + [Fact] + public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "slow-tool" }, + TestContext.Current.CancellationToken); + + // Because the client signals task support and a TaskStore is configured, + // the server should wrap the tool execution in a task. + Assert.True(augmented.IsTask); + Assert.NotNull(augmented.TaskCreated); + Assert.Equal(McpTaskStatus.Working, augmented.TaskCreated.Status); + } + + [Fact] + public async Task CallToolAsync_WithTaskStore_PollsToCompletion() + { + await using var client = await CreateMcpClientForServer(); + + // CallToolAsync should poll until the background execution completes. + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "slow-tool" }, + TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Single(result.Content); + Assert.Equal("slow result", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask() + { + await using var client = await CreateMcpClientForServer(); + + // Even a fast tool should go through the task store when the client signals capability. + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "fast-tool" }, + TestContext.Current.CancellationToken); + + Assert.True(augmented.IsTask); + } + + [Fact] + public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "fast-tool" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + // The fast-tool returns immediately in the background, so poll briefly + GetTaskResult? taskResult = null; + for (int i = 0; i < 20; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is CompletedTaskResult) + { + break; + } + } + + Assert.IsType(taskResult); + } + + [Fact] + public async Task CancelTaskAsync_ViaStore_TransitionsToCancelled() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + // Create a slow task that won't complete on its own + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "slow-tool" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + // Cancel it + await client.CancelTaskAsync(taskId, ct); + + // Verify state + var taskResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(taskResult); + } + + [Fact] + public async Task GetTaskAsync_UnknownId_ThrowsWithInvalidParams() + { + await using var client = await CreateMcpClientForServer(); + + var ex = await Assert.ThrowsAsync(async () => + await client.GetTaskAsync("nonexistent-id", TestContext.Current.CancellationToken)); + + Assert.Contains("Unknown task", ex.Message); + } + + [Fact] + public async Task ToolExecution_Failure_StoresAsCompletedWithError() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "failing-tool" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + // Poll until completed (tool exceptions are wrapped as isError:true results) + GetTaskResult? taskResult = null; + for (int i = 0; i < 20; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is CompletedTaskResult) + { + break; + } + } + + var completed = Assert.IsType(taskResult); + // The tool result has isError: true + Assert.True(completed.Result.GetProperty("isError").GetBoolean()); + } + + [Fact] + public async Task McpProtocolException_FromTool_StoresAsFailedWithJsonRpcErrorShape() + { + // SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}. + // When a tool throws McpProtocolException, the task-store wrapper must serialize the error + // payload with the exception's ErrorCode and Message preserved on the wire. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + GetTaskResult? taskResult = null; + for (int i = 0; i < 20; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is FailedTaskResult) + { + break; + } + } + + var failed = Assert.IsType(taskResult); + + // The error MUST be a JSON-RPC error object with at least 'code' and 'message'. + Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind); + Assert.Equal((int)McpErrorCode.InvalidParams, failed.Error.GetProperty("code").GetInt32()); + Assert.Equal("custom-protocol-message", failed.Error.GetProperty("message").GetString()); + } + + [Fact] + public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage() + { + // [McpServerTool] methods that throw InputRequiredException can't compose with the task-store + // wrapper today: the taskId was already returned synchronously and there's no way to surface + // InputRequiredResult retroactively. The wrapper must fail the task with a clear, actionable + // message instead of leaking the raw exception through the generic catch. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "mrtr-tool" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + GetTaskResult? taskResult = null; + for (int i = 0; i < 20; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is FailedTaskResult) + { + break; + } + } + + var failed = Assert.IsType(taskResult); + Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind); + Assert.Equal((int)McpErrorCode.InvalidRequest, failed.Error.GetProperty("code").GetInt32()); + + var message = failed.Error.GetProperty("message").GetString(); + Assert.NotNull(message); + Assert.Contains("MRTR", message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + } + + [Fact] + public async Task ElicitTool_ViaTask_RedirectsThroughStore() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + { + // Client responds to the elicitation + return new ValueTask(new ElicitResult { Action = "accept" }); + } + } + }); + var ct = TestContext.Current.CancellationToken; + + // CallToolAsync will poll and resolve input requests automatically. + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "elicit-tool" }, ct); + + Assert.NotNull(result); + Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task SampleTool_ViaTask_RedirectsThroughStore() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers + { + SamplingHandler = (request, progress, ct) => + { + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "sampled response" }], + Model = "test-model", + }); + } + } + }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "sample-tool" }, ct); + + Assert.NotNull(result); + Assert.Equal("sampled response", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task RootsTool_ViaTask_RedirectsThroughStore() + { + // Verifies that server-initiated roots/list calls issued from inside a [McpServerTool] + // running under the task wrapper are redirected through the task store as input requests + // (rather than being sent as direct JSON-RPC requests to the client). + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability(), + }, + Handlers = new McpClientHandlers + { + RootsHandler = (request, ct) => + new ValueTask(new ListRootsResult + { + Roots = [new Root { Uri = "file:///workspace" }, new Root { Uri = "file:///other" }], + }), + }, + }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "roots-tool" }, ct); + + Assert.NotNull(result); + Assert.Equal("file:///workspace,file:///other", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotificationE2E() + { + // E2E coverage for SendTaskStatusNotificationAsync: the tool emits a Working then a + // Completed notification with a fixed test taskId, and the client receives them via + // its notifications/tasks subscription, deserialized to the right concrete subtype. + var notifications = Channel.CreateUnbounded(); + + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + await using var registration = client.RegisterNotificationHandler( + NotificationMethods.TaskStatusNotification, + (notification, _) => + { + var typed = JsonSerializer.Deserialize( + notification.Params, + McpJsonUtilities.DefaultOptions); + if (typed is not null) + { + notifications.Writer.TryWrite(typed); + } + + return default; + }); + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "notifying-tool" }, ct); + + Assert.Equal("notified", Assert.IsType(result.Content[0]).Text); + + // Read both notifications. The server emits them in strict order (each + // SendTaskStatusNotificationAsync awaits the transport write before the next), but client-side + // dispatch (McpSessionHandler.ProcessMessageAsync) is fire-and-forget per message, so user + // handlers may observe them out of order. Reconstruct send order via the server-set + // LastUpdatedAt timestamp. + var first = await notifications.Reader.ReadAsync(ct); + var second = await notifications.Reader.ReadAsync(ct); + var ordered = new[] { first, second }.OrderBy(n => n.LastUpdatedAt).ToArray(); + + var workingTyped = Assert.IsType(ordered[0]); + Assert.Equal("notify-test-task-id", workingTyped.TaskId); + Assert.Equal(McpTaskStatus.Working, workingTyped.Status); + + var completedTyped = Assert.IsType(ordered[1]); + Assert.Equal("notify-test-task-id", completedTyped.TaskId); + Assert.Equal(McpTaskStatus.Completed, completedTyped.Status); + Assert.Equal("notify-result", completedTyped.Result.GetString()); + } + + [Fact] + public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificationE2E() + { + // Companion to the Working/Completed test above, covering the Failed branch which + // carries the required JsonElement Error payload. + var notifications = Channel.CreateUnbounded(); + + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + await using var registration = client.RegisterNotificationHandler( + NotificationMethods.TaskStatusNotification, + (notification, _) => + { + var typed = JsonSerializer.Deserialize( + notification.Params, + McpJsonUtilities.DefaultOptions); + if (typed is FailedTaskNotificationParams) + { + notifications.Writer.TryWrite(typed); + } + + return default; + }); + + // The tool emits a Failed notification then returns a normal result, so we isolate the + // notification round-trip from the task-store's own failure handling. + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "failing-notify-tool" }, ct); + + Assert.Equal("emitted-failed", Assert.IsType(result.Content[0]).Text); + + var failed = await notifications.Reader.ReadAsync(ct); + var typed = Assert.IsType(failed); + Assert.Equal("failing-notify-task-id", typed.TaskId); + Assert.Equal(McpTaskStatus.Failed, typed.Status); + Assert.Equal(-32000, typed.Error.GetProperty("code").GetInt32()); + Assert.Equal("boom", typed.Error.GetProperty("message").GetString()); + } + + [Fact] + public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() + { + // This test verifies that the client doesn't re-resolve an input request + // that it has already responded to in a previous poll cycle. + int elicitCallCount = 0; + + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + { + Interlocked.Increment(ref elicitCallCount); + return new ValueTask(new ElicitResult { Action = "accept" }); + } + } + }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "elicit-tool" }, ct); + + // The handler should be called exactly once despite potential multiple polls + Assert.Equal(1, elicitCallCount); + Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }) + } + }); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "elicit-tool" }, ct); + + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; + + // Poll — eventually the task should be input_required (elicit-tool calls ElicitAsync) + GetTaskResult? taskResult = null; + for (int i = 0; i < 40; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is InputRequiredTaskResult) + { + break; + } + } + + Assert.IsType(taskResult); + } + + [Fact] + public async Task CancelTaskAsync_AlreadyCompleted_AcknowledgesIdempotently_AndDoesNotResurrect() + { + // Exercises the SDK's default tasks/cancel handler against the real InMemoryMcpTaskStore. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + // Run a fast tool to completion via the task store. + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "fast-tool" }, ct); + var taskId = augmented.TaskCreated!.TaskId; + + GetTaskResult? taskResult = null; + for (int i = 0; i < 40 && taskResult is not CompletedTaskResult; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + } + Assert.IsType(taskResult); + + // SEP-2663: tasks/cancel must be acknowledged idempotently even after the task has completed. + var cancelResult = await client.CancelTaskAsync(taskId, ct); + Assert.NotNull(cancelResult); + + // The task must remain Completed and the result must not be lost. + var verifyResult = await client.GetTaskAsync(taskId, ct); + var stillCompleted = Assert.IsType(verifyResult); + Assert.NotEqual(default(JsonElement), stillCompleted.Result); + } + + [Fact] + public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveClientStuck() + { + // Verifies bug fix: when the client-side input handler throws while resolving an + // InputRequired task, the exception propagates promptly (instead of the poll loop + // hanging) and the client issues a best-effort tasks/cancel to release the server. + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + throw new InvalidOperationException("handler-failed"), + } + }); + var ct = TestContext.Current.CancellationToken; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct)); + sw.Stop(); + + Assert.Equal("handler-failed", ex.Message); + + // Must fail fast: without the fix this would keep polling until the test cancellation token fires. + // Allow generous slack for CI but well under the test timeout. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), + $"CallToolAsync should propagate input handler exceptions promptly but took {sw.Elapsed}."); + } + + [Fact] + public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately() + { + // SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the + // extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw + // tools/call request without the io.modelcontextprotocol/tasks key in _meta. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var result = await client.SendRequestAsync( + RequestMethods.ToolsCall, + new CallToolRequestParams { Name = "fast-tool" }, + serializerOptions: McpJsonUtilities.DefaultOptions, + cancellationToken: ct); + + // Server should return a regular CallToolResult, never escalate to a task. + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.Equal("fast result", Assert.IsType(result.Content[0]).Text); + + // resultType is reserved and must not be the "task" discriminator for plain results. + Assert.NotEqual("task", result.ResultType); + } + + [Fact] + public async Task ToolReturnsCallToolResultWithIsError_AsTask_StoresAsCompleted_NotFailed() + { + // SEP-2663: "An MCP server MUST NOT use [Failed] for errors that would have been signaled + // by setting `CallToolResult.isError` to true ... Such errors are domain-level errors, and + // their result MUST be returned by the server in the same way that any standard call-tool + // result is returned." So a tool that returns isError:true MUST end up as a Completed task. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "iserror-tool" }, ct); + + var taskId = augmented.TaskCreated!.TaskId; + + GetTaskResult? taskResult = null; + for (int i = 0; i < 40 && taskResult is not CompletedTaskResult; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + } + + var completed = Assert.IsType(taskResult); + Assert.Equal(McpTaskStatus.Completed, completed.Status); + Assert.True(completed.Result.GetProperty("isError").GetBoolean()); + } + + [Fact] + public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_AcrossPolls() + { + // SEP-2663: "Each entry [in inputRequests] MUST be treated as if it were an equivalent + // standalone server-to-client request" and "clients SHOULD use [keys] to deduplicate". + // A tool that fires two concurrent server->client requests produces two unique keys; the + // client must dispatch the handler exactly twice in total, even across multiple polls. + int elicitCount = 0; + var observedMessages = new System.Collections.Concurrent.ConcurrentBag(); + + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + { + Interlocked.Increment(ref elicitCount); + observedMessages.Add(request?.Message ?? string.Empty); + return new ValueTask(new ElicitResult { Action = "accept" }); + } + } + }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolAsync( + new CallToolRequestParams { Name = "multi-elicit-tool" }, ct); + + // Exactly two handler invocations — one per unique input request key. + Assert.Equal(2, elicitCount); + Assert.Contains("first", observedMessages); + Assert.Contains("second", observedMessages); + Assert.Equal("accept|accept", Assert.IsType(result.Content[0]).Text); + } + + [McpServerToolType] + private sealed class TaskStoreTestTools + { + [McpServerTool(Name = "slow-tool"), System.ComponentModel.Description("A tool that takes time")] + public static async Task SlowTool(CancellationToken cancellationToken) + { + await Task.Delay(200, cancellationToken); + return "slow result"; + } + + [McpServerTool(Name = "fast-tool"), System.ComponentModel.Description("A fast tool")] + public static string FastTool() => "fast result"; + + [McpServerTool(Name = "failing-tool"), System.ComponentModel.Description("A tool that fails")] + public static string FailingTool() => throw new InvalidOperationException("intentional failure"); + + [McpServerTool(Name = "throws-mcp-protocol"), System.ComponentModel.Description("A tool that throws McpProtocolException")] + public static string ThrowsMcpProtocol() => + throw new McpProtocolException("custom-protocol-message", McpErrorCode.InvalidParams); + + [McpServerTool(Name = "mrtr-tool"), System.ComponentModel.Description("A tool that throws InputRequiredException (MRTR)")] + public static string MrtrTool() => + throw new InputRequiredException(requestState: "test-state"); + + [McpServerTool(Name = "elicit-tool"), System.ComponentModel.Description("A tool that elicits")] + public static async Task ElicitTool(McpServer server, CancellationToken cancellationToken) + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new(), + }, cancellationToken); + + return result.Action == "accept" ? "accepted" : "declined"; + } + + [McpServerTool(Name = "sample-tool"), System.ComponentModel.Description("A tool that samples")] + public static async Task SampleTool(McpServer server, CancellationToken cancellationToken) + { + var result = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "hello" }] }], + MaxTokens = 100, + }, cancellationToken); + + return result.Content.OfType().FirstOrDefault()?.Text ?? "no response"; + } + + [McpServerTool(Name = "roots-tool"), System.ComponentModel.Description("A tool that lists roots")] + public static async Task RootsTool(McpServer server, CancellationToken cancellationToken) + { + var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken); + return string.Join(",", result.Roots.Select(r => r.Uri)); + } + + [McpServerTool(Name = "notifying-tool"), System.ComponentModel.Description("A tool that emits SendTaskStatusNotificationAsync from inside the task wrapper")] + public static async Task NotifyingTool(McpServer server, CancellationToken cancellationToken) + { + var createdAt = DateTimeOffset.UtcNow; + + // Emit working then completed notifications using the public SendTaskStatusNotificationAsync API, + // so the test asserts the wire round-trip end-to-end (server → transport → client handler). + // Use distinct LastUpdatedAt values so the test can reconstruct send order on the receive side + // (client-side dispatch via McpSessionHandler.ProcessMessageAsync is fire-and-forget per message + // and may surface notifications to user handlers out of receipt order). + await server.SendTaskStatusNotificationAsync(new WorkingTaskNotificationParams + { + TaskId = "notify-test-task-id", + CreatedAt = createdAt, + LastUpdatedAt = createdAt, + }, cancellationToken); + + await server.SendTaskStatusNotificationAsync(new CompletedTaskNotificationParams + { + TaskId = "notify-test-task-id", + CreatedAt = createdAt, + LastUpdatedAt = createdAt.AddTicks(1), + Result = JsonElement.Parse("\"notify-result\""), + }, cancellationToken); + + return "notified"; + } + + [McpServerTool(Name = "failing-notify-tool"), System.ComponentModel.Description("A tool that emits a FailedTaskNotificationParams via SendTaskStatusNotificationAsync")] + public static async Task FailingNotifyTool(McpServer server, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var errorJson = JsonElement.Parse("""{"code":-32000,"message":"boom"}"""); + + await server.SendTaskStatusNotificationAsync(new FailedTaskNotificationParams + { + TaskId = "failing-notify-task-id", + CreatedAt = now, + LastUpdatedAt = now, + Error = errorJson, + }, cancellationToken); + + return "emitted-failed"; + } + + [McpServerTool(Name = "iserror-tool"), System.ComponentModel.Description("A tool that returns IsError=true without throwing")] + public static CallToolResult IsErrorTool() => new() + { + IsError = true, + Content = [new TextContentBlock { Text = "domain-error" }], + }; + + [McpServerTool(Name = "multi-elicit-tool"), System.ComponentModel.Description("A tool that issues two parallel elicitations")] + public static async Task MultiElicitTool(McpServer server, CancellationToken cancellationToken) + { + var first = server.ElicitAsync(new ElicitRequestParams + { + Message = "first", + RequestedSchema = new(), + }, cancellationToken); + + var second = server.ElicitAsync(new ElicitRequestParams + { + Message = "second", + RequestedSchema = new(), + }, cancellationToken); + + await Task.WhenAll(first.AsTask(), second.AsTask()); + + return $"{first.Result.Action}|{second.Result.Action}"; + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index cc075a676..1b424d002 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -3,13 +3,16 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Runtime.InteropServices; using System.Text.Json; +#pragma warning disable MCPEXP001 + namespace ModelContextProtocol.Tests.Server; /// -/// Integration tests for task cancellation behavior, including TTL-based automatic -/// cancellation and explicit cancellation via tasks/cancel. +/// Integration tests for task cancellation behavior, including explicit cancellation +/// via tasks/cancel and TTL-based automatic cancellation. /// public class TaskCancellationIntegrationTests : ClientServerTestBase { @@ -19,27 +22,28 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase public TaskCancellationIntegrationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif } protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Add task store for server-side task support - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - - services.Configure(options => + mcpServerBuilder.Services.Configure(options => { - options.TaskStore = taskStore; + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + DefaultTtlMs = 5000, + }; }); - // Add a long-running tool that captures cancellation mcpServerBuilder.WithTools([McpServerTool.Create( async (CancellationToken ct) => { _toolStarted.TrySetResult(true); try { - // Wait indefinitely until cancelled await Task.Delay(Timeout.Infinite, ct); return "completed"; } @@ -56,127 +60,54 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer })]); } - private static IDictionary EmptyArguments() => new Dictionary(); - - [Fact] - public async Task TaskTool_CancellationToken_FiresWhenTtlExpires() - { - // Arrange - await using McpClient client = await CreateMcpClientForServer(); - - // Act - Call tool with short TTL (200ms) - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long-running-tool", - Arguments = EmptyArguments(), - // Use a TTL long enough that thread pool scheduling delays on loaded CI machines - // don't cause the CTS to fire before the tool lambda begins executing. - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) } - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Verify task was created - Assert.NotNull(callResult.Task); - - // Wait for the tool to start executing - await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Assert - Wait for the cancellation to fire (should happen when TTL expires) - var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - Assert.True(cancelled, "Tool's CancellationToken should have been triggered when TTL expired"); - - // Note: TTL-based expiration does not explicitly set task status to Cancelled. - // Instead, expired tasks are considered "dead" and will be cleaned up by the task store. - // The task may still be in Working status or may throw "not found" if already cleaned up. - } - [Fact] public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled() { - // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - // Start a long-running task with a long TTL - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long-running-tool", - Arguments = EmptyArguments(), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) } - }, - cancellationToken: TestContext.Current.CancellationToken); + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "long-running-tool" }, ct); - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; // Wait for the tool to start executing - await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - // Act - Explicitly cancel the task - var cancelledTask = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + // Explicitly cancel the task + await client.CancelTaskAsync(taskId, ct); - // Assert - Wait for the cancellation to propagate to the tool - var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - Assert.True(cancelled, "Tool's CancellationToken should have been triggered by explicit cancellation"); + // Wait for the cancellation to propagate to the tool + var cancelled = await _toolCancellationFired.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + Assert.True(cancelled); - // Verify task status - Assert.Equal(McpTaskStatus.Cancelled, cancelledTask.Status); + // Verify task status shows cancelled + var taskResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(taskResult); } [Fact] - public async Task TaskTool_CompletesSuccessfully_WhenNotCancelled() + public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel() { - // Arrange - Create a new test with a quick-completing tool - var quickToolCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var services = new ServiceCollection(); - services.AddLogging(); - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - - var builder = services - .AddMcpServer() - .WithStreamServerTransport( - new System.IO.Pipelines.Pipe().Reader.AsStream(), - new System.IO.Pipelines.Pipe().Writer.AsStream()); - - builder.WithTools([McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(50, ct); // Quick operation - var result = $"Result: {input}"; - quickToolCompleted.TrySetResult(result); - return result; - }, - new McpServerToolCreateOptions - { - Name = "quick-tool", - Description = "A tool that completes quickly" - })]); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - services.Configure(options => - { - options.TaskStore = taskStore; - }); + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "long-running-tool" }, ct); - await using var client = await CreateMcpClientForServer(); + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; - // Act - Call tool with long TTL - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "long-running-tool", // Use the base class tool which will block - Arguments = EmptyArguments(), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(5) } - }, - cancellationToken: TestContext.Current.CancellationToken); + // Wait for the tool to start + await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, ct); - Assert.NotNull(callResult.Task); + // Check status while still running + var taskResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(taskResult); - // Verify task is in working state initially - var task = await client.GetTaskAsync(callResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Working, task.Status); + // Cleanup + await client.CancelTaskAsync(taskId, ct); } } @@ -192,19 +123,21 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase public TaskCancellationConcurrencyTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif } protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - - services.Configure(options => + mcpServerBuilder.Services.Configure(options => { - options.TaskStore = taskStore; + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + }; }); - // Tool that tracks cancellation per-invocation using a marker argument mcpServerBuilder.WithTools([McpServerTool.Create( async (string marker, CancellationToken ct) => { @@ -279,102 +212,47 @@ private static IDictionary CreateMarkerArgs(string marker) [Fact] public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks() { - // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; RegisterMarker("task1"); RegisterMarker("task2"); // Start two tasks - var result1 = await client.CallToolAsync( + var result1 = await client.CallToolRawAsync( new CallToolRequestParams { Name = "trackable-tool", Arguments = CreateMarkerArgs("task1"), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) } - }, - cancellationToken: TestContext.Current.CancellationToken); + }, ct); - var result2 = await client.CallToolAsync( + var result2 = await client.CallToolRawAsync( new CallToolRequestParams { Name = "trackable-tool", Arguments = CreateMarkerArgs("task2"), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) } - }, - cancellationToken: TestContext.Current.CancellationToken); + }, ct); - Assert.NotNull(result1.Task); - Assert.NotNull(result2.Task); + Assert.True(result1.IsTask); + Assert.True(result2.IsTask); // Wait for both tools to start - await WaitForStart("task1", TestContext.Current.CancellationToken); - await WaitForStart("task2", TestContext.Current.CancellationToken); + await WaitForStart("task1", ct); + await WaitForStart("task2", ct); - // Act - Cancel only task1 - await client.CancelTaskAsync(result1.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); + // Cancel only task1 + await client.CancelTaskAsync(result1.TaskCreated!.TaskId, ct); - // Assert - task1 should be cancelled - var task1Cancelled = await WaitForCancellation("task1", TestContext.Current.CancellationToken); - Assert.True(task1Cancelled, "Task1 should have been cancelled"); + // task1 should be cancelled + var task1Cancelled = await WaitForCancellation("task1", ct); + Assert.True(task1Cancelled); - // task2 should still be running (give it a moment to verify it wasn't cancelled) - var task2Status = await client.GetTaskAsync(result2.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Working, task2Status.Status); + // task2 should still be working + var task2Status = await client.GetTaskAsync(result2.TaskCreated!.TaskId, ct); + Assert.IsType(task2Status); - // Clean up - cancel task2 - await client.CancelTaskAsync(result2.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); - } - - [Fact] - public async Task MultipleTasks_WithDifferentTtls_CancelIndependently() - { - // Arrange - await using McpClient client = await CreateMcpClientForServer(); - - RegisterMarker("short-ttl"); - RegisterMarker("long-ttl"); - - // Start task with short TTL. Use a TTL long enough that thread pool scheduling - // delays on loaded CI machines don't cause the CTS to fire before the tool - // lambda begins executing (CancelAfter starts counting at task creation, not - // when the tool's Task.Run is scheduled). - var shortTtlResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "trackable-tool", - Arguments = CreateMarkerArgs("short-ttl"), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) } - }, - cancellationToken: TestContext.Current.CancellationToken); - - // Start task with long TTL - var longTtlResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "trackable-tool", - Arguments = CreateMarkerArgs("long-ttl"), - Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) } - }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(shortTtlResult.Task); - Assert.NotNull(longTtlResult.Task); - - // Wait for both to start - await WaitForStart("short-ttl", TestContext.Current.CancellationToken); - await WaitForStart("long-ttl", TestContext.Current.CancellationToken); - - // Assert - short TTL task should be cancelled automatically - var shortCancelled = await WaitForCancellation("short-ttl", TestContext.Current.CancellationToken); - Assert.True(shortCancelled, "Short TTL task should have been cancelled when TTL expired"); - - // Long TTL task should still be running - var longTtlStatus = await client.GetTaskAsync(longTtlResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Working, longTtlStatus.Status); - - // Clean up - await client.CancelTaskAsync(longTtlResult.Task.TaskId, cancellationToken: TestContext.Current.CancellationToken); + // Cleanup + await client.CancelTaskAsync(result2.TaskCreated!.TaskId, ct); } } @@ -388,16 +266,19 @@ public class TerminalTaskStatusTransitionTests : ClientServerTestBase public TerminalTaskStatusTransitionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif } protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - - services.Configure(options => + mcpServerBuilder.Services.Configure(options => { - options.TaskStore = taskStore; + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + }; }); mcpServerBuilder.WithTools([ @@ -429,81 +310,63 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer ]); } - private static IDictionary EmptyArguments() => new Dictionary(); - [Fact] - public async Task CompletedTask_CannotTransitionToOtherStatus() + public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchanged() { - // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "quick-tool", - Arguments = EmptyArguments(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "quick-tool" }, ct); - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; // Wait for completion - McpTask taskStatus; + GetTaskResult? taskResult; do { - await Task.Delay(50, TestContext.Current.CancellationToken); - taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); } - while (taskStatus.Status == McpTaskStatus.Working); - - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + while (taskResult is not CompletedTaskResult); - // Act - Try to cancel a completed task (should be idempotent) - var cancelResult = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + // SEP-2663: cancel on a terminal task must be acknowledged idempotently. + var cancelResult = await client.CancelTaskAsync(taskId, ct); + Assert.NotNull(cancelResult); - // Assert - Status should still be completed (not cancelled) - Assert.Equal(McpTaskStatus.Completed, cancelResult.Status); - - // Verify via get - var verifyStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal(McpTaskStatus.Completed, verifyStatus.Status); + // Verify status is still completed (not flipped to cancelled). + var verifyResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(verifyResult); } [Fact] - public async Task FailedTask_CannotTransitionToOtherStatus() + public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently() { - // Arrange - await using McpClient client = await CreateMcpClientForServer(); + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; - var callResult = await client.CallToolAsync( - new CallToolRequestParams - { - Name = "failing-tool", - Arguments = EmptyArguments(), - Task = new McpTaskMetadata() - }, - cancellationToken: TestContext.Current.CancellationToken); + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "failing-tool" }, ct); - Assert.NotNull(callResult.Task); - string taskId = callResult.Task.TaskId; + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; - // Wait for failure - McpTask taskStatus; + // Wait for completion (tool errors are wrapped as completed with isError=true) + GetTaskResult? taskResult; do { - await Task.Delay(50, TestContext.Current.CancellationToken); - taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); } - while (taskStatus.Status == McpTaskStatus.Working); - - Assert.Equal(McpTaskStatus.Failed, taskStatus.Status); + while (taskResult is not CompletedTaskResult); - // Act - Try to cancel a failed task (should be idempotent) - var cancelResult = await client.CancelTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + // SEP-2663: cancel on a terminal task must be acknowledged idempotently. + var cancelResult = await client.CancelTaskAsync(taskId, ct); + Assert.NotNull(cancelResult); - // Assert - Status should still be failed - Assert.Equal(McpTaskStatus.Failed, cancelResult.Status); + // Verify status is still completed (not flipped to cancelled). + var verifyResult = await client.GetTaskAsync(taskId, ct); + Assert.IsType(verifyResult); } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs new file mode 100644 index 000000000..8cd2f7839 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Runtime.InteropServices; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies the runtime validation that fires when a handler opts into task-augmented execution +/// (returns a ) without the server having any tasks/get +/// handler registered. +/// +public class TaskHandlerConfigurationValidationTests : ClientServerTestBase +{ + public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + + // Intentionally configure a task-augmented handler without TaskStore or any of the + // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). + options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + new ValueTask>(new CreateTaskResult + { + TaskId = "orphan-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }); + }); + } + + [Fact] + public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime() + { + await using var client = await CreateMcpClientForServer(); + + // Client surfaces a generic protocol error (the server intentionally redacts the message + // on the wire), so use the base McpException type and confirm via server-side logs that + // the originating exception was the misconfiguration guard. + await Assert.ThrowsAnyAsync(async () => + await client.CallToolAsync( + new CallToolRequestParams { Name = "anything" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Exception is InvalidOperationException ioe && + ioe.Message.Contains("tasks/get", StringComparison.Ordinal) && + ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs new file mode 100644 index 000000000..2f00d925a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Runtime.InteropServices; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Exercises the client-side guard that prevents an unbounded poll loop when a server keeps a +/// task in without publishing any new input requests +/// after every previously requested input has been resolved. +/// +public class TaskPollStuckDetectorTests : ClientServerTestBase +{ + public TaskPollStuckDetectorTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + + // CallTool always returns a CreateTaskResult with a tiny poll interval so the + // test exercises the threshold in well under a second. + options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + { + var taskId = Guid.NewGuid().ToString("N"); + return new ValueTask>(new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + ResultType = "task", + }); + }; + + // GetTask always reports InputRequired with NO outstanding input requests — the + // misbehaving-server condition the stuck-detector exists to break out of. + options.Handlers.GetTaskHandler = (context, cancellationToken) => + { + return new ValueTask(new InputRequiredTaskResult + { + TaskId = context.Params!.TaskId, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + InputRequests = new Dictionary(), + ResultType = "complete", + }); + }; + + // CancelTask must succeed since the client issues a best-effort cancel when it + // gives up; otherwise the cancel failure would mask the real exception. + options.Handlers.CancelTaskHandler = (context, cancellationToken) => + new ValueTask(new CancelTaskResult { ResultType = "complete" }); + + // UpdateTask is never invoked in this scenario (there are no input requests to resolve) + // but must be present so the handler-configuration validation passes. + options.Handlers.UpdateTaskHandler = (context, cancellationToken) => + new ValueTask(new UpdateTaskResult { ResultType = "complete" }); + }); + } + + [Fact] + public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_ThrowsAfterThreshold() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + + sw.Stop(); + + Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message); + Assert.Contains("consecutive polls", ex.Message); + + // 60 polls × 5ms ≈ 300ms; allow generous slack for CI. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), + $"Stuck-detector should give up promptly but took {sw.Elapsed}."); + } + + [Fact] + public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() + { + // Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync: + // a smaller configured threshold is surfaced verbatim in the McpException message. + const int CustomThreshold = 3; + + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + MaxConsecutiveStuckPolls = CustomThreshold, + }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + + // The message embeds the configured threshold, which is the strongest signal that the + // option value (not the 60-default constant) is what governed the loop. + Assert.Contains($"{CustomThreshold} consecutive polls", ex.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value) + { + var options = new McpClientOptions(); + Assert.Throws(() => options.MaxConsecutiveStuckPolls = value); + } + + [Fact] + public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60() + { + Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs new file mode 100644 index 000000000..303d16e17 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Runtime.InteropServices; +using System.Text.Json; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that when both and +/// are configured and the handler returns +/// (IsTask = true), the store's pre-created task is failed with a +/// clear error rather than being orphaned in forever. +/// +public class TaskStoreOrphanedTaskTests : ClientServerTestBase +{ + public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + options.TaskStore = new InMemoryMcpTaskStore(); + + // Returning IsTask = true here while TaskStore is also configured is the + // misconfiguration the server must guard against. + options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + new ValueTask>(new CreateTaskResult + { + TaskId = "user-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }); + }); + } + + [Fact] + public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearError() + { + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + // The store's task is created synchronously and its taskId returned to the client. + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "anything" }, ct); + + Assert.True(augmented.IsTask); + var taskId = augmented.TaskCreated!.TaskId; + + // Poll until the background runner observes the handler's IsTask=true and fails the + // store's task. Without the fix this would loop forever in Working. + GetTaskResult? taskResult = null; + for (int i = 0; i < 40; i++) + { + await Task.Delay(50, ct); + taskResult = await client.GetTaskAsync(taskId, ct); + if (taskResult is FailedTaskResult) + { + break; + } + } + + var failed = Assert.IsType(taskResult); + Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind); + Assert.Equal((int)McpErrorCode.InternalError, failed.Error.GetProperty("code").GetInt32()); + + var message = failed.Error.GetProperty("message").GetString(); + Assert.NotNull(message); + Assert.Contains(nameof(McpServerOptions.TaskStore), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs b/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs deleted file mode 100644 index 25db2b330..000000000 --- a/tests/ModelContextProtocol.Tests/Server/ToolTaskSupportTests.cs +++ /dev/null @@ -1,727 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Integration tests verifying that tools report correct ToolTaskSupport values -/// based on server configuration and method signatures. -/// -public class ToolTaskSupportTests : LoggedTest -{ - public ToolTaskSupportTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) - { - } - - [Fact] - public async Task Tools_WithoutTaskStore_ReportForbiddenTaskSupport() - { - // Arrange - Server without a task store - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([ - McpServerTool.Create(async (string input, CancellationToken ct) => - { - await Task.Delay(10, ct); - return $"Async: {input}"; - }, - new McpServerToolCreateOptions { Name = "async-tool", Description = "An async tool" }), - - McpServerTool.Create((string input) => $"Sync: {input}", - new McpServerToolCreateOptions { Name = "sync-tool", Description = "A sync tool" }) - ]); - }); - - // Act - var tools = await fixture.Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Both tools should have Forbidden task support when no task store is configured - Assert.Equal(2, tools.Count); - - var asyncTool = tools.Single(t => t.Name == "async-tool"); - var syncTool = tools.Single(t => t.Name == "sync-tool"); - - // Without a task store, async tools should still report Optional (their intrinsic capability) - // but the server won't have tasks in capabilities. The tool itself declares its support. - Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution?.TaskSupport); - - // Sync tools should have null Execution or Forbidden task support - Assert.True( - syncTool.ProtocolTool.Execution is null || - syncTool.ProtocolTool.Execution.TaskSupport is null || - syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden, - "Sync tools should not support task execution"); - } - - [Fact] - public async Task Tools_WithTaskStore_AsyncToolsReportOptionalTaskSupport() - { - // Arrange - Server with a task store - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([ - McpServerTool.Create(async (string input, CancellationToken ct) => - { - await Task.Delay(10, ct); - return $"Async: {input}"; - }, - new McpServerToolCreateOptions { Name = "async-tool", Description = "An async tool" }), - - McpServerTool.Create((string input) => $"Sync: {input}", - new McpServerToolCreateOptions { Name = "sync-tool", Description = "A sync tool" }) - ]); - }, - configureServices: services => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - // Act - var tools = await fixture.Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(2, tools.Count); - - var asyncTool = tools.Single(t => t.Name == "async-tool"); - var syncTool = tools.Single(t => t.Name == "sync-tool"); - - // Async tools should report Optional task support - Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution?.TaskSupport); - - // Sync tools should have null Execution or Forbidden task support - Assert.True( - syncTool.ProtocolTool.Execution is null || - syncTool.ProtocolTool.Execution.TaskSupport is null || - syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden, - "Sync tools should not support task execution"); - } - - [Fact] - public async Task Tools_WithExplicitTaskSupport_ReportsConfiguredValue() - { - // Arrange - Server with explicit task support configured on tools - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([ - McpServerTool.Create(async (string input, CancellationToken ct) => - { - await Task.Delay(10, ct); - return $"Async: {input}"; - }, - new McpServerToolCreateOptions - { - Name = "required-async-tool", - Description = "A tool that requires task execution", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - }), - - McpServerTool.Create((string input) => $"Sync: {input}", - new McpServerToolCreateOptions - { - Name = "forbidden-sync-tool", - Description = "A tool that forbids task execution", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Forbidden } - }) - ]); - }, - configureServices: services => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - // Act - var tools = await fixture.Client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(2, tools.Count); - - var requiredTool = tools.Single(t => t.Name == "required-async-tool"); - var forbiddenTool = tools.Single(t => t.Name == "forbidden-sync-tool"); - - Assert.Equal(ToolTaskSupport.Required, requiredTool.ProtocolTool.Execution?.TaskSupport); - Assert.Equal(ToolTaskSupport.Forbidden, forbiddenTool.ProtocolTool.Execution?.TaskSupport); - } - - [Fact] - public async Task ServerCapabilities_WithoutTaskStore_DoNotIncludeTasksCapability() - { - // Arrange - Server without a task store - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([ - McpServerTool.Create((string input) => $"Result: {input}", - new McpServerToolCreateOptions { Name = "test-tool" }) - ]); - }); - - // Assert - Server capabilities should not include tasks - Assert.Null(fixture.Client.ServerCapabilities?.Tasks); - } - - [Fact] - public async Task ServerCapabilities_WithTaskStore_IncludeTasksCapability() - { - // Arrange - Server with a task store - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([ - McpServerTool.Create((string input) => $"Result: {input}", - new McpServerToolCreateOptions { Name = "test-tool" }) - ]); - }, - configureServices: services => - { - services.Configure(options => - { - options.TaskStore = taskStore; - }); - }); - - // Assert - Server capabilities should include tasks - Assert.NotNull(fixture.Client.ServerCapabilities?.Tasks); - Assert.NotNull(fixture.Client.ServerCapabilities.Tasks.List); - Assert.NotNull(fixture.Client.ServerCapabilities.Tasks.Cancel); - Assert.NotNull(fixture.Client.ServerCapabilities.Tasks.Requests?.Tools?.Call); - } - -#pragma warning disable MCPEXP001 // Tasks feature is experimental - [Fact] - public void McpServerToolAttribute_TaskSupport_CanBeSetOnAttribute() - { - // Test that the TaskSupport property can be set via the attribute - // and is correctly read when creating a tool - var tool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.RequiredTaskTool))!); - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Required, tool.ProtocolTool.Execution.TaskSupport); - - var optionalTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.OptionalTaskTool))!); - Assert.NotNull(optionalTool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, optionalTool.ProtocolTool.Execution.TaskSupport); - - var forbiddenTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.ForbiddenTaskTool))!); - Assert.NotNull(forbiddenTool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Forbidden, forbiddenTool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void McpServerToolAttribute_TaskSupport_WhenNotSet_AllowsAutoDetection() - { - // When TaskSupport is not set on the attribute, async tools should use auto-detection (Optional) - var asyncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.AsyncToolWithoutTaskSupport))!); - Assert.NotNull(asyncTool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution.TaskSupport); - - // Sync tools without TaskSupport set should have null Execution or Forbidden - var syncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.SyncToolWithoutTaskSupport))!); - Assert.True( - syncTool.ProtocolTool.Execution is null || - syncTool.ProtocolTool.Execution.TaskSupport is null || - syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden, - "Sync tools without explicit TaskSupport should not support tasks"); - } - - [Fact] - public void McpServerToolAttribute_TaskSupport_ExplicitForbidden_OverridesAutoDetection() - { - // Verify that explicitly setting Forbidden overrides auto-detection for async methods - var forbiddenAsyncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.ForbiddenAsyncTool))!); - Assert.NotNull(forbiddenAsyncTool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Forbidden, forbiddenAsyncTool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void McpServerToolAttribute_TaskSupport_OptionalOnSyncMethod_IsAllowed() - { - // Setting Optional on a sync method is allowed - the tool will just execute very quickly - // This tests that the SDK doesn't prevent this configuration at tool creation time - var tool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.OptionalTaskTool))!); - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void McpServerToolAttribute_TaskSupport_RequiredOnSyncMethod_IsAllowed() - { - // Setting Required on a sync method is allowed - the tool will just execute very quickly - // This tests that the SDK doesn't prevent this configuration at tool creation time - var tool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.RequiredTaskTool))!); - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Required, tool.ProtocolTool.Execution.TaskSupport); - } -#pragma warning restore MCPEXP001 - -#pragma warning disable MCPEXP001 // Tasks feature is experimental - [Fact] - public void McpServerToolAttribute_TaskSupport_WhenNotSet_DefaultsBasedOnMethodSignature() - { - // When TaskSupport is not set on the attribute, async tools should default to Optional - var asyncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.AsyncToolWithoutTaskSupport))!); - Assert.NotNull(asyncTool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Optional, asyncTool.ProtocolTool.Execution.TaskSupport); - - // Sync tools should have null or no Execution set - var syncTool = McpServerTool.Create(typeof(TaskSupportAttributeTestTools).GetMethod(nameof(TaskSupportAttributeTestTools.SyncToolWithoutTaskSupport))!); - Assert.True( - syncTool.ProtocolTool.Execution is null || - syncTool.ProtocolTool.Execution.TaskSupport is null || - syncTool.ProtocolTool.Execution.TaskSupport == ToolTaskSupport.Forbidden, - "Sync tools without explicit TaskSupport should not support tasks"); - } - - [Theory] - [InlineData(ToolTaskSupport.Forbidden, "\"forbidden\"")] - [InlineData(ToolTaskSupport.Optional, "\"optional\"")] - [InlineData(ToolTaskSupport.Required, "\"required\"")] - public void ToolTaskSupport_SerializesToJsonCorrectly(ToolTaskSupport value, string expectedJson) - { - var json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions); - Assert.Equal(expectedJson, json); - } - - [Theory] - [InlineData("\"forbidden\"", ToolTaskSupport.Forbidden)] - [InlineData("\"optional\"", ToolTaskSupport.Optional)] - [InlineData("\"required\"", ToolTaskSupport.Required)] - public void ToolTaskSupport_DeserializesFromJsonCorrectly(string json, ToolTaskSupport expected) - { - var value = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); - Assert.Equal(expected, value); - } - - [Fact] - public void ToolExecution_TaskSupport_NullByDefault() - { - // Verify that ToolExecution.TaskSupport is null by default - var execution = new ToolExecution(); - Assert.Null(execution.TaskSupport); - - // When serialized with a value, it should appear correctly - var tool = new Tool - { - Name = "test", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - }; - var toolJson = JsonSerializer.Serialize(tool, McpJsonUtilities.DefaultOptions); - Assert.Contains("\"optional\"", toolJson); - } - - [Fact] - public void McpServerToolCreateOptions_Execution_OverridesAutoDetection() - { - // When Execution is set via options, it should override auto-detection - var tool = McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(1, ct); - return input; - }, - new McpServerToolCreateOptions - { - Name = "test", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Forbidden } - }); - - // Even though this is an async method, it should have Forbidden since it was explicitly set - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Forbidden, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void McpServerToolCreateOptions_Execution_Required_SetsCorrectly() - { - var tool = McpServerTool.Create( - (string input) => input, - new McpServerToolCreateOptions - { - Name = "test", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - }); - - Assert.NotNull(tool.ProtocolTool.Execution); - Assert.Equal(ToolTaskSupport.Required, tool.ProtocolTool.Execution.TaskSupport); - } - - [Fact] - public void ToolTaskSupport_EnumValues_AreCorrect() - { - // Verify enum values are as expected (Forbidden = 0) - Assert.Equal(0, (int)ToolTaskSupport.Forbidden); - Assert.Equal(1, (int)ToolTaskSupport.Optional); - Assert.Equal(2, (int)ToolTaskSupport.Required); - } - - [Fact] - public void McpServerToolAttribute_TaskSupport_PublicPropertyDefaultsToForbidden() - { - // Verify that the public property returns Forbidden when not set - var attr = new McpServerToolAttribute(); - Assert.Equal(ToolTaskSupport.Forbidden, attr.TaskSupport); - } -#pragma warning restore MCPEXP001 - - [McpServerToolType] - private static class TaskSupportAttributeTestTools - { -#pragma warning disable MCPEXP001 // Tasks feature is experimental - [McpServerTool(TaskSupport = ToolTaskSupport.Required)] - public static string RequiredTaskTool(string input) => $"Required: {input}"; - - [McpServerTool(TaskSupport = ToolTaskSupport.Optional)] - public static string OptionalTaskTool(string input) => $"Optional: {input}"; - - [McpServerTool(TaskSupport = ToolTaskSupport.Forbidden)] - public static string ForbiddenTaskTool(string input) => $"Forbidden: {input}"; - - [McpServerTool(TaskSupport = ToolTaskSupport.Forbidden)] - public static async Task ForbiddenAsyncTool(string input, CancellationToken ct) - { - await Task.Delay(1, ct); - return $"ForbiddenAsync: {input}"; - } -#pragma warning restore MCPEXP001 - - [McpServerTool] - public static async Task AsyncToolWithoutTaskSupport(string input, CancellationToken ct) - { - await Task.Delay(1, ct); - return $"Async: {input}"; - } - - [McpServerTool] - public static string SyncToolWithoutTaskSupport(string input) => $"Sync: {input}"; - } - - #region Sync Method with Optional/Required TaskSupport Integration Tests - -#pragma warning disable MCPEXP001 // Tasks feature is experimental - [Fact] - public async Task SyncTool_WithOptionalTaskSupport_CanBeCalledAsTask() - { - // Arrange - Server with task store and a sync tool with Optional task support - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([McpServerTool.Create( - (string input) => $"Sync result: {input}", - new McpServerToolCreateOptions - { - Name = "optional-sync-tool", - Description = "A sync tool with optional task support", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - })]); - }, - configureServices: services => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - // Act - Call the sync tool as a task - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "optional-sync-tool", - arguments: new Dictionary { ["input"] = "test" }, - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Task was created successfully - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - } - - [Fact] - public async Task SyncTool_WithRequiredTaskSupport_CanBeCalledAsTask() - { - // Arrange - Server with task store and a sync tool with Required task support - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([McpServerTool.Create( - (string input) => $"Sync result: {input}", - new McpServerToolCreateOptions - { - Name = "required-sync-tool", - Description = "A sync tool with required task support", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - })]); - }, - configureServices: services => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - // Act - Call the sync tool as a task - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "required-sync-tool", - arguments: new Dictionary { ["input"] = "test" }, - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Task was created successfully - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - } - - [Fact] - public async Task SyncTool_WithRequiredTaskSupport_CannotBeCalledDirectly() - { - // Arrange - Server with task store and a sync tool with Required task support - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([McpServerTool.Create( - (string input) => $"Sync result: {input}", - new McpServerToolCreateOptions - { - Name = "required-sync-tool", - Description = "A sync tool with required task support", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } - })]); - }, - configureServices: services => - { - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - // Act & Assert - Calling directly should fail because task execution is required - var exception = await Assert.ThrowsAsync(() => - fixture.Client.CallToolAsync( - "required-sync-tool", - arguments: new Dictionary { ["input"] = "test" }, - cancellationToken: TestContext.Current.CancellationToken).AsTask()); - - // The server returns InvalidParams because direct invocation is not allowed for required-task tools - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - Assert.Contains("task", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task TaskPath_Logs_Tool_Name_On_Successful_Call() - { - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([McpServerTool.Create( - (string input) => $"Result: {input}", - new McpServerToolCreateOptions - { - Name = "task-success-tool", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - })]); - }, - configureServices: services => - { - services.AddSingleton(MockLoggerProvider); - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "task-success-tool", - arguments: new Dictionary { ["input"] = "test" }, - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(mcpTask); - - // Wait for the async task execution to complete - await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-success-tool\" completed. IsError = False."); - Assert.Equal(LogLevel.Information, infoLog.LogLevel); - } - - [Fact] - public async Task TaskPath_Logs_Tool_Name_With_IsError_When_Tool_Returns_Error() - { - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([McpServerTool.Create( - () => new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = "Task tool error" }], - }, - new McpServerToolCreateOptions - { - Name = "task-error-result-tool", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - })]); - }, - configureServices: services => - { - services.AddSingleton(MockLoggerProvider); - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "task-error-result-tool", - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(mcpTask); - - // Wait for the async task execution to complete - await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - var infoLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.Message == "\"task-error-result-tool\" completed. IsError = True."); - Assert.Equal(LogLevel.Information, infoLog.LogLevel); - } - - [Fact] - public async Task TaskPath_Logs_Error_When_Tool_Throws() - { - var taskStore = new InMemoryMcpTaskStore(); - - await using var fixture = new ClientServerFixture( - LoggerFactory, - configureServer: builder => - { - builder.WithTools([McpServerTool.Create( - string () => throw new InvalidOperationException("Task tool error"), - new McpServerToolCreateOptions - { - Name = "task-throw-tool", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } - })]); - }, - configureServices: services => - { - services.AddSingleton(MockLoggerProvider); - services.AddSingleton(taskStore); - services.Configure(options => options.TaskStore = taskStore); - }); - - var mcpTask = await fixture.Client.CallToolAsTaskAsync( - "task-throw-tool", - taskMetadata: new McpTaskMetadata(), - progress: null, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(mcpTask); - - // Wait for the async task execution to complete - await fixture.Client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - var errorLog = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error); - Assert.Equal("\"task-throw-tool\" threw an unhandled exception.", errorLog.Message); - Assert.IsType(errorLog.Exception); - } -#pragma warning restore MCPEXP001 - - #endregion - - /// - /// A fixture that creates a connected MCP client-server pair for testing. - /// - private sealed class ClientServerFixture : IAsyncDisposable - { - private readonly System.IO.Pipelines.Pipe _clientToServerPipe = new(); - private readonly System.IO.Pipelines.Pipe _serverToClientPipe = new(); - private readonly CancellationTokenSource _cts; - private readonly Task _serverTask; - private readonly IServiceProvider _serviceProvider; - - public McpClient Client { get; } - public McpServer Server { get; } - - public ClientServerFixture( - ILoggerFactory loggerFactory, - Action? configureServer, - Action? configureServices = null) - { - ServiceCollection sc = new(); - sc.AddLogging(); - - var builder = sc - .AddMcpServer() - .WithStreamServerTransport(_clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream()); - - configureServer?.Invoke(builder); - configureServices?.Invoke(sc); - - _serviceProvider = sc.BuildServiceProvider(validateScopes: true); - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - - Server = _serviceProvider.GetRequiredService(); - _serverTask = Server.RunAsync(_cts.Token); - - // Create client synchronously by blocking - this is test code - Client = McpClient.CreateAsync( - new StreamClientTransport( - serverInput: _clientToServerPipe.Writer.AsStream(), - _serverToClientPipe.Reader.AsStream(), - loggerFactory), - loggerFactory: loggerFactory, - cancellationToken: TestContext.Current.CancellationToken).GetAwaiter().GetResult(); - } - - public async ValueTask DisposeAsync() - { - await Client.DisposeAsync(); - await _cts.CancelAsync(); - - _clientToServerPipe.Writer.Complete(); - _serverToClientPipe.Writer.Complete(); - - await _serverTask; - - if (_serviceProvider is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - else if (_serviceProvider is IDisposable disposable) - { - disposable.Dispose(); - } - - _cts.Dispose(); - } - } -} From c021d0a4f8b99eb2c8b6d995bedd6e32ede386f5 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:44:01 -0700 Subject: [PATCH 27/50] Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results (#1623) Co-authored-by: Tarek Mahmoud Sayed --- .../Client/McpClient.Methods.cs | 24 ++ .../CompatibilitySuppressions.xml | 28 -- .../McpJsonUtilities.cs | 1 + .../Protocol/CacheScope.cs | 44 +++ .../Protocol/CacheScopeConverter.cs | 70 +++++ .../Protocol/ICacheableResult.cs | 58 ++++ .../Protocol/ListPromptsResult.cs | 12 +- .../Protocol/ListResourceTemplatesResult.cs | 12 +- .../Protocol/ListResourcesResult.cs | 12 +- .../Protocol/ListToolsResult.cs | 12 +- .../Protocol/ReadResourceResult.cs | 12 +- .../Protocol/TimeSpanMillisecondsConverter.cs | 120 +++++++ .../Server/McpServerImpl.cs | 21 ++ tests/Common/Utils/NodeHelpers.cs | 205 ++++++++++-- .../CachingConformanceTests.cs | 139 +++++++++ .../ClientConformanceTests.cs | 2 +- .../ServerConformanceTests.cs | 128 +++----- .../Program.cs | 55 +++- .../CacheableResultClientServerTests.cs | 93 ++++++ .../Protocol/CacheableResultTests.cs | 292 ++++++++++++++++++ 20 files changed, 1193 insertions(+), 147 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/CacheScope.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 6ad56bcef..f3355e76d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -172,6 +172,12 @@ public ValueTask PingAsync( /// The to monitor for cancellation requests. The default is . /// A list of all available tools as instances. /// The request failed or the server returned an error response. + /// + /// This overload aggregates every page into a single list and does not surface the per-result caching hints + /// ( and ). To read those hints, + /// use the overload, which returns the + /// raw for each page. + /// public async ValueTask> ListToolsAsync( RequestOptions? options = null, CancellationToken cancellationToken = default) @@ -256,6 +262,12 @@ public ValueTask ListToolsAsync( /// The to monitor for cancellation requests. The default is . /// A list of all available prompts as instances. /// The request failed or the server returned an error response. + /// + /// This overload aggregates every page into a single list and does not surface the per-result caching hints + /// ( and ). To read those hints, + /// use the overload, which returns the + /// raw for each page. + /// public async ValueTask> ListPromptsAsync( RequestOptions? options = null, CancellationToken cancellationToken = default) @@ -366,6 +378,12 @@ public ValueTask GetPromptAsync( /// The to monitor for cancellation requests. The default is . /// A list of all available resource templates as instances. /// The request failed or the server returned an error response. + /// + /// This overload aggregates every page into a single list and does not surface the per-result caching hints + /// ( and ). To read those hints, + /// use the overload, which returns the + /// raw for each page. + /// public async ValueTask> ListResourceTemplatesAsync( RequestOptions? options = null, CancellationToken cancellationToken = default) @@ -422,6 +440,12 @@ public ValueTask ListResourceTemplatesAsync( /// The to monitor for cancellation requests. The default is . /// A list of all available resources as instances. /// The request failed or the server returned an error response. + /// + /// This overload aggregates every page into a single list and does not surface the per-result caching hints + /// ( and ). To read those hints, + /// use the overload, which returns the + /// raw for each page. + /// public async ValueTask> ListResourcesAsync( RequestOptions? options = null, CancellationToken cancellationToken = default) diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index cb0cffaa8..994399364 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -134,13 +134,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0001 - T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0001 T:ModelContextProtocol.Protocol.ToolExecution @@ -295,13 +288,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0001 - T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0001 T:ModelContextProtocol.Protocol.ToolExecution @@ -456,13 +442,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0001 - T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0001 T:ModelContextProtocol.Protocol.ToolExecution @@ -617,13 +596,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0001 - T:ModelContextProtocol.Protocol.TimeSpanMillisecondsConverter - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0001 T:ModelContextProtocol.Protocol.ToolExecution diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 7006d7221..457e4670c 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -145,6 +145,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(PingResult))] [JsonSerializable(typeof(ReadResourceRequestParams))] [JsonSerializable(typeof(ReadResourceResult))] + [JsonSerializable(typeof(CacheScope))] [JsonSerializable(typeof(SetLevelRequestParams))] [JsonSerializable(typeof(SubscribeRequestParams))] [JsonSerializable(typeof(UnsubscribeRequestParams))] diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScope.cs b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs new file mode 100644 index 000000000..d87cdd7f9 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CacheScope.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Indicates the intended scope of a cached response, analogous to the HTTP +/// Cache-Control: public and Cache-Control: private directives. +/// +/// +/// +/// This is used by to control who may cache a +/// response returned by tools/list, prompts/list, resources/list, +/// resources/templates/list, and resources/read. +/// +/// +/// When the field is absent from a response, clients should treat it as . +/// +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CacheScope +{ + /// + /// The response does not contain user-specific data. Any client, shared gateway, or caching + /// proxy may store and serve the cached response to any user. + /// + /// + /// This is appropriate for lists of tools, prompts, and resource templates that are identical + /// for all users. + /// + [JsonStringEnumMemberName("public")] + Public, + + /// + /// The response contains user-specific data. Only the requesting user's client may cache it. + /// Shared caches (for example, multi-tenant gateways) must not serve the cached response to a + /// different user. + /// + /// + /// This is appropriate for resources/read results that depend on the authenticated user, + /// or for filtered list results that vary per user. + /// + [JsonStringEnumMemberName("private")] + Private +} diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs new file mode 100644 index 000000000..ef61df263 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Serializes caching-scope hints, tolerating unknown or future values on read. +/// +/// +/// +/// SEP-2549 introduces cacheScope as a forward-looking caching hint. If a server sends an +/// unrecognized scope string (for example, a value added in a later revision of the specification) or a +/// non-string token, this converter maps it to rather than throwing. This prevents +/// a single unexpected hint from breaking deserialization of the entire result (for example, the whole +/// tool list). A result is the same as an absent field, which clients treat as +/// . +/// +/// +/// This converter is applied per-property on the cacheable result types. The +/// enum itself retains a standard string converter for any standalone serialization. +/// +/// +internal sealed class CacheScopeConverter : JsonConverter +{ + public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String) + { + string? value = reader.GetString(); + + // Match case-insensitively so a non-conforming casing of "private" (a security-relevant hint) + // is honored rather than falling through to null, which clients would treat as "public" and + // could cache user-specific data in a shared cache. Genuinely unknown values still map to null. + if (string.Equals(value, "public", StringComparison.OrdinalIgnoreCase)) + { + return CacheScope.Public; + } + + if (string.Equals(value, "private", StringComparison.OrdinalIgnoreCase)) + { + return CacheScope.Private; + } + + return null; + } + + // Any non-string token (number, bool, object, array) is an unrecognized hint. Consume the whole + // value, including the contents of an object or array, so the reader is left correctly positioned + // before mapping to null. Skipping is required for container tokens: returning without consuming + // them would leave the reader mispositioned and break deserialization of the enclosing result. + reader.Skip(); + return null; + } + + public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value switch + { + CacheScope.Public => "public", + CacheScope.Private => "private", + _ => throw new JsonException($"Unsupported {nameof(CacheScope)} value: {value}."), + }); + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs new file mode 100644 index 000000000..ecf39e976 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs @@ -0,0 +1,58 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a result that carries time-to-live (TTL) caching hints, allowing clients to cache +/// the response for a period of time before re-fetching. +/// +/// +/// +/// This interface corresponds to the CacheableResult type in the Model Context Protocol +/// schema and is implemented by the results of tools/list, prompts/list, +/// resources/list, resources/templates/list, and resources/read. +/// +/// +/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing +/// list_changed and resources/updated notification mechanisms; both can coexist. A +/// relevant notification invalidates a cached response regardless of any remaining TTL. +/// +/// +public interface ICacheableResult +{ + /// + /// Gets or sets a hint indicating how long the client may cache this response before re-fetching. + /// + /// + /// + /// The semantics are analogous to the HTTP Cache-Control: max-age directive. The value is + /// serialized as an integer number of milliseconds under the ttlMs JSON property. + /// + /// + /// A value of indicates the response should be considered immediately + /// stale; a positive value indicates the client should consider the response fresh for that + /// duration from the time it was received. + /// + /// + /// When this property is (the field was absent from the response), clients + /// should assume a default of (immediately stale) and rely on their + /// own caching heuristics or notifications. The SDK preserves whatever value the server sent and + /// does not coerce it; a client that receives a negative value should treat it as immediately stale. + /// + /// + TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the intended scope of the cached response. + /// + /// + /// + /// When this property is (the field was absent from the response), clients + /// should treat the response as . + /// + /// + /// An unrecognized or future scope value sent by a server (or a non-string value) is tolerated and + /// surfaced as rather than causing deserialization of the whole result to + /// fail, so a single unexpected hint never prevents a client from reading the result. + /// + /// + CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs index 1f648bd5a..a7f26b521 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListPromptsResult : PaginatedResult +public sealed class ListPromptsResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of prompts or prompt templates that the server offers. /// [JsonPropertyName("prompts")] public IList Prompts { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs index 6e422a751..988d6f186 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs @@ -20,7 +20,7 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListResourceTemplatesResult : PaginatedResult +public sealed class ListResourceTemplatesResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of resource templates that the server offers. @@ -32,4 +32,14 @@ public sealed class ListResourceTemplatesResult : PaginatedResult /// [JsonPropertyName("resourceTemplates")] public IList ResourceTemplates { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs index 16d01491c..54c1df601 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListResourcesResult : PaginatedResult +public sealed class ListResourcesResult : PaginatedResult, ICacheableResult { /// /// Gets or sets a list of resources that the server offers. /// [JsonPropertyName("resources")] public IList Resources { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs index a2f03b853..55eed5ddb 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs @@ -18,11 +18,21 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// -public sealed class ListToolsResult : PaginatedResult +public sealed class ListToolsResult : PaginatedResult, ICacheableResult { /// /// Gets or sets the server's response to a tools/list request from the client. /// [JsonPropertyName("tools")] public IList Tools { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs index 084322fde..53e138806 100644 --- a/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol; /// /// See the schema for details. /// -public sealed class ReadResourceResult : Result +public sealed class ReadResourceResult : Result, ICacheableResult { /// /// Gets or sets a list of objects that this resource contains. @@ -20,4 +20,14 @@ public sealed class ReadResourceResult : Result /// [JsonPropertyName("contents")] public IList Contents { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs new file mode 100644 index 000000000..18386d326 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs @@ -0,0 +1,120 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Provides a JSON converter for that serializes as integer milliseconds. +/// +/// +/// This converter serializes TimeSpan values as the total number of milliseconds (as an integer), +/// and deserializes integer millisecond values back to TimeSpan. System.Text.Json automatically +/// handles nullable TimeSpan properties using this converter. Millisecond values that fall outside +/// the range representable by are clamped to +/// / rather than throwing, so an +/// oversized or malformed hint can never break deserialization of the enclosing result. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class TimeSpanMillisecondsConverter : JsonConverter +{ + /// + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Number) + { + if (reader.TryGetInt64(out long milliseconds)) + { + return FromMillisecondsClamped(milliseconds); + } + + // Non-integer value: fractional, or a magnitude too large to represent. Use the non-throwing + // TryGetDouble so an out-of-range exponent never breaks deserialization. Note that different + // runtimes disagree on out-of-range doubles: in-box .NET returns +/-Infinity, whereas .NET + // Framework's parser reports failure. Handle both so behavior is identical everywhere. + if (reader.TryGetDouble(out double value)) + { + if (double.IsPositiveInfinity(value)) + { + return TimeSpan.MaxValue; + } + + if (double.IsNegativeInfinity(value)) + { + return TimeSpan.MinValue; + } + + return FromTicksClamped(value * TimeSpan.TicksPerMillisecond); + } + + // The runtime could not represent the number as a double at all (e.g. .NET Framework on an + // overflowing exponent). Clamp by the sign of the raw token. + return IsNegativeNumberToken(ref reader) ? TimeSpan.MinValue : TimeSpan.MaxValue; + } + + throw new JsonException($"Unable to convert {reader.TokenType} to TimeSpan."); + } + + private static bool IsNegativeNumberToken(ref Utf8JsonReader reader) + { + ReadOnlySpan token = reader.HasValueSequence ? reader.ValueSequence.First.Span : reader.ValueSpan; + return !token.IsEmpty && token[0] == (byte)'-'; + } + + // Largest whole-millisecond count representable as a TimeSpan (TimeSpan.MaxValue.Ticks / TicksPerMillisecond). + private const long MaxWholeMilliseconds = long.MaxValue / TimeSpan.TicksPerMillisecond; + + // Converts an integer millisecond count to a TimeSpan, clamping out-of-range values to + // TimeSpan.MinValue/MaxValue instead of throwing. A malformed or oversized hint (for example a + // hostile or buggy server returning an enormous ttlMs) must never break deserialization of the + // whole result; per SEP-2549 clients should handle unexpected TTL values gracefully. + private static TimeSpan FromMillisecondsClamped(long milliseconds) + { + if (milliseconds > MaxWholeMilliseconds) + { + return TimeSpan.MaxValue; + } + + if (milliseconds < -MaxWholeMilliseconds) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks(milliseconds * TimeSpan.TicksPerMillisecond); + } + + // Converts a (possibly fractional or out-of-range) tick count to a TimeSpan, clamping instead of + // throwing. The caller passes a value already scaled into tick-space (milliseconds * TicksPerMillisecond) + // because TimeSpan is backed by a long tick count, so comparing against long.MaxValue/MinValue is the + // exact test for whether the final (long) cast would overflow. The comparisons MUST run before that cast: + // double arithmetic saturates to +/-Infinity on overflow rather than throwing, and both infinities fall + // into the clamp branches here (+Infinity >= long.MaxValue, -Infinity <= long.MinValue); if Infinity + // instead reached "(long)ticks" the unchecked conversion would silently yield long.MinValue. NaN is not + // reachable from valid JSON (the only multiplicand is a non-zero constant) but is mapped to zero + // defensively so a non-numeric hint can never break deserialization. + private static TimeSpan FromTicksClamped(double ticks) + { + if (double.IsNaN(ticks)) + { + return TimeSpan.Zero; + } + + if (ticks >= long.MaxValue) + { + return TimeSpan.MaxValue; + } + + if (ticks <= long.MinValue) + { + return TimeSpan.MinValue; + } + + return TimeSpan.FromTicks((long)ticks); + } + + /// + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue((long)value.TotalMilliseconds); + } +} diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 85b1cc26a..d6759ac79 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1275,6 +1275,27 @@ private void SetHandler( JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo) { + // SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list, + // resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields. + // When a handler leaves them unset, fill in conservative defaults (immediately stale and not + // shareable) so the wire form always carries the fields while preserving today's "don't cache" + // behavior. Any value supplied by the handler or a filter is left untouched. + if (typeof(ICacheableResult).IsAssignableFrom(typeof(TResult))) + { + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (result is ICacheableResult cacheable) + { + cacheable.TimeToLive ??= TimeSpan.Zero; + cacheable.CacheScope ??= CacheScope.Private; + } + + return result; + }; + } + _requestHandlers.Set(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index ef1686abb..374184cfa 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; namespace ModelContextProtocol.Tests.Utils; @@ -78,16 +80,25 @@ public static void EnsureNpmDependenciesInstalled() /// /// The name of the binary in node_modules/.bin (e.g. "conformance"). /// The arguments to pass to the binary. + /// + /// When (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION + /// environment variable is set, a "--spec-version <value>" argument is appended. + /// Pass for scenarios that pin their own spec version (e.g. the + /// draft-only caching scenario) to avoid a conflicting duplicate flag. + /// /// A configured ProcessStartInfo for running the binary. - public static ProcessStartInfo ConformanceTestStartInfo(string arguments) + public static ProcessStartInfo ConformanceTestStartInfo(string arguments, bool appendProtocolVersionFromEnv = true) { EnsureNpmDependenciesInstalled(); // If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner. - var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); - if (!string.IsNullOrEmpty(protocolVersion)) + if (appendProtocolVersionFromEnv) { - arguments += $" --spec-version {protocolVersion}"; + var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION"); + if (!string.IsNullOrEmpty(protocolVersion)) + { + arguments += $" --spec-version {protocolVersion}"; + } } var repoRoot = FindRepoRoot(); @@ -168,41 +179,197 @@ public static bool IsNodeInstalled() } /// - /// Checks whether the SEP-2243 conformance scenarios are available by reading - /// the conformance package version from the repo's package.json. + /// Checks whether the SEP-2243 conformance scenarios are available, by reading the + /// installed conformance package version from node_modules. /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, - /// http-header-validation, and http-custom-header-server-validation scenarios - /// require a conformance package version that includes SEP-2243 support. + /// http-header-validation, and http-custom-header-server-validation scenarios were + /// introduced in conformance package 0.2.0. Reading the installed version (rather than + /// the pinned version in package.json) means this also returns + /// when a newer private build has been installed locally via + /// npm install --no-save <path-to-conformance>. + /// + public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + + /// + /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance + /// PR #275) is available, by reading the installed conformance package version + /// from node_modules. The caching scenario was introduced in conformance package 0.2.0. + /// Reading the installed version (rather than the pinned version in package.json) means + /// this also returns when a newer private build has been installed + /// locally via npm install --no-save <path-to-conformance>. + /// + public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + + /// + /// Returns when the conformance package installed in node_modules + /// has a version greater than or equal to . /// - public static bool HasSep2243Scenarios() + private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) + { + var version = GetInstalledConformanceVersion(); + return version is not null && version >= minimumVersion; + } + + /// + /// Reads the version of the conformance package actually installed in node_modules, + /// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be + /// parsed as a . Returns if it cannot be + /// determined. + /// + private static Version? GetInstalledConformanceVersion() { try { var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); + var packageJsonPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + + // This is a skip gate for version-conditional conformance scenarios, so it must stay + // side-effect-free. If the conformance package isn't installed, report no version (the + // scenario is simply gated off); the actual scenario run path restores npm dependencies + // separately via ConformanceTestStartInfo. if (!File.Exists(packageJsonPath)) { - return false; + return null; } - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("dependencies", out var deps) && - deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("version", out var versionElement) && + versionElement.GetString() is { } versionStr) { - var versionStr = versionElement.GetString(); - if (versionStr is not null && Version.TryParse(versionStr, out var version)) + // Strip any prerelease/build suffix so System.Version can parse it. + var core = versionStr.Split('-', '+')[0]; + if (Version.TryParse(core, out var version)) { - // SEP-2243 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); + return version; } } - return false; + return null; } catch + { + return null; + } + } + + /// + /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process + /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) + /// shared by the server-side conformance tests. + /// + /// Arguments to pass to the conformance runner. + /// Optional callback invoked for each captured stdout/stderr line. + /// + /// Forwarded to . + /// + /// Token used to cancel the run. + public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync( + string arguments, + Action? onLine = null, + bool appendProtocolVersionFromEnv = true, + CancellationToken cancellationToken = default) + { + var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + using var process = new Process { StartInfo = startInfo }; + + // Protect callbacks with try/catch so a callback that throws on a background thread + // (e.g. ITestOutputHelper after the test completes) does not crash the test host. + DataReceivedEventHandler outputHandler = (sender, e) => + { + if (e.Data != null) + { + try { onLine?.Invoke(e.Data); } catch { } + outputBuilder.AppendLine(e.Data); + } + }; + + DataReceivedEventHandler errorHandler = (sender, e) => + { + if (e.Data != null) + { + try { onLine?.Invoke(e.Data); } catch { } + errorBuilder.AppendLine(e.Data); + } + }; + + process.OutputDataReceived += outputHandler; + process.ErrorDataReceived += errorHandler; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); + try + { +#if NET + await process.WaitForExitAsync(cts.Token); +#else + // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill + // extension and surface a timeout the same way the modern path does. + await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); + if (!process.HasExited) + { + throw new OperationCanceledException(); + } +#endif + } + catch (OperationCanceledException) + { +#if NET + process.Kill(entireProcessTree: true); +#else + process.Kill(); +#endif + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + return ( + false, + outputBuilder.ToString(), + errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."); + } + + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + + var stdoutText = outputBuilder.ToString(); + var stderrText = errorBuilder.ToString(); + + // The Node.js conformance runner can crash during cleanup on Windows with a libuv + // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit + // code even though every conformance check passed. When that happens, fall back to + // parsing the "Test Results:" summary in stdout to decide success. + bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); + + return (success, stdoutText, stderrText); + } + + /// + /// Parses the conformance runner output for a "Test Results:" line such as + /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed + /// and none failed. + /// + private static bool ConformanceOutputIndicatesSuccess(string output) + { + // Match lines like "Passed: 3/3, 0 failed, 0 warnings" + var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); + if (!match.Success) { return false; } + + int passed = int.Parse(match.Groups[1].Value); + int total = int.Parse(match.Groups[2].Value); + int failed = int.Parse(match.Groups[3].Value); + + return passed == total && failed == 0 && total > 0; } /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs new file mode 100644 index 000000000..5cdd2948a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft +/// SEP-2549 "caching" conformance scenario requires. Started on demand (so it is not bound +/// when the caching test is skipped) and torn down via . Uses a +/// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so +/// the two can run in parallel without TCP conflicts. +/// +internal sealed class StatelessConformanceServer : IAsyncDisposable +{ + // Use different ports for each target framework to allow parallel execution across the + // multi-targeted test processes, offset from a caller-supplied base port so independent + // stateless servers (e.g. caching vs. SEP-2243) do not collide. net10.0 -> +0, + // net9.0 -> +1, net8.0 -> +2. + private static int GetPortForTargetFramework(int basePort) + { + var testBinaryDir = AppContext.BaseDirectory; + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + + var offset = targetFramework switch + { + "net10.0" => 0, + "net9.0" => 1, + "net8.0" => 2, + _ => 0 // Default fallback + }; + + return basePort + offset; + } + + private readonly Task _serverTask; + private readonly CancellationTokenSource _serverCts; + + public string ServerUrl { get; } + + private StatelessConformanceServer(string serverUrl, Task serverTask, CancellationTokenSource serverCts) + { + ServerUrl = serverUrl; + _serverTask = serverTask; + _serverCts = serverCts; + } + + public static async Task StartAsync(CancellationToken cancellationToken, int basePort = 3011) + { + var serverUrl = $"http://localhost:{GetPortForTargetFramework(basePort)}"; + var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // "--stateless true" opts this server instance into the SEP-2575 stateless lifecycle + // (see ConformanceServer.Program), without mutating process-wide environment state. + var serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( + ["--urls", serverUrl, "--stateless", "true"], cancellationToken: serverCts.Token)); + + // Wait for the server to be ready (retry for up to 30 seconds). + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + + while (stopwatch.Elapsed < timeout) + { + try + { + await httpClient.GetAsync($"{serverUrl}/health", cancellationToken); + return new StatelessConformanceServer(serverUrl, serverTask, serverCts); + } + catch (HttpRequestException) + { + // Connection refused means the server is not ready yet. + } + catch (TaskCanceledException) + { + // Timeout means the server might be processing; give it more time. + } + + await Task.Delay(500, cancellationToken); + } + + serverCts.Cancel(); + serverCts.Dispose(); + throw new InvalidOperationException("Stateless ConformanceServer failed to start within the timeout period"); + } + + public async ValueTask DisposeAsync() + { + _serverCts.Cancel(); + try + { + await _serverTask.WaitAsync(TestConstants.DefaultTimeout); + } + catch + { + // Ignore exceptions during shutdown. + } + _serverCts.Dispose(); + } +} + +/// +/// Runs the official MCP conformance "caching" scenario (SEP-2549: TTL for List Results, +/// added in conformance PR #275) against the SDK's ConformanceServer, verifying that the SDK +/// correctly emits the ttlMs and cacheScope caching hints on cacheable results +/// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). +/// +/// +/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle. +/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when +/// running against the currently-pinned package, so it activates automatically once a +/// conformance package containing the caching scenario is installed (including a local private +/// build installed via npm install --no-save <path-to-conformance>). The stateless +/// server is started only after the gates pass, so a skipped run binds no port. +/// +public class CachingConformanceTests(ITestOutputHelper output) +{ + [Fact] + public async Task RunCachingConformanceTest() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen( + !NodeHelpers.HasCachingScenario(), + "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); + + await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); + + // The caching scenario only exists in the draft spec, so pin the spec version + // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a + // conflicting duplicate --spec-version flag). + var result = await NodeHelpers.RunServerConformanceAsync( + $"server --url {server.ServerUrl} --scenario caching --spec-version DRAFT-2026-v1", + line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success, + $"SEP-2549 caching conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 7b2be118b..f389ffbba 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -63,7 +63,7 @@ public async Task RunConformanceTest(string scenario) } // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-custom-headers")] [InlineData("http-invalid-tool-headers")] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index ea4187a95..e996ffd1d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -37,8 +35,10 @@ private static int GetPortForTargetFramework() public async ValueTask InitializeAsync() { _serverCts = new CancellationTokenSource(); + // Explicitly pass "--stateless false" so this stateful fixture is immune to a globally + // set MCP_CONFORMANCE_STATELESS environment variable (the command-line switch wins). _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + ["--urls", ServerUrl, "--stateless", "false"], cancellationToken: _serverCts.Token)); // Wait for server to be ready (retry for up to 30 seconds) var timeout = TimeSpan.FromSeconds(30); @@ -139,9 +139,19 @@ public async Task RunPendingConformanceTest_ServerSsePolling() public async Task RunConformanceTest_HttpHeaderValidation() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available."); + Assert.SkipWhen( + !NodeHelpers.HasSep2243Scenarios(), + "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + + // SEP-2243 is a draft (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it + // requires a stateless server (a stateful server rejects the un-initialized list/call + // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with + // the stateful class fixture (300x) or the caching stateless server (301x). + await using var server = await StatelessConformanceServer.StartAsync( + TestContext.Current.CancellationToken, basePort: 3021); - var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -151,9 +161,15 @@ public async Task RunConformanceTest_HttpHeaderValidation() public async Task RunConformanceTest_HttpCustomHeaderServerValidation() { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available."); + Assert.SkipWhen( + !NodeHelpers.HasSep2243Scenarios(), + "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + + await using var server = await StatelessConformanceServer.StartAsync( + TestContext.Current.CancellationToken, basePort: 3024); - var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -189,94 +205,20 @@ public async Task RunMrtrConformanceTest(string scenario) private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { - var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments); - - var outputBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); - - var process = new Process { StartInfo = startInfo }; - - // Protect callbacks with try/catch to prevent ITestOutputHelper from - // throwing on a background thread if events arrive after the test completes. - DataReceivedEventHandler outputHandler = (sender, e) => - { - if (e.Data != null) - { - try { output.WriteLine(e.Data); } catch { } - outputBuilder.AppendLine(e.Data); - } - }; - - DataReceivedEventHandler errorHandler = (sender, e) => - { - if (e.Data != null) - { - try { output.WriteLine(e.Data); } catch { } - errorBuilder.AppendLine(e.Data); - } - }; - - process.OutputDataReceived += outputHandler; - process.ErrorDataReceived += errorHandler; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - try - { - await process.WaitForExitAsync(cts.Token); - } - catch (OperationCanceledException) - { - process.Kill(entireProcessTree: true); - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - return ( - Success: false, - Output: outputBuilder.ToString(), - Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed." - ); - } - - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - - var stdoutText = outputBuilder.ToString(); - var stderrText = errorBuilder.ToString(); - - // The Node.js conformance runner can crash during cleanup on Windows with a libuv - // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit - // code even though every conformance check passed. When that happens, fall back to - // parsing the "Test Results:" summary in stdout to decide success. - bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); - - return ( - Success: success, - Output: stdoutText, - Error: stderrText - ); + return await NodeHelpers.RunServerConformanceAsync( + arguments, + line => { try { output.WriteLine(line); } catch { } }, + cancellationToken: TestContext.Current.CancellationToken); } - /// - /// Parses the conformance runner output for a "Test Results:" line such as - /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed - /// and none failed. - /// - private static bool ConformanceOutputIndicatesSuccess(string output) + // For draft scenarios that pin --spec-version explicitly, suppress the + // MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended. + private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments) { - // Match lines like "Passed: 3/3, 0 failed, 0 warnings" - var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); - if (!match.Success) - { - return false; - } - - int passed = int.Parse(match.Groups[1].Value); - int total = int.Parse(match.Groups[2].Value); - int failed = int.Parse(match.Groups[3].Value); - - return passed == total && failed == 0 && total > 0; + return await NodeHelpers.RunServerConformanceAsync( + arguments, + line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); } } diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index f30d58a4d..9416ec8db 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -25,10 +25,25 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // because .NET does not have a built-in concurrent HashSet ConcurrentDictionary> subscriptions = new(); + // Allow running the server in the SEP-2575 stateless lifecycle, which the draft + // "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" + // command-line switch (read via configuration) takes precedence so an in-process test + // fixture can opt in or out per-instance deterministically; when it is not supplied, + // fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs. + // The default (no switch, no env var) remains the stateful server that serves the + // active conformance suite unchanged. + var statelessConfig = builder.Configuration["stateless"]; + var stateless = statelessConfig is not null + ? string.Equals(statelessConfig, "true", StringComparison.OrdinalIgnoreCase) + : string.Equals( + Environment.GetEnvironmentVariable("MCP_CONFORMANCE_STATELESS"), + "true", + StringComparison.OrdinalIgnoreCase); + builder.Services.AddDistributedMemoryCache(); builder.Services .AddMcpServer() - .WithHttpTransport() + .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() .WithTools() .WithTools() @@ -45,6 +60,44 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide await request.EnablePollingAsync(TimeSpan.FromMilliseconds(500), cancellationToken); } + return result; + }) + // SEP-2549: advertise TTL/cacheScope caching hints on cacheable results. The + // conformance server's tools, prompts, resources, and resource templates are the + // same for every caller, so they are cacheable with a "public" scope. + .AddListToolsFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListPromptsFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListResourcesFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddListResourceTemplatesFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(5); + result.CacheScope = CacheScope.Public; + return result; + }) + .AddReadResourceFilter(next => async (request, cancellationToken) => + { + var result = await next(request, cancellationToken); + result.TimeToLive = TimeSpan.FromMinutes(1); + result.CacheScope = CacheScope.Public; return result; })) .WithPrompts() diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs new file mode 100644 index 000000000..f460172d5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// End-to-end tests verifying that SEP-2549 caching hints set by a server on cacheable results +/// are observed by a connected client. +/// +public class CacheableResultClientServerTests(ITestOutputHelper testOutputHelper) + : ClientServerTestBase(testOutputHelper) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult + { + Tools = [new Tool { Name = "echo" }], + TimeToLive = TimeSpan.FromMinutes(5), + CacheScope = CacheScope.Public, + })) + .WithListPromptsHandler((_, _) => new ValueTask(new ListPromptsResult + { + Prompts = [new Prompt { Name = "greet" }], + })) + .WithListResourcesHandler((_, _) => new ValueTask(new ListResourcesResult + { + Resources = [new Resource { Uri = "test://resource", Name = "resource" }], + })) + .WithReadResourceHandler((request, _) => new ValueTask(new ReadResourceResult + { + Contents = [new TextResourceContents { Uri = request.Params!.Uri!, Text = "hi" }], + TimeToLive = TimeSpan.FromSeconds(30), + CacheScope = CacheScope.Private, + })); + } + + [Fact] + public async Task ListTools_PropagatesCachingHints_ToClient() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListToolsAsync( + new ListToolsRequestParams(), + TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.FromMinutes(5), result.TimeToLive); + Assert.Equal(CacheScope.Public, result.CacheScope); + } + + [Fact] + public async Task ReadResource_PropagatesCachingHints_ToClient() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ReadResourceAsync( + "test://resource", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive); + Assert.Equal(CacheScope.Private, result.CacheScope); + } + + [Fact] + public async Task ListPrompts_WhenHandlerOmitsHints_ServerInjectsConservativeDefaults() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListPromptsAsync( + new ListPromptsRequestParams(), + TestContext.Current.CancellationToken); + + // SEP-2549: the handler left the hints unset, so the server fills in conservative defaults + // (immediately stale, not shareable) rather than omitting the now-required fields. + Assert.Equal(TimeSpan.Zero, result.TimeToLive); + Assert.Equal(CacheScope.Private, result.CacheScope); + } + + [Fact] + public async Task ListResources_WhenHandlerOmitsHints_ServerInjectsConservativeDefaults() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListResourcesAsync( + new ListResourcesRequestParams(), + TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.Zero, result.TimeToLive); + Assert.Equal(CacheScope.Private, result.CacheScope); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs new file mode 100644 index 000000000..aba38bfa0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs @@ -0,0 +1,292 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Tests for the SEP-2549 caching hints (ttlMs and cacheScope) carried by +/// implementations: the results of tools/list, +/// prompts/list, resources/list, resources/templates/list, and +/// resources/read. +/// +public static class CacheableResultTests +{ + public static IEnumerable CacheableResultTypes() + { + yield return new object[] { typeof(ListToolsResult) }; + yield return new object[] { typeof(ListPromptsResult) }; + yield return new object[] { typeof(ListResourcesResult) }; + yield return new object[] { typeof(ListResourceTemplatesResult) }; + yield return new object[] { typeof(ReadResourceResult) }; + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_SerializesTtlMsAsIntegerMilliseconds(Type type) + { + var result = (ICacheableResult)Activator.CreateInstance(type)!; + result.TimeToLive = TimeSpan.FromMilliseconds(300_000); + result.CacheScope = CacheScope.Public; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind()); + Assert.Equal(300_000, node["ttlMs"]!.GetValue()); + Assert.Equal("public", node["cacheScope"]!.GetValue()); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_PrivateScope_RoundTrips(Type type) + { + var result = (ICacheableResult)Activator.CreateInstance(type)!; + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // A TTL of zero is meaningful (immediately stale) and must still be emitted. + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(0, node["ttlMs"]!.GetValue()); + Assert.Equal("private", node["cacheScope"]!.GetValue()); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_OmitsCachingHints_WhenUnset(Type type) + { + object result = Activator.CreateInstance(type)!; + + string json = JsonSerializer.Serialize(result, type, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // Backward compatibility: servers that do not set the hints must not emit them. + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesMissingHints_AsNull(Type type) + { + // A response from a server that predates SEP-2549 contains neither field. + string json = "{}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesNegativeTtl(Type type) + { + // Per SEP-2549, a negative ttlMs is preserved on the DTO; callers SHOULD treat it as zero. + string json = "{\"ttlMs\":-5,\"cacheScope\":\"public\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(-5), deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesOversizedTtl_ClampsInsteadOfThrowing(Type type) + { + // A hostile or buggy server could return a ttlMs that is a valid JSON integer but exceeds the + // range representable by TimeSpan. Deserialization must not throw (which would break reading the + // entire list); the value is clamped to TimeSpan.MaxValue instead. + string json = "{\"ttlMs\":9999999999999999,\"cacheScope\":\"public\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.MaxValue, deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesLargeNegativeTtl_ClampsToMinValue(Type type) + { + string json = "{\"ttlMs\":-9999999999999999}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.MinValue, deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesMaxRepresentableTtl_DoesNotThrow(Type type) + { + // The largest whole-millisecond count that fits in a TimeSpan must round-trip without clamping. + long maxWholeMs = long.MaxValue / TimeSpan.TicksPerMillisecond; + string json = $"{{\"ttlMs\":{maxWholeMs}}}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromTicks(maxWholeMs * TimeSpan.TicksPerMillisecond), deserialized.TimeToLive); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesHugeFloatTtl_ClampsInsteadOfThrowing(Type type) + { + // A fractional/exponent ttlMs whose tick count overflows to +Infinity is clamped to MaxValue + // rather than throwing. (1e400 is beyond double range, so GetDouble() itself returns +Infinity.) + Assert.Equal( + TimeSpan.MaxValue, + DeserializeTtl(type, "{\"ttlMs\":1e400}")); + + // 1e308 is finite but overflows once scaled into tick-space. + Assert.Equal( + TimeSpan.MaxValue, + DeserializeTtl(type, "{\"ttlMs\":1e308}")); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesNegativeInfinityFloatTtl_ClampsToMinValue(Type type) + { + // A large negative exponent ttlMs yields -Infinity from GetDouble(); it must clamp to MinValue, + // not silently become long.MinValue ticks via the cast. + Assert.Equal( + TimeSpan.MinValue, + DeserializeTtl(type, "{\"ttlMs\":-1e400}")); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesUnknownCacheScope_AsNull(Type type) + { + // A future/unknown cacheScope string must not break deserialization of the entire result; it is + // tolerated and surfaced as null (equivalent to an absent field, which clients treat as public). + // Non-string tokens, including objects and arrays, must likewise be tolerated and fully consumed. + foreach (string scope in new[] { "\"shared\"", "\"\"", "123", "true", "null", "{}", "[]", "{\"a\":1}", "[1,2]" }) + { + string json = $"{{\"cacheScope\":{scope}}}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.CacheScope); + } + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesCacheScope_CaseInsensitively(Type type) + { + // Casing of the security-relevant "private" hint must be honored rather than silently dropped to + // null (which clients treat as public), so matching is case-insensitive on read. + foreach (string scope in new[] { "PUBLIC", "Public", "pUbLiC" }) + { + var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(CacheScope.Public, result.CacheScope); + } + + foreach (string scope in new[] { "PRIVATE", "Private", "pRiVaTe" }) + { + var result = (ICacheableResult)JsonSerializer.Deserialize($"{{\"cacheScope\":\"{scope}\"}}", type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(CacheScope.Private, result.CacheScope); + } + } + + private static TimeSpan? DeserializeTtl(Type type, string json) => + ((ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!).TimeToLive; + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesFractionalTtl(Type type) + { + string json = "{\"ttlMs\":1.5}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromTicks((long)(1.5 * TimeSpan.TicksPerMillisecond)), deserialized.TimeToLive); + } + + [Fact] + public static void CacheScope_SerializesAsLowercaseStrings() + { + Assert.Equal("\"public\"", JsonSerializer.Serialize(CacheScope.Public, McpJsonUtilities.DefaultOptions)); + Assert.Equal("\"private\"", JsonSerializer.Serialize(CacheScope.Private, McpJsonUtilities.DefaultOptions)); + Assert.Equal(CacheScope.Public, JsonSerializer.Deserialize("\"public\"", McpJsonUtilities.DefaultOptions)); + Assert.Equal(CacheScope.Private, JsonSerializer.Deserialize("\"private\"", McpJsonUtilities.DefaultOptions)); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesTtlWithoutCacheScope(Type type) + { + // ttlMs present, cacheScope absent: the SEP says an absent scope defaults to "public", + // but the SDK only propagates the wire value, so the DTO reports null (caller applies default). + string json = "{\"ttlMs\":1000}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromSeconds(1), deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_DeserializesCacheScopeWithoutTtl(Type type) + { + // cacheScope present, ttlMs absent: a server may classify cacheability without a freshness hint. + string json = "{\"cacheScope\":\"private\"}"; + + var deserialized = (ICacheableResult)JsonSerializer.Deserialize(json, type, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Theory] + [MemberData(nameof(CacheableResultTypes))] + public static void CacheableResult_PaginatedPages_CarryIndependentCachingHints(Type type) + { + // SEP-2549: each paginated page independently carries its own ttlMs/cacheScope. + // Two result instances representing consecutive pages must round-trip distinct hints. + var page1 = (ICacheableResult)Activator.CreateInstance(type)!; + page1.TimeToLive = TimeSpan.FromMinutes(10); + page1.CacheScope = CacheScope.Public; + + var page2 = (ICacheableResult)Activator.CreateInstance(type)!; + page2.TimeToLive = TimeSpan.FromSeconds(5); + page2.CacheScope = CacheScope.Private; + + var rt1 = (ICacheableResult)JsonSerializer.Deserialize( + JsonSerializer.Serialize(page1, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!; + var rt2 = (ICacheableResult)JsonSerializer.Deserialize( + JsonSerializer.Serialize(page2, type, McpJsonUtilities.DefaultOptions), type, McpJsonUtilities.DefaultOptions)!; + + Assert.Equal(TimeSpan.FromMinutes(10), rt1.TimeToLive); + Assert.Equal(CacheScope.Public, rt1.CacheScope); + Assert.Equal(TimeSpan.FromSeconds(5), rt2.TimeToLive); + Assert.Equal(CacheScope.Private, rt2.CacheScope); + } + + [Fact] + public static void CacheableResult_MaxValueTtl_WriteThenRead_IsStableAcrossRoundTrips() + { + // Writing TimeSpan.MaxValue truncates the sub-millisecond remainder to a whole-millisecond + // integer (922337203685477 ms), so the first round-trip is slightly less than MaxValue. + // Critically, once written this value is a fixed point: further round-trips do not drift. + var first = RoundTrip(new ListToolsResult { TimeToLive = TimeSpan.MaxValue }); + var second = RoundTrip(new ListToolsResult { TimeToLive = first.TimeToLive }); + + Assert.NotEqual(TimeSpan.MaxValue, first.TimeToLive); + Assert.Equal(first.TimeToLive, second.TimeToLive); + } + + private static ListToolsResult RoundTrip(ListToolsResult result) => + JsonSerializer.Deserialize( + JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)!; +} From 0663b7c878774ebcb8269441ae503c070e7f3ffc Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:02:04 -0400 Subject: [PATCH 28/50] Relax outputSchema to any JSON Schema 2020-12 document per SEP-2106 (#1568) Co-authored-by: Mike Kistler --- .../McpJsonUtilities.cs | 11 + .../McpSessionHandler.cs | 24 + .../Protocol/Tool.cs | 23 +- .../Server/AIFunctionMcpServerTool.cs | 149 +++++-- .../Server/McpServerImpl.cs | 15 +- .../Protocol/ToolTests.cs | 47 ++ .../Server/McpServerToolTests.cs | 416 ++++++++---------- .../Server/Sep2106ListToolsBackCompatTests.cs | 414 +++++++++++++++++ 8 files changed, 839 insertions(+), 260 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 457e4670c..414d0dafc 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -84,6 +84,17 @@ internal static bool IsValidMcpToolSchema(JsonElement element) return false; // No type keyword found. } + // Per SEP-2106, a tool's outputSchema may be any valid JSON Schema document — not just + // schemas with type:"object". Validation is therefore reduced to a structural check + // matching JSON Schema 2020-12: a schema may be either a JSON object (the usual form + // with keywords like "type", "properties", etc.) or a boolean (`true` matches anything, + // `false` matches nothing). Stricter keyword-level validation is intentionally not + // performed. Pre-2026-06-30 clients still receive the legacy wrapped wire shape — that + // wiring lives in AIFunctionMcpServerTool.CreateStructuredResponse and McpServerImpl's + // listToolsHandler. + internal static bool IsValidToolOutputSchema(JsonElement element) => + element.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False; + // Keep in sync with CreateDefaultOptions above. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index e874e6724..00f2231d6 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -72,6 +72,30 @@ internal static bool SupportsPrimingEvent(string? protocolVersion) return string.Compare(protocolVersion, MinResumabilityProtocolVersion, StringComparison.Ordinal) >= 0; } + /// + /// Checks whether the negotiated protocol version permits emitting non-object output + /// schemas and their structured content in their natural shape (per SEP-2106). + /// + /// The negotiated protocol version, or null if + /// negotiation has not completed. + /// true if the version is "2026-06-30" or later (including the + /// in-flight "DRAFT-2026-06-v1", since 'D' > '2' ordinally); false + /// otherwise. A false return signals that the wire emission boundary must apply + /// the {"result": <value>} envelope expected by clients on protocol versions + /// that pre-date SEP-2106's widening of outputSchema to any JSON Schema 2020-12 + /// document. + internal static bool SupportsNaturalOutputSchemas(string? protocolVersion) + { + const string MinNaturalOutputSchemasProtocolVersion = "2026-06-30"; + + if (protocolVersion is null) + { + return false; + } + + return string.Compare(protocolVersion, MinNaturalOutputSchemasProtocolVersion, StringComparison.Ordinal) >= 0; + } + private readonly bool _isServer; private readonly string _transportKind; private readonly ITransport _transport; diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 8974e3bb5..274d53be3 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -81,17 +81,24 @@ public JsonElement InputSchema } = McpJsonUtilities.DefaultMcpToolSchema; /// - /// Gets or sets a JSON Schema object defining the expected structured outputs for the tool. + /// Gets or sets a JSON Schema document describing the shape of the tool's structured output. /// - /// The value is not a valid MCP tool JSON schema. + /// + /// The value is not a valid JSON Schema 2020-12 document — i.e., not a JSON object or a + /// JSON boolean. + /// /// /// - /// The schema must be a valid JSON Schema object with the "type" property set to "object". - /// This is enforced by validation in the setter which will throw an - /// if an invalid schema is provided. + /// Per SEP-2106 ("Allow valid JSON Schemas in outputSchema"), the schema may describe + /// any JSON value — object, array, string, number, boolean, or — to + /// support tools whose structured output is not an object. The setter only checks that the + /// supplied value is a structurally valid JSON Schema 2020-12 document (a JSON object, or + /// the boolean schemas true/false per §4.3); deeper keyword-level validation + /// is intentionally not performed. /// /// - /// The schema should describe the shape of the data as returned in . + /// The schema describes the shape of the value placed in . + /// Unlike , the top-level type is not required to be "object". /// /// [JsonPropertyName("outputSchema")] @@ -100,9 +107,9 @@ public JsonElement? OutputSchema get => field; set { - if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value)) + if (value is not null && !McpJsonUtilities.IsValidToolOutputSchema(value.Value)) { - throw new ArgumentException("The specified document is not a valid MCP tool output JSON schema.", nameof(OutputSchema)); + throw new ArgumentException("The specified document is not a valid JSON Schema 2020-12 document (must be a JSON object or a JSON boolean).", nameof(OutputSchema)); } field = value; diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index ae9c3ca45..c1c4fe664 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -13,7 +13,6 @@ namespace ModelContextProtocol.Server; /// Provides an that's implemented via an . internal sealed partial class AIFunctionMcpServerTool : McpServerTool { - private readonly bool _structuredOutputRequiresWrapping; private readonly IReadOnlyList _metadata; /// @@ -120,7 +119,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Name = options?.Name ?? function.Name, Description = GetToolDescription(function, options), InputSchema = function.JsonSchema, - OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping), + OutputSchema = CreateOutputSchema(function, options), Icons = options?.Icons, }; @@ -156,7 +155,7 @@ options.OpenWorld is not null || options.Meta; } - return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); + return new AIFunctionMcpServerTool(function, tool, options?.Services, options?.Metadata ?? []); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -218,14 +217,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe internal AIFunction AIFunction { get; } /// Initializes a new instance of the class. - private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList metadata) + private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, IReadOnlyList metadata) { ValidateToolName(tool.Name); AIFunction = function; ProtocolTool = tool; - _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; _metadata = metadata; } @@ -235,6 +233,41 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider /// public override IReadOnlyList Metadata => _metadata; + /// + /// Returns a clone whose is rewritten + /// into the wire shape required by clients on protocol versions older than + /// "2026-06-30". Those versions require outputSchema.type == "object"; + /// SEP-2106 (negotiated at "2026-06-30" and later) widens that to any JSON + /// Schema 2020-12 document. To stay compatible, non-object schemas are wrapped in + /// {"type":"object","properties":{"result":<schema>}} and the + /// type:["object","null"] array form is normalized to plain "object" + /// before emission. Returns unchanged when there is no + /// output schema. Callers must gate the call on the negotiated version — this method + /// is unconditional; the gate lives at the emission site. + /// + internal Tool BuildLegacyWireProtocolTool() + { + if (ProtocolTool.OutputSchema is not { } natural) + { + return ProtocolTool; + } + + JsonElement legacyOutputSchema = TransformOutputSchemaForLegacyWire(natural); + + return new Tool + { + Name = ProtocolTool.Name, + Title = ProtocolTool.Title, + Description = ProtocolTool.Description, + InputSchema = ProtocolTool.InputSchema, + OutputSchema = legacyOutputSchema, + Annotations = ProtocolTool.Annotations, + Execution = ProtocolTool.Execution, + Icons = ProtocolTool.Icons, + Meta = ProtocolTool.Meta, + }; + } + /// public override async ValueTask InvokeAsync( RequestContext request, CancellationToken cancellationToken = default) @@ -256,7 +289,7 @@ public override async ValueTask InvokeAsync( object? result; result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); - JsonElement? structuredContent = CreateStructuredResponse(result); + JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion); return result switch { AIContent aiContent => new() @@ -468,48 +501,102 @@ schema.ValueKind is not JsonValueKind.Object || return descriptionElement.GetString(); } - private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping) + private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions) { - structuredOutputRequiresWrapping = false; - if (toolCreateOptions?.UseStructuredContent is not true) { return null; } + // Per SEP-2106, any valid JSON Schema document is acceptable for outputSchema — + // arrays, primitives, compositions, and nullable types pass through unchanged. // Explicit OutputSchema takes precedence over AIFunction's return schema. - JsonElement outputSchema; + // Back-compat for pre-2026-06-30 clients is applied at the wire emission sites + // (CreateStructuredResponse for tools/call, listToolsHandler for tools/list). if (toolCreateOptions.OutputSchema is { } explicitSchema) { - outputSchema = explicitSchema; + return explicitSchema; } - else if (function.ReturnJsonSchema is { } returnSchema) + + if (function.ReturnJsonSchema is { } returnSchema) { - outputSchema = returnSchema; + return returnSchema; } - else + + return null; + } + + /// + /// Returns iff the structured-content value must be wrapped in + /// the {"result": <value>} envelope on the wire — i.e., the output schema + /// is neither plain object-typed (type:"object") nor the + /// type:["object","null"] array form. Used by + /// to decide whether to apply the envelope when emitting to a client that negotiated a + /// protocol version older than "2026-06-30" (those versions pre-date SEP-2106's + /// allowance of non-object output schemas). The inner type:["object","null"] + /// check is hoisted into a named bool to keep the surrounding control flow free of + /// empty branches. + /// + internal static bool ShouldWrapValueForLegacyWire(JsonElement schema) + { + bool structuredOutputRequiresWrapping = false; + + if (schema.ValueKind is not JsonValueKind.Object || + !schema.TryGetProperty("type", out JsonElement typeProperty) || + typeProperty.ValueKind is not JsonValueKind.String || + typeProperty.GetString() is not "object") { - return null; + JsonNode? schemaNode = JsonSerializer.SerializeToNode(schema, McpJsonUtilities.JsonContext.Default.JsonElement); + + bool isNullableObjectArray = + schemaNode is JsonObject objSchema && + objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && + typeNode is JsonArray { Count: 2 } typeArray && + typeArray.Any(type => (string?)type is "object") && + typeArray.Any(type => (string?)type is "null"); + + if (!isNullableObjectArray) + { + structuredOutputRequiresWrapping = true; + } } - if (outputSchema.ValueKind is not JsonValueKind.Object || - !outputSchema.TryGetProperty("type", out JsonElement typeProperty) || + return structuredOutputRequiresWrapping; + } + + /// + /// Transforms into the wire shape required by clients + /// on protocol versions older than "2026-06-30": non-object schemas are wrapped + /// in {"type":"object","properties":{"result":<schema>},"required":["result"]}, + /// the type:["object","null"] array form is normalized to plain "object", + /// and plain object-typed schemas pass through unchanged. SEP-2106 clients + /// ("2026-06-30"+) see the natural schema and never need this transform. + /// Dispatches on so the wrap decision lives + /// in one place. + /// + /// The natural JSON Schema 2020-12 document. + internal static JsonElement TransformOutputSchemaForLegacyWire(JsonElement naturalSchema) + { + if (naturalSchema.ValueKind is not JsonValueKind.Object || + !naturalSchema.TryGetProperty("type", out JsonElement typeProperty) || typeProperty.ValueKind is not JsonValueKind.String || typeProperty.GetString() is not "object") { - // If the output schema is not an object, need to modify to be a valid MCP output schema. - JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + JsonNode? schemaNode = JsonSerializer.SerializeToNode(naturalSchema, McpJsonUtilities.JsonContext.Default.JsonElement); if (schemaNode is JsonObject objSchema && objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && - typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null")) + typeNode is JsonArray { Count: 2 } typeArray && + typeArray.Any(type => (string?)type is "object") && + typeArray.Any(type => (string?)type is "null")) { - // For schemas that are of type ["object", "null"], replace with just "object" to be conformant. + // type:["object","null"] → normalize to plain "object". No envelope. objSchema["type"] = "object"; } else { - // For anything else, wrap the schema in an envelope with a "result" property. + // Anything else (string, integer, array, boolean schemas, missing type, + // compositions). Wrap in the {"result": } envelope. schemaNode = new JsonObject { ["type"] = "object", @@ -524,14 +611,12 @@ typeProperty.ValueKind is not JsonValueKind.String || // paths (e.g., "#/items/..." or "#") are now invalid because the original schema // has moved under "#/properties/result". Rewrite them to account for the new location. RewriteRefPointers(schemaNode["properties"]!["result"]); - - structuredOutputRequiresWrapping = true; } - outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); + return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); } - return outputSchema; + return naturalSchema; } /// @@ -580,7 +665,7 @@ private static void RewriteRefPointers(JsonNode? node) } } - private JsonElement? CreateStructuredResponse(object? aiFunctionResult) + private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion) { if (ProtocolTool.OutputSchema is null) { @@ -596,10 +681,14 @@ private static void RewriteRefPointers(JsonNode? node) _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), }; - if (_structuredOutputRequiresWrapping) + // Pre-SEP-2106 clients expect the {"result": } envelope for non-object + // schemas. SEP-2106 clients see the natural shape. The classification is decided + // fresh per request from the stored natural schema. + if (!McpSessionHandler.SupportsNaturalOutputSchemas(negotiatedProtocolVersion) && + ShouldWrapValueForLegacyWire(ProtocolTool.OutputSchema.Value)) { - JsonNode? resultNode = elementResult is { } je - ? JsonSerializer.SerializeToNode(je, McpJsonUtilities.JsonContext.Default.JsonElement) + JsonNode? resultNode = elementResult is { } v + ? JsonSerializer.SerializeToNode(v, McpJsonUtilities.JsonContext.Default.JsonElement) : null; return JsonSerializer.SerializeToElement(new JsonObject { diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index d6759ac79..c66b24c79 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -825,9 +825,22 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) if (request.Params?.Cursor is null) { + // SEP-2106 wire shaping: clients on protocol versions older than + // 2026-06-30 require outputSchema.type == "object", so the natural + // schema is reshaped before emission (type:["object","null"] normalized + // to "object", any other non-object schema wrapped in + // {"type":"object","properties":{"result":}}). Clients on + // 2026-06-30+ receive the natural JSON Schema 2020-12 document stored + // on Tool.OutputSchema. Only AIFunctionMcpServerTool tools go through + // reshaping; custom McpServerTool subclasses build their Tool directly + // and pass through unchanged at every protocol version. + bool useNaturalSchemas = McpSessionHandler.SupportsNaturalOutputSchemas(request.Server.NegotiatedProtocolVersion); foreach (var t in tools) { - result.Tools.Add(t.ProtocolTool); + Tool wireTool = useNaturalSchemas || t is not AIFunctionMcpServerTool aiFunctionTool + ? t.ProtocolTool + : aiFunctionTool.BuildLegacyWireProtocolTool(); + result.Tools.Add(wireTool); } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs index d8335c055..150f6d307 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs @@ -147,4 +147,51 @@ public static void ToolInputSchema_AcceptsValidSchemaDocuments(string validSchem Assert.True(JsonElement.DeepEquals(document.RootElement, tool.InputSchema)); } + + [Theory] + [InlineData("null")] + [InlineData("3.5e3")] + [InlineData("[]")] + [InlineData("\"a-string\"")] + public static void ToolOutputSchema_RejectsInvalidJsonSchemaDocuments(string invalidSchema) + { + // Per SEP-2106 / JSON Schema 2020-12 §4.3, a schema document is either a JSON object + // or a boolean (true/false). Other JSON values — null literals, numbers, strings, + // arrays — are not valid schema documents and are rejected. + using var document = JsonDocument.Parse(invalidSchema); + var tool = new Tool { Name = "test" }; + + Assert.Throws(() => tool.OutputSchema = document.RootElement); + } + + [Theory] + [InlineData("""{"type":"object"}""")] + [InlineData("""{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}""")] + [InlineData("""{"type":"array","items":{"type":"integer"}}""")] + [InlineData("""{"type":"string"}""")] + [InlineData("""{"type":"number"}""")] + [InlineData("""{"type":"integer","minimum":0}""")] + [InlineData("""{"type":"boolean"}""")] + [InlineData("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""")] + [InlineData("""{}""")] + [InlineData("""{"oneOf":[{"type":"string"},{"type":"integer"}]}""")] + [InlineData("true")] + [InlineData("false")] + public static void ToolOutputSchema_AcceptsAnyValidJsonSchemaDocument(string validSchema) + { + // Per SEP-2106, OutputSchema accepts any valid JSON Schema 2020-12 document — JSON + // objects (with arrays, primitives, compositions, nullable types) plus the boolean + // schemas `true` (matches any value) and `false` (matches nothing). The `true` form + // also appears organically as the auto-derived schema for an unconstrained `object` + // return type. + using var document = JsonDocument.Parse(validSchema); + Tool tool = new() + { + Name = "test", + OutputSchema = document.RootElement, + }; + + Assert.NotNull(tool.OutputSchema); + Assert.True(JsonElement.DeepEquals(document.RootElement, tool.OutputSchema.Value)); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index a499b833b..e11152301 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -461,15 +461,22 @@ public async Task SupportsSchemaCreateOptions() [MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))] public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) { + // Per SEP-2106 the output schema's top-level "type" matches the natural shape of the + // return value (e.g. "string", "integer", "array") rather than always being "object". + // The strict round-trip check is AssertMatchesJsonSchema below, which proves the + // emitted structuredContent validates against the published schema. + // + // Pinned to a SEP-2106 negotiated version because the assertion compares the natural + // in-memory schema against the emitted value. Under a legacy negotiated version the + // emitted value would be re-wrapped in {"result": } for backward compatibility + // and would no longer validate against the natural schema. JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); - var mockServer = new Mock(); - var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new CallToolRequestParams { Name = "tool" }); + var request = CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion); var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); Assert.NotNull(result.StructuredContent); AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); } @@ -594,9 +601,11 @@ public void OutputSchema_Options_RequiresUseStructuredContent() } [Fact] - public void OutputSchema_Options_NonObjectSchema_GetsWrapped() + public void OutputSchema_Options_NonObjectSchema_PassesThrough() { - // Non-object output schema should be wrapped in a "result" property envelope + // Per SEP-2106, outputSchema may be any valid JSON Schema document — including + // non-object schemas. The SDK no longer wraps non-object schemas in a + // {"type":"object","properties":{"result":}} envelope. JsonElement outputSchema = JsonDocument.Parse("""{"type":"string"}""").RootElement; McpServerTool tool = McpServerTool.Create(() => "result", new() { @@ -605,16 +614,15 @@ public void OutputSchema_Options_NonObjectSchema_GetsWrapped() }); Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); - Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("result", out var resultProp)); - Assert.Equal("string", resultProp.GetProperty("type").GetString()); + Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _)); } [Fact] - public void OutputSchema_Options_NullableObjectSchema_BecomesObject() + public void OutputSchema_Options_NullableObjectSchema_PassesThrough() { - // ["object", "null"] type should be simplified to just "object" + // Per SEP-2106, the SDK no longer normalizes ["object","null"] type-arrays down + // to just "object". The schema author's intent is preserved on the wire. JsonElement outputSchema = JsonDocument.Parse("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement; McpServerTool tool = McpServerTool.Create(() => "result", new() { @@ -623,7 +631,177 @@ public void OutputSchema_Options_NullableObjectSchema_BecomesObject() }); Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + var typeProperty = tool.ProtocolTool.OutputSchema.Value.GetProperty("type"); + Assert.Equal(JsonValueKind.Array, typeProperty.ValueKind); + Assert.Collection(typeProperty.EnumerateArray(), + t => Assert.Equal("object", t.GetString()), + t => Assert.Equal("null", t.GetString())); + } + + [Fact] + public void OutputSchema_Create_StringReturn_NoEnvelope() + { + // End-to-end check: a tool with a string return type and UseStructuredContent + // produces an outputSchema describing the string directly (no "result" envelope) + // and emits the raw string value as structuredContent. + McpServerTool tool = McpServerTool.Create(() => "hello", new() { UseStructuredContent = true }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _)); + } + + // SEP-2106 backward-compat: for clients negotiating a pre-2026-06-30 protocol version, + // non-object structured content is wrapped in the legacy {"result": } envelope. + // Clients on the SEP-2106 protocol ("2026-06-30" and later, including the draft) see the + // natural value shape. In-memory storage stays natural in both modes — only the wire + // emission flips. + private const string LegacyProtocolVersion = "2025-11-25"; + private const string DraftSep2106ProtocolVersion = "DRAFT-2026-06-v1"; + private const string Sep2106ProtocolVersion = "2026-06-30"; + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(null, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task StructuredContent_StringReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped) + { + McpServerTool tool = McpServerTool.Create(() => "hello", new() { Name = "tool", UseStructuredContent = true }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + if (expectWrapped) + { + Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind); + Assert.True(result.StructuredContent.Value.TryGetProperty("result", out var inner)); + Assert.Equal("hello", inner.GetString()); + } + else + { + Assert.Equal(JsonValueKind.String, result.StructuredContent.Value.ValueKind); + Assert.Equal("hello", result.StructuredContent.Value.GetString()); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(null, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task StructuredContent_IntegerReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped) + { + McpServerTool tool = McpServerTool.Create(() => 42, new() { Name = "tool", UseStructuredContent = true }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + if (expectWrapped) + { + Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind); + Assert.True(result.StructuredContent.Value.TryGetProperty("result", out var inner)); + Assert.Equal(42, inner.GetInt32()); + } + else + { + Assert.Equal(JsonValueKind.Number, result.StructuredContent.Value.ValueKind); + Assert.Equal(42, result.StructuredContent.Value.GetInt32()); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(null, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task StructuredContent_ArrayReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped) + { + McpServerTool tool = McpServerTool.Create(() => new[] { "a", "b" }, new() { Name = "tool", UseStructuredContent = true }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + if (expectWrapped) + { + Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind); + Assert.True(result.StructuredContent.Value.TryGetProperty("result", out var inner)); + Assert.Equal(JsonValueKind.Array, inner.ValueKind); + Assert.Equal(2, inner.GetArrayLength()); + } + else + { + Assert.Equal(JsonValueKind.Array, result.StructuredContent.Value.ValueKind); + Assert.Equal(2, result.StructuredContent.Value.GetArrayLength()); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion)] + [InlineData(null)] + [InlineData(DraftSep2106ProtocolVersion)] + [InlineData(Sep2106ProtocolVersion)] + public async Task StructuredContent_ObjectReturn_NeverWrapped(string? protocolVersion) + { + // Object-typed return: the stored schema is type:"object" — already the form + // expected by clients on protocol versions older than 2026-06-30, so no envelope + // is applied at any protocol version. Wire shape must be identical across versions. + McpServerTool tool = McpServerTool.Create(() => new Person("John", 27), new() + { + Name = "tool", + UseStructuredContent = true, + SerializerOptions = CreateSerializerOptionsWithPerson(), + }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("result", out _)); + Assert.Equal("John", result.StructuredContent.Value.GetProperty("name").GetString()); + Assert.Equal(27, result.StructuredContent.Value.GetProperty("age").GetInt32()); + } + + [Theory] + [InlineData(LegacyProtocolVersion)] + [InlineData(null)] + [InlineData(DraftSep2106ProtocolVersion)] + [InlineData(Sep2106ProtocolVersion)] + public async Task StructuredContent_NullableObjectReturn_NeverWrapped(string? protocolVersion) + { + // type:["object","null"] — for clients on protocol versions older than 2026-06-30, + // the SCHEMA is normalized to plain type:"object" (verified in + // Sep2106ListToolsBackCompatTests), but the value side is never envelope-wrapped at + // any protocol version. So the emitted structured content stays a plain object + // across versions. + JsonElement outputSchema = JsonDocument.Parse( + """{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement; + McpServerTool tool = McpServerTool.Create(() => new Person("John", 27), new() + { + Name = "tool", + UseStructuredContent = true, + OutputSchema = outputSchema, + SerializerOptions = CreateSerializerOptionsWithPerson(), + }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("result", out _)); + Assert.Equal("John", result.StructuredContent.Value.GetProperty("name").GetString()); + } + + private static RequestContext CreateRequestContextWithProtocolVersion(string? protocolVersion) + { + var mockServer = new Mock(); + mockServer.SetupGet(s => s.NegotiatedProtocolVersion).Returns(protocolVersion); + return new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new CallToolRequestParams { Name = "tool" }); } [Fact] @@ -794,186 +972,6 @@ public async Task ToolWithNullableParameters_ReturnsExpectedSchema(JsonNumberHan Assert.True(JsonElement.DeepEquals(expectedSchema, tool.ProtocolTool.InputSchema)); } - [Fact] - public async Task StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers() - { - // When a non-object return type contains the same type at multiple locations, - // System.Text.Json's schema exporter emits $ref pointers for deduplication. - // After wrapping the schema under properties.result, those $ref pointers must - // be rewritten to remain valid. This test verifies that fix. - var data = new List - { - new() - { - WorkPhones = [new() { Number = "555-0100", Type = "work" }], - HomePhones = [new() { Number = "555-0200", Type = "home" }], - } - }; - - JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; - McpServerTool tool = McpServerTool.Create(() => data, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); - var mockServer = new Mock(); - var result = await tool.InvokeAsync( - new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new() { Name = "tool" }), - TestContext.Current.CancellationToken); - - Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); - Assert.NotNull(result.StructuredContent); - - // Verify $ref pointers in the schema point to valid locations after wrapping. - // Without the fix, $ref values like "#/items/..." would be unresolvable because - // the original schema was moved under "#/properties/result". - AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); - - // Also verify that any $ref in the schema starts with #/properties/result - // (confirming the rewrite happened). - string schemaJson = tool.ProtocolTool.OutputSchema.Value.GetRawText(); - var schemaNode = JsonNode.Parse(schemaJson)!; - int refCount = AssertAllRefsStartWith(schemaNode, "#/properties/result"); - Assert.True(refCount > 0, "Expected at least one $ref in the schema to validate the rewrite, but none were found."); - int resolvableCount = AssertAllRefsResolvable(schemaNode, schemaNode); - Assert.True(resolvableCount > 0, "Expected at least one resolvable $ref in the schema, but none were found."); - } - - [Fact] - public async Task StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers() - { - // When a non-object return type contains a recursive type, System.Text.Json's - // schema exporter emits $ref pointers (including potentially bare "#") for the - // recursive reference. After wrapping, these must be rewritten. For List, - // Children's items emit "$ref": "#/items" which must become "#/properties/result/items". - var data = new List - { - new() - { - Name = "root", - Children = [new() { Name = "child" }], - } - }; - - JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; - McpServerTool tool = McpServerTool.Create(() => data, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); - var mockServer = new Mock(); - var result = await tool.InvokeAsync( - new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new() { Name = "tool" }), - TestContext.Current.CancellationToken); - - Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); - Assert.NotNull(result.StructuredContent); - - AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); - - string schemaJson = tool.ProtocolTool.OutputSchema.Value.GetRawText(); - var schemaNode = JsonNode.Parse(schemaJson)!; - int refCount = AssertAllRefsStartWith(schemaNode, "#/properties/result"); - Assert.True(refCount > 0, "Expected at least one $ref in the schema to validate the rewrite, but none were found."); - int resolvableCount = AssertAllRefsResolvable(schemaNode, schemaNode); - Assert.True(resolvableCount > 0, "Expected at least one resolvable $ref in the schema, but none were found."); - } - - private static int AssertAllRefsStartWith(JsonNode? node, string expectedPrefix) - { - int count = 0; - if (node is JsonObject obj) - { - if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && - refNode?.GetValue() is string refValue) - { - Assert.StartsWith(expectedPrefix, refValue); - count++; - } - - foreach (var property in obj) - { - count += AssertAllRefsStartWith(property.Value, expectedPrefix); - } - } - else if (node is JsonArray arr) - { - foreach (var item in arr) - { - count += AssertAllRefsStartWith(item, expectedPrefix); - } - } - - return count; - } - - /// - /// Walks the JSON tree and verifies that every $ref pointer resolves to a valid node. - /// - private static int AssertAllRefsResolvable(JsonNode root, JsonNode? node) - { - int count = 0; - if (node is JsonObject obj) - { - if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && - refNode?.GetValue() is string refValue && - refValue.StartsWith("#", StringComparison.Ordinal)) - { - var resolved = ResolveJsonPointer(root, refValue); - Assert.True(resolved is not null, $"$ref \"{refValue}\" does not resolve to a valid node in the schema."); - count++; - } - - foreach (var property in obj) - { - count += AssertAllRefsResolvable(root, property.Value); - } - } - else if (node is JsonArray arr) - { - foreach (var item in arr) - { - count += AssertAllRefsResolvable(root, item); - } - } - - return count; - } - - /// - /// Resolves a JSON Pointer (e.g., #/properties/result/items) against a root node. - /// Returns null if the pointer cannot be resolved. - /// - private static JsonNode? ResolveJsonPointer(JsonNode root, string pointer) - { - if (pointer == "#") - { - return root; - } - - if (!pointer.StartsWith("#/", StringComparison.Ordinal)) - { - return null; - } - - JsonNode? current = root; - string[] segments = pointer.Substring(2).Split('/'); - foreach (string segment in segments) - { - if (current is JsonObject obj) - { - if (!obj.TryGetPropertyValue(segment, out current)) - { - return null; - } - } - else if (current is JsonArray arr && int.TryParse(segment, out int index) && index >= 0 && index < arr.Count) - { - current = arr[index]; - } - else - { - return null; - } - } - - return current; - } - public static IEnumerable StructuredOutput_ReturnsExpectedSchema_Inputs() { yield return new object[] { "string" }; @@ -1106,30 +1104,6 @@ private static JsonSerializerOptions CreateSerializerOptionsWithPerson() return options; } - // Types used by StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers. - // ContactInfo has two properties of the same type (PhoneNumber) which causes - // System.Text.Json's schema exporter to emit $ref pointers for deduplication. - private sealed class PhoneNumber - { - public string? Number { get; set; } - public string? Type { get; set; } - } - - private sealed class ContactInfo - { - public List? WorkPhones { get; set; } - public List? HomePhones { get; set; } - } - - // Recursive type used by StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers. - // When List is the return type, Children's items emit "$ref": "#/items" - // pointing back to the first TreeNode definition, which must be rewritten after wrapping. - private sealed class TreeNode - { - public string? Name { get; set; } - public List? Children { get; set; } - } - [Fact] public void SupportsIconsInCreateOptions() { @@ -1208,15 +1182,15 @@ public void ReturnDescription_StructuredOutputDisabled_IncludedInToolDescription [Fact] public void ReturnDescription_StructuredOutputEnabled_NotIncludedInToolDescription() { - // When UseStructuredContent is true, return description should be in the output schema, not in tool description + // When UseStructuredContent is true, return description should be in the output schema, not in tool description. + // Per SEP-2106 the schema is no longer wrapped in a {"result": } envelope, so the description + // sits directly on the (non-object) output schema. McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() { UseStructuredContent = true }); Assert.Equal("Tool that returns data.", tool.ProtocolTool.Description); Assert.NotNull(tool.ProtocolTool.OutputSchema); - // Verify the output schema contains the description - Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("result", out var result)); - Assert.True(result.TryGetProperty("description", out var description)); + Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("description", out var description)); Assert.Equal("The computed result", description.GetString()); } diff --git a/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs b/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs new file mode 100644 index 000000000..e559a40f5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs @@ -0,0 +1,414 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// SEP-2106 backward-compat at the tools/list emission boundary. Clients negotiating a +/// pre-2026-06-30 protocol version must still receive the legacy +/// {"type":"object","properties":{"result":<schema>},"required":["result"]} +/// envelope for non-object output schemas. In-memory storage stays natural; only the +/// wire emission flips on the negotiated version. +/// +public class Sep2106ListToolsBackCompatTests : ClientServerTestBase +{ + private const string LegacyProtocolVersion = "2025-11-25"; + private const string DraftSep2106ProtocolVersion = "DRAFT-2026-06-v1"; + private const string Sep2106ProtocolVersion = "2026-06-30"; + + public Sep2106ListToolsBackCompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task ListTools_StringTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped) + { + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_string"); + + if (expectWrapped) + { + AssertResultEnvelope(schema, innerType: "string"); + } + else + { + Assert.Equal("string", schema.GetProperty("type").GetString()); + Assert.False(schema.TryGetProperty("properties", out _)); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task ListTools_IntegerTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped) + { + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_int"); + + if (expectWrapped) + { + AssertResultEnvelope(schema, innerType: "integer"); + } + else + { + Assert.Equal("integer", schema.GetProperty("type").GetString()); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task ListTools_ArrayTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped) + { + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_array"); + + if (expectWrapped) + { + AssertResultEnvelope(schema, innerType: "array"); + } + else + { + Assert.Equal("array", schema.GetProperty("type").GetString()); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion)] + [InlineData(DraftSep2106ProtocolVersion)] + [InlineData(Sep2106ProtocolVersion)] + public async Task ListTools_ObjectTool_NeverWrapsOutputSchema(string serverProtocolVersion) + { + // Object-shaped schemas should be wire-identical across all protocol versions. + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_person"); + + Assert.Equal("object", schema.GetProperty("type").GetString()); + Assert.True(schema.GetProperty("properties").TryGetProperty("name", out _)); + Assert.True(schema.GetProperty("properties").TryGetProperty("age", out _)); + Assert.False(schema.GetProperty("properties").TryGetProperty("result", out _)); + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task ListTools_NullableObjectTool_NormalizesTypeArrayForLegacyClients(string serverProtocolVersion, bool expectNormalized) + { + // For clients on protocol versions older than 2026-06-30, type:["object","null"] + // must be emitted as plain type:"object" (those versions accept object schemas but + // not type-arrays — and the value side stays a plain object, no envelope). SEP-2106 + // clients (2026-06-30+) see the natural type-array intact per the SEP's + // any-JSON-Schema-2020-12 allowance. + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_nullable_object"); + + Assert.False(schema.GetProperty("properties").TryGetProperty("result", out _), + "type:['object','null'] schemas must not be re-wrapped in a result envelope."); + + JsonElement typeProperty = schema.GetProperty("type"); + if (expectNormalized) + { + // Legacy wire shape: ["object","null"] collapsed to plain "object" string. + Assert.Equal(JsonValueKind.String, typeProperty.ValueKind); + Assert.Equal("object", typeProperty.GetString()); + } + else + { + // SEP-2106 wire shape: the natural type array passes through with both + // members, in either order. + Assert.Equal(JsonValueKind.Array, typeProperty.ValueKind); + Assert.Equal(2, typeProperty.GetArrayLength()); + HashSet members = []; + foreach (JsonElement entry in typeProperty.EnumerateArray()) + { + members.Add(entry.GetString()); + } + Assert.Contains("object", members); + Assert.Contains("null", members); + } + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task ListTools_DuplicateTypeRefsTool_RewritesRefsWhenWrapped(string serverProtocolVersion, bool expectWrapped) + { + // List has the same type (PhoneNumber) at two locations, so the schema + // exporter emits $ref pointers for deduplication. For legacy clients the array schema + // is wrapped under properties.result, which must rewrite those $refs to stay resolvable. + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_duplicate_refs"); + + AssertRefsValidForWire(schema, expectWrapped); + } + + [Theory] + [InlineData(LegacyProtocolVersion, true)] + [InlineData(DraftSep2106ProtocolVersion, false)] + [InlineData(Sep2106ProtocolVersion, false)] + public async Task ListTools_RecursiveTypeRefsTool_RewritesRefsWhenWrapped(string serverProtocolVersion, bool expectWrapped) + { + // List is recursive: Children's items emit a $ref back to the TreeNode + // definition (e.g. "#/items"). When wrapped for legacy clients that becomes + // "#/properties/result/items" and must still resolve. + ConfigureServerWithTools(serverProtocolVersion); + await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); + + JsonElement schema = await GetOutputSchemaAsync(client, "return_recursive_refs"); + + AssertRefsValidForWire(schema, expectWrapped); + } + + /// + /// Asserts the emitted schema's $ref pointers are consistent with the wire shape: + /// when wrapped (legacy clients) the array schema is enveloped under + /// properties.result and every $ref is rewritten under that prefix; otherwise + /// (SEP-2106 clients) the natural array schema is emitted with its original refs. Either + /// way, every $ref must resolve against the emitted schema root. + /// + private static void AssertRefsValidForWire(JsonElement schema, bool expectWrapped) + { + JsonNode schemaNode = JsonNode.Parse(schema.GetRawText())!; + + if (expectWrapped) + { + Assert.Equal("object", schema.GetProperty("type").GetString()); + int rewritten = AssertAllRefsStartWith(schemaNode, "#/properties/result"); + Assert.True(rewritten > 0, "Expected at least one $ref rewritten under #/properties/result."); + } + else + { + Assert.Equal("array", schema.GetProperty("type").GetString()); + Assert.Equal(0, CountRefsStartingWith(schemaNode, "#/properties/result")); + } + + int resolvable = AssertAllRefsResolvable(schemaNode, schemaNode); + Assert.True(resolvable > 0, "Expected at least one resolvable $ref in the schema."); + } + + private static int AssertAllRefsStartWith(JsonNode? node, string expectedPrefix) + { + int count = 0; + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && + refNode?.GetValue() is string refValue) + { + Assert.StartsWith(expectedPrefix, refValue); + count++; + } + + foreach (var property in obj) + { + count += AssertAllRefsStartWith(property.Value, expectedPrefix); + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + count += AssertAllRefsStartWith(item, expectedPrefix); + } + } + + return count; + } + + private static int CountRefsStartingWith(JsonNode? node, string prefix) + { + int count = 0; + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && + refNode?.GetValue() is string refValue && + refValue.StartsWith(prefix, StringComparison.Ordinal)) + { + count++; + } + + foreach (var property in obj) + { + count += CountRefsStartingWith(property.Value, prefix); + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + count += CountRefsStartingWith(item, prefix); + } + } + + return count; + } + + private static int AssertAllRefsResolvable(JsonNode root, JsonNode? node) + { + int count = 0; + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) && + refNode?.GetValue() is string refValue && + refValue.StartsWith("#", StringComparison.Ordinal)) + { + var resolved = ResolveJsonPointer(root, refValue); + Assert.True(resolved is not null, $"$ref \"{refValue}\" does not resolve to a valid node in the schema."); + count++; + } + + foreach (var property in obj) + { + count += AssertAllRefsResolvable(root, property.Value); + } + } + else if (node is JsonArray arr) + { + foreach (var item in arr) + { + count += AssertAllRefsResolvable(root, item); + } + } + + return count; + } + + private static JsonNode? ResolveJsonPointer(JsonNode root, string pointer) + { + if (pointer == "#") + { + return root; + } + + if (!pointer.StartsWith("#/", StringComparison.Ordinal)) + { + return null; + } + + JsonNode? current = root; + string[] segments = pointer.Substring(2).Split('/'); + foreach (string segment in segments) + { + if (current is JsonObject obj) + { + if (!obj.TryGetPropertyValue(segment, out current)) + { + return null; + } + } + else if (current is JsonArray arr && int.TryParse(segment, out int index) && index >= 0 && index < arr.Count) + { + current = arr[index]; + } + else + { + return null; + } + } + + return current; + } + + private void ConfigureServerWithTools(string protocolVersion) + { + JsonSerializerOptions serializerOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + JsonElement nullableObjectSchema = JsonDocument.Parse( + """{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement; + + ServiceCollection.Configure(o => o.ProtocolVersion = protocolVersion); + McpServerBuilder.WithTools( + [ + McpServerTool.Create(() => "hello", new() { Name = "return_string", UseStructuredContent = true, SerializerOptions = serializerOptions }), + McpServerTool.Create(() => 42, new() { Name = "return_int", UseStructuredContent = true, SerializerOptions = serializerOptions }), + McpServerTool.Create(() => new[] { "a", "b" }, new() { Name = "return_array", UseStructuredContent = true, SerializerOptions = serializerOptions }), + McpServerTool.Create(() => new Person("John", 27), new() { Name = "return_person", UseStructuredContent = true, SerializerOptions = serializerOptions }), + McpServerTool.Create(() => new Person("John", 27), new() { Name = "return_nullable_object", UseStructuredContent = true, OutputSchema = nullableObjectSchema, SerializerOptions = serializerOptions }), + McpServerTool.Create(() => new List + { + new() + { + WorkPhones = [new() { Number = "555-0100", Type = "work" }], + HomePhones = [new() { Number = "555-0200", Type = "home" }], + } + }, new() { Name = "return_duplicate_refs", UseStructuredContent = true, SerializerOptions = serializerOptions }), + McpServerTool.Create(() => new List + { + new() { Name = "root", Children = [new() { Name = "child" }] } + }, new() { Name = "return_recursive_refs", UseStructuredContent = true, SerializerOptions = serializerOptions }), + ]); + StartServer(); + } + + private static async Task GetOutputSchemaAsync(McpClient client, string toolName) + { + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == toolName); + Assert.NotNull(tool.ProtocolTool.OutputSchema); + return tool.ProtocolTool.OutputSchema.Value; + } + + private static void AssertResultEnvelope(JsonElement schema, string innerType) + { + Assert.Equal("object", schema.GetProperty("type").GetString()); + + JsonElement properties = schema.GetProperty("properties"); + Assert.True(properties.TryGetProperty("result", out JsonElement inner)); + Assert.Equal(innerType, inner.GetProperty("type").GetString()); + + JsonElement required = schema.GetProperty("required"); + Assert.Equal(JsonValueKind.Array, required.ValueKind); + Assert.Equal(1, required.GetArrayLength()); + Assert.Equal("result", required[0].GetString()); + } + + private sealed record Person(string Name, int Age); + + // ContactInfo has two properties of the same type (PhoneNumber), which makes the schema + // exporter emit $ref pointers for deduplication. + private sealed class PhoneNumber + { + public string? Number { get; set; } + public string? Type { get; set; } + } + + private sealed class ContactInfo + { + public List? WorkPhones { get; set; } + public List? HomePhones { get; set; } + } + + // Recursive type: Children's items emit a $ref back to the TreeNode definition. + private sealed class TreeNode + { + public string? Name { get; set; } + public List? Children { get; set; } + } +} From de75c7d0dfbac90ec4281f94f802de58da5668d4 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 17 Jun 2026 11:59:59 -0700 Subject: [PATCH 29/50] Fix main build: remove dangling Tool.Execution reference in BuildLegacyWireProtocolTool (#1660) --- src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index c1c4fe664..6fad7766b 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -262,7 +262,6 @@ internal Tool BuildLegacyWireProtocolTool() InputSchema = ProtocolTool.InputSchema, OutputSchema = legacyOutputSchema, Annotations = ProtocolTool.Annotations, - Execution = ProtocolTool.Execution, Icons = ProtocolTool.Icons, Meta = ProtocolTool.Meta, }; From f93df7e172e5b58760771196f27a1c6ba97d26c8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:28:47 -0700 Subject: [PATCH 30/50] Add MCP Apps extension support (typed metadata, attribute, and helpers) (#1484) --- ModelContextProtocol.slnx | 2 + docs/concepts/apps/apps.md | 191 +++++++ docs/concepts/index.md | 6 + docs/concepts/toc.yml | 6 +- docs/list-of-diagnostics.md | 1 + samples/WeatherAppServer/Program.cs | 34 ++ samples/WeatherAppServer/README.md | 41 ++ samples/WeatherAppServer/UsCity.cs | 64 +++ .../WeatherAppServer/WeatherAppServer.csproj | 20 + samples/WeatherAppServer/WeatherResources.cs | 25 + samples/WeatherAppServer/WeatherTools.cs | 64 +++ samples/WeatherAppServer/WeatherUI.png | Bin 0 -> 367532 bytes .../WeatherAppServer/ui/weather-forecast.html | 257 +++++++++ src/Common/Experimentals.cs | 20 + ...odelContextProtocol.Extensions.Apps.csproj | 44 ++ .../Server/McpAppUiAttribute.cs | 59 +++ .../Server/McpApps.cs | 265 ++++++++++ .../Server/McpAppsBuilderExtensions.cs | 71 +++ .../Server/McpAppsJsonContext.cs | 19 + .../Server/McpUiClientCapabilities.cs | 26 + .../Server/McpUiResourceCsp.cs | 49 ++ .../Server/McpUiResourceMeta.cs | 50 ++ .../Server/McpUiResourcePermissions.cs | 27 + .../Server/McpUiToolMeta.cs | 41 ++ .../Server/McpUiToolVisibility.cs | 25 + .../ModelContextProtocol.Tests.csproj | 1 + .../Server/McpAppsTests.cs | 489 ++++++++++++++++++ 27 files changed, 1896 insertions(+), 1 deletion(-) create mode 100644 docs/concepts/apps/apps.md create mode 100644 samples/WeatherAppServer/Program.cs create mode 100644 samples/WeatherAppServer/README.md create mode 100644 samples/WeatherAppServer/UsCity.cs create mode 100644 samples/WeatherAppServer/WeatherAppServer.csproj create mode 100644 samples/WeatherAppServer/WeatherResources.cs create mode 100644 samples/WeatherAppServer/WeatherTools.cs create mode 100644 samples/WeatherAppServer/WeatherUI.png create mode 100644 samples/WeatherAppServer/ui/weather-forecast.html create mode 100644 src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpAppUiAttribute.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceCsp.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceMeta.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourcePermissions.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolMeta.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolVisibility.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 2eebe7225..abfb430cb 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -51,6 +51,7 @@ + @@ -67,6 +68,7 @@ + diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md new file mode 100644 index 000000000..285f48de5 --- /dev/null +++ b/docs/concepts/apps/apps.md @@ -0,0 +1,191 @@ +--- +title: MCP Apps +author: mikekistler +description: How to use the MCP Apps extension to deliver interactive UIs from MCP servers. +uid: apps +--- + +# MCP Apps + +[MCP Apps] is an extension to the Model Context Protocol that enables MCP servers to deliver interactive user interfaces — dashboards, forms, visualizations, and more — directly inside conversational AI clients. + +[MCP Apps]: https://modelcontextprotocol.io/extensions/apps/overview + +> [!IMPORTANT] +> MCP Apps support is experimental. All types are marked with `[Experimental("MCPEXP003")]` and require suppressing that diagnostic to use. + +## Installation + +MCP Apps is provided in the `ModelContextProtocol.Extensions.Apps` package, which layers on top of the core SDK: + +```shell +dotnet add package ModelContextProtocol.Extensions.Apps +``` + +## Overview + +The MCP Apps extension introduces the concept of **UI resources** — HTML pages served by the MCP server that a client can display alongside the conversation. Tools can be associated with a UI resource so the client knows which interface to show when a tool is called. + +The key concepts are: + +- **UI capability negotiation** — Client and server declare support via `extensions["io.modelcontextprotocol/ui"]` +- **UI resources** — HTML content served with the MIME type `text/html;profile=mcp-app` +- **Tool UI metadata** — Tools declare their associated UI resource in `_meta.ui` + +## Associating tools with UI resources + +### Using the builder extension (recommended) + +The simplest approach is to apply `[McpAppUi]` attributes to your tool methods and call `WithMcpApps()` on the server builder: + +```csharp +[McpServerToolType] +public class WeatherTools +{ + [McpServerTool, Description("Get current weather for a location")] + [McpAppUi(ResourceUri = "ui://weather/view.html")] + public static string GetWeather(string location) => $"Weather for {location}"; + + [McpServerTool, Description("Get forecast (model-only tool)")] + [McpAppUi(ResourceUri = "ui://weather/forecast.html", Visibility = [McpUiToolVisibility.Model])] + public static string GetForecast(string location) => $"Forecast for {location}"; +} +``` + +```csharp +builder.Services.AddMcpServer() + .WithTools() + .WithMcpApps(); +``` + +The `WithMcpApps()` call registers a post-configuration step that processes all registered tools and applies `[McpAppUi]` attribute metadata to their `_meta.ui` field automatically. + +### Using the attribute with manual processing + +If you create tools manually (without `WithMcpApps()`), you can still use the attribute and process tools explicitly: + +```csharp +var tools = new[] +{ + McpServerTool.Create(typeof(WeatherTools).GetMethod(nameof(WeatherTools.GetWeather))!), + McpServerTool.Create(typeof(WeatherTools).GetMethod(nameof(WeatherTools.GetForecast))!), +}; + +McpApps.ApplyAppUiAttributes(tools); +``` + +### Using the programmatic API + +For full control, use `McpApps.SetAppUi` to set UI metadata directly: + +```csharp +var tool = McpServerTool.Create((string location) => $"Weather for {location}"); + +McpApps.SetAppUi(tool, new McpUiToolMeta +{ + ResourceUri = "ui://weather/view.html", + Visibility = [McpUiToolVisibility.Model, McpUiToolVisibility.App], +}); +``` + +## Checking client capabilities + +During a session, you can check whether the connected client supports MCP Apps: + +```csharp +[McpServerTool, Description("Get weather")] +[McpAppUi(ResourceUri = "ui://weather/view.html")] +public static string GetWeather(McpServer server, string location) +{ + var uiCapability = McpApps.GetUiCapability(server.ClientCapabilities); + if (uiCapability is not null) + { + // Client supports MCP Apps — the UI will be displayed + } + + return $"Weather for {location}"; +} +``` + +## Tool visibility + +The `Visibility` property controls which principals can invoke the tool: + +| Value | Meaning | +| - | - | +| `McpUiToolVisibility.Model` | Only the LLM can call this tool | +| `McpUiToolVisibility.App` | Only the app UI can call this tool | +| Both (or null/empty) | Both the model and app can call the tool (default) | + +## UI resources + +UI resources are HTML pages registered with the MCP server using the `ui://` URI scheme and the `text/html;profile=mcp-app` MIME type. The `McpUiResourceMeta` type provides metadata for these resources, including: + +- **CSP (Content Security Policy)** — Controls allowed origins for network requests and resource loads +- **Permissions** — Sandbox permissions (scripts, forms, popups, etc.) +- **Domain** — Dedicated origin for OAuth flows and CORS +- **PrefersBorder** — Whether the host should render a visual border + +## App-only tools + +Tools with `Visibility = [McpUiToolVisibility.App]` are not visible to the LLM — they are intended only for use by the app UI. +This is useful for tools that serve UI interaction (button handlers, form submissions) without cluttering the model's tool list: + +```csharp +[McpServerTool, Description("Submit the weather form")] +[McpAppUi(ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.App])] +public static string SubmitWeatherForm(string city) => GetWeatherHtml(city); +``` + +## Graceful degradation + +Not all clients support MCP Apps. Use `GetUiCapability` to detect support and return text-only content as a fallback: + +```csharp +[McpServerTool, Description("Get weather")] +[McpAppUi(ResourceUri = "ui://weather/view.html")] +public static string GetWeather(McpServer server, string location) +{ + var uiCapability = McpApps.GetUiCapability(server.ClientCapabilities); + if (uiCapability is null) + { + // Client doesn't support MCP Apps — return plain text + return $"Current weather for {location}: 72°F, sunny"; + } + + // Client supports MCP Apps — the UI resource will be displayed + return $"Weather data for {location} loaded into UI"; +} +``` + +## Display modes + +The MCP Apps spec defines display modes (`inline`, `fullscreen`, `pip`) that control how the host renders the UI. Display mode is negotiated between the client and server during capability exchange and is not set per-tool — it depends on the host implementation. + +## Host theming + +Hosts pass standardized CSS custom properties (e.g., `--color-background-primary`, `--color-text-primary`) to app iframes. Your HTML can reference these variables to automatically match the host's theme without any server-side configuration. + +See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) for the full list of CSS variables. + +## Single-file HTML bundling + +The default Content Security Policy restricts external script and style loads. For production apps, bundle all JavaScript and CSS into a single HTML file using tools like [vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile). For simple apps, inline ` + + diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index 411acd989..b55eba7cd 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -50,6 +50,26 @@ internal static class Experimentals /// public const string Extensions_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001"; + /// + /// Diagnostic ID for 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. This uses a dedicated diagnostic ID + /// so that users can suppress it independently from other experimental APIs. + /// + public const string Apps_DiagnosticId = "MCPEXP003"; + + /// + /// Message for the experimental MCP Apps extension APIs. + /// + public const string Apps_Message = "The MCP Apps extension is experimental and subject to change as the specification evolves."; + + /// + /// URL for the experimental MCP Apps extension APIs. + /// + 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. diff --git a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj new file mode 100644 index 000000000..1d7d0dd8a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj @@ -0,0 +1,44 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Apps + MCP Apps extension for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP003 + + false + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppUiAttribute.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppUiAttribute.cs new file mode 100644 index 000000000..2a682b8c6 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppUiAttribute.cs @@ -0,0 +1,59 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Specifies MCP Apps UI metadata for a tool method. +/// +/// +/// +/// Apply this attribute alongside to associate an MCP Apps +/// UI resource with the tool. When processed by +/// or , it populates the +/// structured _meta.ui object in the tool's metadata. +/// +/// +/// Explicit Meta["ui"] set via or a raw +/// [McpMeta("ui", ...)] attribute takes precedence over this attribute: if the tool +/// already has a ui key in , this attribute is ignored. +/// +/// +/// +/// +/// [McpServerTool] +/// [McpAppUi(ResourceUri = "ui://weather/view.html")] +/// [Description("Get current weather for a location")] +/// public string GetWeather(string location) => ...; +/// +/// // Restrict visibility to model only: +/// [McpServerTool] +/// [McpAppUi(ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.Model])] +/// public string GetWeatherModelOnly(string location) => ...; +/// +/// +[AttributeUsage(AttributeTargets.Method)] +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpAppUiAttribute : Attribute +{ + /// + /// Gets or sets the URI of the UI resource associated with this tool. + /// + /// + /// This should be a ui:// URI pointing to the HTML resource registered + /// with the server (e.g., "ui://weather/view.html"). + /// + public string? ResourceUri { get; set; } + + /// + /// Gets or sets the visibility of the tool, controlling which principals can invoke it. + /// + /// + /// + /// Allowed values are and . + /// When or empty, the tool is visible to both the model and the app (the default). + /// + /// + public string[]? Visibility { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs new file mode 100644 index 000000000..53de40759 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs @@ -0,0 +1,265 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Provides constants and helper methods for building MCP Apps-enabled servers. +/// +/// +/// +/// MCP Apps is an extension to the Model Context Protocol that enables MCP servers to deliver +/// interactive user interfaces — dashboards, forms, visualizations, and more — directly inside +/// conversational AI clients. +/// +/// +/// Use the constants in this class when populating the extensions capability and the +/// _meta field of tools and resources. Use to check whether +/// the connected client supports the MCP Apps extension. +/// +/// +/// Use to set the _meta.ui metadata on a tool, or +/// to automatically process +/// instances on tools created from methods. +/// +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public static class McpApps +{ + /// + /// The MIME type used for MCP App HTML resources. + /// + /// + /// This MIME type should be used when registering UI resources with + /// text/html;profile=mcp-app to indicate they are MCP App resources. + /// + public const string HtmlMimeType = "text/html;profile=mcp-app"; + + /// + /// The extension identifier used for MCP Apps capability negotiation. + /// + /// + /// This key is used in the and + /// dictionaries to advertise support for + /// the MCP Apps extension. + /// + public const string ExtensionId = "io.modelcontextprotocol/ui"; + + /// + /// Gets the configured with source-generated metadata + /// for MCP Apps extension types. + /// + /// + /// Use these options when serializing or deserializing MCP Apps types such as + /// , , and . + /// + public static JsonSerializerOptions SerializerOptions { get; } = CreateSerializerOptions(); + + private static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions); + options.TypeInfoResolverChain.Insert(0, McpAppsJsonContext.Default); + options.MakeReadOnly(); + return options; + } + + /// + /// Gets the MCP Apps client capability, if advertised by the connected client. + /// + /// The client capabilities received during the MCP initialize handshake. + /// + /// A instance if the client advertises support for the MCP Apps extension; + /// otherwise, . + /// + /// + /// Use this method to determine whether the connected client supports the MCP Apps extension + /// and to read the client's supported MIME types. + /// + public static McpUiClientCapabilities? GetUiCapability(ClientCapabilities? capabilities) + { + if (capabilities?.Extensions is not { } extensions || + !extensions.TryGetValue(ExtensionId, out var value)) + { + return null; + } + + // Handle the case where the value was set programmatically (e.g. in tests + // or a non-JSON code path) rather than deserialized from the handshake. + if (value is McpUiClientCapabilities uiCapabilities) + { + return uiCapabilities; + } + + if (value is JsonElement element) + { + // Guard against malformed extension values (e.g. a bare string or number) + // that would throw JsonException during deserialization. Return null + // consistently for any non-object value, matching the other failure modes. + if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return null; + } + + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiClientCapabilities); + } + + return null; + } + + /// + /// Sets the MCP Apps UI metadata on a tool's property. + /// + /// The tool to set the UI metadata on. + /// The UI metadata to apply. + /// The same instance, for chaining. + /// + /// + /// This method sets the ui key in the tool's object. + /// If a ui key is already present in , it is not overwritten. + /// + /// + /// or is . + public static McpServerTool SetAppUi(McpServerTool tool, McpUiToolMeta appUi) + { +#if NET + ArgumentNullException.ThrowIfNull(tool); + ArgumentNullException.ThrowIfNull(appUi); +#else + if (tool is null) throw new ArgumentNullException(nameof(tool)); + if (appUi is null) throw new ArgumentNullException(nameof(appUi)); +#endif + + var protocolTool = tool.ProtocolTool; + protocolTool.Meta ??= new JsonObject(); + + if (!protocolTool.Meta.ContainsKey("ui")) + { + var uiNode = JsonSerializer.SerializeToNode(appUi, McpAppsJsonContext.Default.McpUiToolMeta); + if (uiNode is not null) + { + protocolTool.Meta["ui"] = uiNode; + } + } + + return tool; + } + + /// + /// Sets the MCP Apps UI metadata on a resource's property. + /// + /// The resource to set the UI metadata on. + /// The UI metadata to apply. + /// The same instance, for chaining. + /// + /// + /// This method sets the ui key in the resource's object. + /// If a ui key is already present in , it is not overwritten. + /// + /// + /// or is . + public static McpServerResource SetResourceUi(McpServerResource resource, McpUiResourceMeta resourceUi) + { +#if NET + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceUi); +#else + if (resource is null) throw new ArgumentNullException(nameof(resource)); + if (resourceUi is null) throw new ArgumentNullException(nameof(resourceUi)); +#endif + + var protocolResource = resource.ProtocolResourceTemplate; + protocolResource.Meta ??= new JsonObject(); + + if (!protocolResource.Meta.ContainsKey("ui")) + { + var uiNode = JsonSerializer.SerializeToNode(resourceUi, McpAppsJsonContext.Default.McpUiResourceMeta); + if (uiNode is not null) + { + protocolResource.Meta["ui"] = uiNode; + } + } + + return resource; + } + + /// + /// Processes a collection of tools, applying metadata to any + /// tool whose underlying method has the attribute. + /// + /// The tools to process. + /// The same enumerable, for chaining. + /// + /// + /// For each tool that has a in its , + /// this method sets the ui key in the tool's if not already present. + /// + /// + /// If already contains a ui key (e.g., set explicitly via + /// ), the attribute is not applied. + /// + /// + /// is . + public static IEnumerable ApplyAppUiAttributes(IEnumerable tools) + { +#if NET + ArgumentNullException.ThrowIfNull(tools); +#else + if (tools is null) throw new ArgumentNullException(nameof(tools)); +#endif + + foreach (var tool in tools) + { + ApplyAppUiAttributes(tool); + } + + return tools; + } + + /// + /// Processes a single tool, applying metadata if the tool's + /// underlying method has the attribute. + /// + /// The tool to process. + /// The same instance, for chaining. + /// + /// + /// If the tool has a in its , + /// this method sets the ui key in the tool's if not already present. + /// + /// + /// is . + public static McpServerTool ApplyAppUiAttributes(McpServerTool tool) + { +#if NET + ArgumentNullException.ThrowIfNull(tool); +#else + if (tool is null) throw new ArgumentNullException(nameof(tool)); +#endif + + // Look for McpAppUiAttribute in tool metadata (attributes from the method) + foreach (var metadataItem in tool.Metadata) + { + if (metadataItem is McpAppUiAttribute appUiAttr) + { + var meta = new McpUiToolMeta + { + ResourceUri = appUiAttr.ResourceUri, + Visibility = appUiAttr.Visibility, + }; + + SetAppUi(tool, meta); + break; + } + } + + return tool; + } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs new file mode 100644 index 000000000..a68d8fe50 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Extension methods for to enable MCP Apps support. +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public static class McpAppsBuilderExtensions +{ + /// + /// Enables MCP Apps support by automatically processing on registered tools. + /// + /// The server builder. + /// The builder provided in . + /// + /// + /// Call this method after registering tools (e.g., after WithTools<T>()) to automatically + /// apply metadata to the tool's _meta.ui field. + /// + /// + /// Tools that already have a ui key in their (e.g., set explicitly + /// via ) are not modified. + /// + /// + /// + /// + /// builder.Services + /// .AddMcpServer() + /// .WithTools<MyToolType>() + /// .WithMcpApps(); + /// + /// + public static IMcpServerBuilder WithMcpApps(this IMcpServerBuilder builder) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); +#endif + + builder.Services.AddSingleton, McpAppsPostConfigureOptions>(); + return builder; + } + + private sealed class McpAppsPostConfigureOptions : IPostConfigureOptions + { + public void PostConfigure(string? name, McpServerOptions options) + { + // Advertise server-side MCP Apps support in capabilities. + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)) + { + options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject(); + } + + if (options.ToolCollection is { IsEmpty: false } tools) + { + foreach (var tool in tools) + { + McpApps.ApplyAppUiAttributes(tool); + } + } + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs new file mode 100644 index 000000000..8a03ab95d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Provides source-generated JSON serialization metadata for MCP Apps extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(McpUiToolMeta))] +[JsonSerializable(typeof(McpUiClientCapabilities))] +[JsonSerializable(typeof(McpUiResourceMeta))] +[JsonSerializable(typeof(McpUiResourceCsp))] +[JsonSerializable(typeof(McpUiResourcePermissions))] +internal sealed partial class McpAppsJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs new file mode 100644 index 000000000..a446d90cb --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Represents the MCP Apps capabilities advertised by a client. +/// +/// +/// +/// This object is the value associated with the "io.modelcontextprotocol/ui" key in the +/// dictionary. +/// +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiClientCapabilities +{ + /// + /// Gets or sets the list of MIME types supported by the client for MCP App UI resources. + /// + /// + /// A client that supports MCP Apps must include "text/html;profile=mcp-app" in this list. + /// + [JsonPropertyName("mimeTypes")] + public IList? MimeTypes { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceCsp.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceCsp.cs new file mode 100644 index 000000000..da13efea3 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceCsp.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Represents the Content Security Policy (CSP) domain allowlists for an MCP Apps UI resource. +/// +/// +/// +/// These allowlists are used by the MCP host to construct the Content-Security-Policy HTTP header +/// for the sandboxed iframe that hosts the UI resource. +/// +/// +/// Each list contains origins (e.g., "https://api.example.com") that are permitted for +/// the corresponding CSP directive. +/// +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiResourceCsp +{ + /// + /// Gets or sets the list of origins allowed for fetch, XMLHttpRequest, WebSocket, and EventSource + /// connections (connect-src CSP directive). + /// + [JsonPropertyName("connectDomains")] + public IList? ConnectDomains { get; set; } + + /// + /// Gets or sets the list of origins allowed for loading scripts, stylesheets, images, and fonts + /// (script-src, style-src, img-src, font-src CSP directives). + /// + [JsonPropertyName("resourceDomains")] + public IList? ResourceDomains { get; set; } + + /// + /// Gets or sets the list of origins allowed for loading nested frames + /// (frame-src CSP directive). + /// + [JsonPropertyName("frameDomains")] + public IList? FrameDomains { get; set; } + + /// + /// Gets or sets the list of allowed base URIs + /// (base-uri CSP directive). + /// + [JsonPropertyName("baseUris")] + public IList? BaseUris { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceMeta.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceMeta.cs new file mode 100644 index 000000000..fcb09d86a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourceMeta.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Represents the UI metadata associated with an MCP resource in the MCP Apps extension. +/// +/// +/// This metadata is placed under the ui key in the resource's _meta object. +/// It provides Content Security Policy (CSP) configuration, sandbox permissions, CORS origin, and +/// visual boundary preferences for the UI resource served by this MCP server. +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiResourceMeta +{ + /// + /// Gets or sets the Content Security Policy configuration for this resource. + /// + /// + /// Specifies the allowed origins for network requests, resource loads, and nested frames. + /// + [JsonPropertyName("csp")] + public McpUiResourceCsp? Csp { get; set; } + + /// + /// Gets or sets the sandbox permissions for this resource. + /// + /// + /// Controls which browser sandbox features the UI resource is allowed to use. + /// + [JsonPropertyName("permissions")] + public McpUiResourcePermissions? Permissions { get; set; } + + /// + /// Gets or sets the dedicated origin domain for this resource. + /// + /// + /// When set, the host will serve the resource from this dedicated origin, + /// enabling OAuth flows and CORS without wildcard exceptions. + /// + [JsonPropertyName("domain")] + public string? Domain { get; set; } + + /// + /// Gets or sets a value indicating whether the host should render a visual border around the UI. + /// + [JsonPropertyName("prefersBorder")] + public bool? PrefersBorder { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourcePermissions.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourcePermissions.cs new file mode 100644 index 000000000..758fee03b --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiResourcePermissions.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Represents the sandbox permissions requested by an MCP Apps UI resource. +/// +/// +/// This maps to the allow attribute on the iframe sandbox in the MCP host. +/// Permissions are specified as standard browser iframe permission strings, +/// such as "camera", "microphone", or "geolocation". +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiResourcePermissions +{ + /// + /// Gets or sets the list of permissions granted to the sandboxed UI resource. + /// + /// + /// These correspond to values allowed in the allow attribute of an HTML iframe, + /// for example "camera", "microphone", "geolocation", + /// "clipboard-read", or "clipboard-write". + /// + [JsonPropertyName("allow")] + public IList? Allow { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolMeta.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolMeta.cs new file mode 100644 index 000000000..44fa369c1 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolMeta.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Represents the UI metadata associated with an MCP tool in the MCP Apps extension. +/// +/// +/// +/// This metadata is placed under the ui key in the tool's _meta object. +/// It associates the tool with a UI resource (identified by a ui:// URI) and optionally +/// controls which principals (model, app) can call the tool. +/// +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiToolMeta +{ + /// + /// Gets or sets the URI of the UI resource associated with this tool. + /// + /// + /// This should be a ui:// URI pointing to the HTML resource registered + /// with the server (e.g., "ui://weather/view.html"). + /// + [JsonPropertyName("resourceUri")] + public string? ResourceUri { get; set; } + + /// + /// Gets or sets the visibility of the tool, controlling which principals can invoke it. + /// + /// + /// + /// Allowed values are ("model") and + /// ("app"). When + /// or empty, the tool is visible to both the model and the app (the default). + /// + /// + [JsonPropertyName("visibility")] + public IList? Visibility { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolVisibility.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolVisibility.cs new file mode 100644 index 000000000..23b66a11b --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiToolVisibility.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Extensions.Apps; + +/// +/// Provides well-known visibility values for . +/// +/// +/// Use these constants to specify which principals can invoke a tool in the MCP Apps extension. +/// When is or empty, the tool +/// is visible to both the model and the app by default. +/// +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public static class McpUiToolVisibility +{ + /// + /// Indicates that the tool can be invoked by the AI model. + /// + public const string Model = "model"; + + /// + /// Indicates that the tool can be invoked by the UI app (iframe). + /// + public const string App = "app"; +} diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index a9b40a412..6025bc316 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -82,6 +82,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs new file mode 100644 index 000000000..756417de0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs @@ -0,0 +1,489 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for MCP Apps extension support: McpApps constants, typed metadata models, +/// McpAppUiAttribute, SetAppUi, and ApplyAppUiAttributes. +/// +public class McpAppsTests +{ + #region F1: Constants + + [Fact] + public void McpApps_Constants_HaveExpectedValues() + { + Assert.Equal("text/html;profile=mcp-app", McpApps.HtmlMimeType); + Assert.Equal("io.modelcontextprotocol/ui", McpApps.ExtensionId); + } + + [Fact] + public void McpUiToolVisibility_Constants_HaveExpectedValues() + { + Assert.Equal("model", McpUiToolVisibility.Model); + Assert.Equal("app", McpUiToolVisibility.App); + } + + #endregion + + #region F2: Typed Metadata Models + + [Fact] + public void McpUiToolMeta_DefaultsToNull() + { + var meta = new McpUiToolMeta(); + Assert.Null(meta.ResourceUri); + Assert.Null(meta.Visibility); + } + + [Fact] + public void McpUiToolMeta_CanBeRoundtrippedAsJson() + { + var meta = new McpUiToolMeta + { + ResourceUri = "ui://weather/view.html", + Visibility = [McpUiToolVisibility.Model, McpUiToolVisibility.App], + }; + + var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); + var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + + Assert.NotNull(deserialized); + Assert.Equal("ui://weather/view.html", deserialized.ResourceUri); + Assert.Equal(["model", "app"], deserialized.Visibility); + } + + [Fact] + public void McpUiToolMeta_OmitsNullProperties() + { + var meta = new McpUiToolMeta { ResourceUri = "ui://app" }; + var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); + var doc = JsonDocument.Parse(json); + + Assert.True(doc.RootElement.TryGetProperty("resourceUri", out _)); + Assert.False(doc.RootElement.TryGetProperty("visibility", out _)); + } + + [Fact] + public void McpUiResourceMeta_CanBeRoundtrippedAsJson() + { + var meta = new McpUiResourceMeta + { + Domain = "https://app.example.com", + PrefersBorder = true, + Csp = new McpUiResourceCsp + { + ConnectDomains = ["https://api.example.com"], + ResourceDomains = ["https://cdn.example.com"], + FrameDomains = ["https://embed.example.com"], + BaseUris = ["https://app.example.com"], + }, + Permissions = new McpUiResourcePermissions + { + Allow = ["camera", "microphone"], + }, + }; + + var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); + var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + + Assert.NotNull(deserialized); + Assert.Equal("https://app.example.com", deserialized.Domain); + Assert.True(deserialized.PrefersBorder); + Assert.NotNull(deserialized.Csp); + Assert.Equal(["https://api.example.com"], deserialized.Csp.ConnectDomains); + Assert.Equal(["https://cdn.example.com"], deserialized.Csp.ResourceDomains); + Assert.Equal(["https://embed.example.com"], deserialized.Csp.FrameDomains); + Assert.Equal(["https://app.example.com"], deserialized.Csp.BaseUris); + Assert.NotNull(deserialized.Permissions); + Assert.Equal(["camera", "microphone"], deserialized.Permissions.Allow); + } + + [Fact] + public void McpUiClientCapabilities_CanBeRoundtrippedAsJson() + { + var caps = new McpUiClientCapabilities + { + MimeTypes = [McpApps.HtmlMimeType], + }; + + var json = JsonSerializer.Serialize(caps, McpApps.SerializerOptions); + var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + + Assert.NotNull(deserialized); + Assert.Equal([McpApps.HtmlMimeType], deserialized.MimeTypes); + } + + #endregion + + #region F3: GetUiCapability + + [Fact] + public void GetUiCapability_ReturnsNull_WhenCapabilitiesIsNull() + { + Assert.Null(McpApps.GetUiCapability(null)); + } + + [Fact] + public void GetUiCapability_ReturnsNull_WhenExtensionsIsNull() + { + var caps = new ClientCapabilities(); + Assert.Null(McpApps.GetUiCapability(caps)); + } + + [Fact] + public void GetUiCapability_ReturnsNull_WhenExtensionKeyIsMissing() + { +#pragma warning disable MCPEXP001 + var caps = new ClientCapabilities + { + Extensions = new Dictionary + { + ["other.extension"] = new { }, + } + }; +#pragma warning restore MCPEXP001 + Assert.Null(McpApps.GetUiCapability(caps)); + } + + [Fact] + public void GetUiCapability_ReturnsCapabilities_WhenExtensionIsPresent() + { + // Simulate what the SDK does when deserializing ClientCapabilities from JSON: + // extensions values come in as JsonElement. + var json = $$""" + { + "extensions": { + "{{McpApps.ExtensionId}}": { + "mimeTypes": ["{{McpApps.HtmlMimeType}}"] + } + } + } + """; + + var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(caps); + + var uiCaps = McpApps.GetUiCapability(caps); + + Assert.NotNull(uiCaps); + Assert.Equal([McpApps.HtmlMimeType], uiCaps.MimeTypes); + } + + [Fact] + public void GetUiCapability_ReturnsNull_WhenExtensionValueIsNull() + { + var json = $$""" + { + "extensions": { + "{{McpApps.ExtensionId}}": null + } + } + """; + + var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(caps); + + Assert.Null(McpApps.GetUiCapability(caps)); + } + + [Theory] + [InlineData("\"a string value\"")] + [InlineData("42")] + [InlineData("true")] + [InlineData("[1, 2, 3]")] + public void GetUiCapability_ReturnsNull_WhenExtensionValueIsMalformed(string malformedValue) + { + var json = $$""" + { + "extensions": { + "{{McpApps.ExtensionId}}": {{malformedValue}} + } + } + """; + + var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(caps); + + // Should return null gracefully, not throw + Assert.Null(McpApps.GetUiCapability(caps)); + } + + [Fact] + public void GetUiCapability_ReturnsCapabilities_WhenValueIsStronglyTyped() + { +#pragma warning disable MCPEXP001 + var caps = new ClientCapabilities + { + Extensions = new Dictionary + { + [McpApps.ExtensionId] = new McpUiClientCapabilities + { + MimeTypes = [McpApps.HtmlMimeType], + }, + } + }; +#pragma warning restore MCPEXP001 + + var uiCaps = McpApps.GetUiCapability(caps); + Assert.NotNull(uiCaps); + Assert.Equal([McpApps.HtmlMimeType], uiCaps.MimeTypes); + } + + #endregion + + #region F6: McpAppUiAttribute via ApplyAppUiAttributes + + [Fact] + public void ApplyAppUiAttributes_PopulatesUiObject() + { + var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!; + var tool = McpServerTool.Create(method, target: null); + + McpApps.ApplyAppUiAttributes(tool); + + var meta = tool.ProtocolTool.Meta; + Assert.NotNull(meta); + + // Structured "ui" object + var uiNode = meta["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); + } + + [Fact] + public void ApplyAppUiAttributes_WithVisibility_IncludesVisibilityInUiObject() + { + var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!; + var tool = McpServerTool.Create(method, target: null); + + McpApps.ApplyAppUiAttributes(tool); + + var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://model-only/view.html", uiNode["resourceUri"]?.GetValue()); + + var visibility = uiNode["visibility"]?.AsArray(); + Assert.NotNull(visibility); + Assert.Single(visibility); + Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue()); + } + + [Fact] + public void ApplyAppUiAttributes_ExplicitMeta_TakesPrecedence() + { + // Explicit Meta["ui"] in options should override the attribute + var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!; + var explicitMeta = new JsonObject + { + ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/override.html" }, + }; + + var tool = McpServerTool.Create(method, target: null, new McpServerToolCreateOptions { Meta = explicitMeta }); + + McpApps.ApplyAppUiAttributes(tool); + + var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); + // Explicit Meta["ui"] wins — ApplyAppUiAttributes does not overwrite + Assert.Equal("ui://explicit/override.html", uiNode?["resourceUri"]?.GetValue()); + } + + [Fact] + public void ApplyAppUiAttributes_Collection_ProcessesAllTools() + { + var tools = new[] + { + McpServerTool.Create(typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!, target: null), + McpServerTool.Create(typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!, target: null), + }; + + McpApps.ApplyAppUiAttributes(tools); + + Assert.NotNull(tools[0].ProtocolTool.Meta?["ui"]); + Assert.NotNull(tools[1].ProtocolTool.Meta?["ui"]); + } + + [Fact] + public void ApplyAppUiAttributes_NoAttribute_DoesNothing() + { + var tool = McpServerTool.Create( + (string input) => input, + new McpServerToolCreateOptions { Name = "plain_tool" }); + + McpApps.ApplyAppUiAttributes(tool); + + Assert.Null(tool.ProtocolTool.Meta); + } + + #endregion + + #region F7: SetAppUi + + [Fact] + public void SetAppUi_PopulatesUiObject() + { + var tool = McpServerTool.Create( + (string location) => $"Weather for {location}", + new McpServerToolCreateOptions { Name = "get_weather" }); + + McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html" }); + + var meta = tool.ProtocolTool.Meta; + Assert.NotNull(meta); + + var uiNode = meta["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); + } + + [Fact] + public void SetAppUi_WithVisibility_IncludesVisibilityInUiObject() + { + var tool = McpServerTool.Create( + (string location) => $"Weather for {location}", + new McpServerToolCreateOptions { Name = "get_weather" }); + + McpApps.SetAppUi(tool, new McpUiToolMeta + { + ResourceUri = "ui://weather/view.html", + Visibility = [McpUiToolVisibility.Model], + }); + + var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + + var visibility = uiNode["visibility"]?.AsArray(); + Assert.NotNull(visibility); + Assert.Single(visibility); + Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue()); + } + + [Fact] + public void SetAppUi_DoesNotOverwrite_ExistingUiKey() + { + var tool = McpServerTool.Create( + (string location) => $"Weather for {location}", + new McpServerToolCreateOptions + { + Name = "get_weather", + Meta = new JsonObject + { + ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/view.html" }, + }, + }); + + McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://new/view.html" }); + + // Existing Meta["ui"] is preserved + var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.Equal("ui://explicit/view.html", uiNode?["resourceUri"]?.GetValue()); + } + + [Fact] + public void SetAppUi_NullResourceUri_ProducesUiObjectWithoutResourceUri() + { + var tool = McpServerTool.Create( + (string location) => $"Weather for {location}", + new McpServerToolCreateOptions { Name = "get_weather" }); + + McpApps.SetAppUi(tool, new McpUiToolMeta { Visibility = [McpUiToolVisibility.App] }); + + var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Null(uiNode["resourceUri"]); + } + + [Fact] + public void SetAppUi_ReturnsSameTool() + { + var tool = McpServerTool.Create( + (string location) => $"Weather for {location}", + new McpServerToolCreateOptions { Name = "get_weather" }); + + var result = McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html" }); + Assert.Same(tool, result); + } + + #endregion + + #region Builder Extension: WithMcpApps + + [Fact] + public void WithMcpApps_AppliesAppUiAttributes_ViaOptions() + { + var sc = new ServiceCollection(); + sc.AddMcpServer() + .WithTools([typeof(TestToolsWithAppUi)]) + .WithMcpApps(); + + using var sp = sc.BuildServiceProvider(); + var options = sp.GetRequiredService>().Value; + + Assert.NotNull(options.ToolCollection); + Assert.NotEmpty(options.ToolCollection); + + // Both tools should have their [McpAppUi] attributes applied + var toolsWithUi = options.ToolCollection.Where(t => t.ProtocolTool.Meta?["ui"] is not null).ToList(); + Assert.Equal(2, toolsWithUi.Count); + + var weatherTool = toolsWithUi.First(t => t.ProtocolTool.Meta!["ui"]!["resourceUri"]?.GetValue() == "ui://weather/view.html"); + Assert.NotNull(weatherTool); + } + + [Fact] + public void WithMcpApps_EmptyToolCollection_DoesNotThrow() + { + var sc = new ServiceCollection(); + sc.AddMcpServer() + .WithMcpApps(); + + using var sp = sc.BuildServiceProvider(); + var options = sp.GetRequiredService>().Value; + + // Should be a no-op when no tools are registered + Assert.True(options.ToolCollection is null || options.ToolCollection.IsEmpty); + } + + [Fact] + public void WithMcpApps_AdvertisesServerCapability() + { + var sc = new ServiceCollection(); + sc.AddMcpServer() + .WithMcpApps(); + + using var sp = sc.BuildServiceProvider(); + var options = sp.GetRequiredService>().Value; + + Assert.NotNull(options.Capabilities); + Assert.NotNull(options.Capabilities.Extensions); + Assert.True(options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)); + } + + #endregion + + #region Test helper types + + [McpServerToolType] + private static class TestToolsWithAppUi + { + [McpServerTool] + [McpAppUi(ResourceUri = "ui://weather/view.html")] + [Description("Get weather")] + public static string WeatherTool(string location) => $"Weather for {location}"; + + [McpServerTool] + [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])] + public static string ModelOnlyTool(string location) => $"Model only for {location}"; + } + + #endregion +} From 4dd58c1e110d88407d7f66fe6173346c51ae1198 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Thu, 18 Jun 2026 17:26:21 -0700 Subject: [PATCH 31/50] Fix broken link to Enterprise-Managed Authorization spec (#1663) --- docs/concepts/transports/transports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index a3dda4ddf..354299ce1 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -381,7 +381,7 @@ Like [stdio](#stdio-transport), the in-memory transport is inherently single-ses ## Cross-Application Access -The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. +The SDK provides built-in support for the [Identity Assertion Authorization Grant (ID-JAG) flow](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx) via `IdentityAssertionGrantProvider`. This enables non-interactive enterprise SSO scenarios where users authenticate once via their enterprise Identity Provider (IdP) and access MCP servers without per-server authorization prompts. The flow consists of two steps: 1. **RFC 8693 Token Exchange** at the enterprise IdP: OIDC ID token → JWT Authorization Grant (JAG) From 9b783d7a3b2ac49b63746732633676aaa8a052f1 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Mon, 22 Jun 2026 06:20:35 -0700 Subject: [PATCH 32/50] Implementation for SEP-2350 Client-side scope accumulation in step-up authorization (#1591) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- .../run-conformance-from-branch/SKILL.md | 76 ++++ .../Authentication/ClientOAuthProvider.cs | 114 +++++- .../ProtectedResourceMetadata.cs | 3 +- .../OAuth/AuthTests.cs | 330 +++++++++++++++++- 4 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 .github/skills/run-conformance-from-branch/SKILL.md diff --git a/.github/skills/run-conformance-from-branch/SKILL.md b/.github/skills/run-conformance-from-branch/SKILL.md new file mode 100644 index 000000000..ee2de1986 --- /dev/null +++ b/.github/skills/run-conformance-from-branch/SKILL.md @@ -0,0 +1,76 @@ +--- +name: run-conformance-from-branch +description: Run MCP conformance tests in the C# SDK against a conformance branch (including forks) instead of the published npm version, then restore pinned dependencies. +compatibility: Requires npm, node, and dotnet SDK. Uses the csharp-sdk repo package.json/package-lock.json and tests/ModelContextProtocol.AspNetCore.Tests. +--- + +# Run Conformance From Branch + +Run C# SDK conformance tests against an unpublished `modelcontextprotocol/conformance` branch (including branches in forks). + +## Use Cases + +- Validate a conformance PR before it is published to npm +- Validate C# SDK behavior against a fork with custom scenario changes +- Reproduce failures caused by conformance changes + +## Safety / Repo Hygiene + +1. Start from a clean git state. +2. Commit or stash local changes first. +3. Restore pinned dependencies when done (`npm ci`). + +## Inputs + +- **Source type**: `upstream-branch` or `fork-branch` +- **Source locator**: + - Upstream branch: `modelcontextprotocol/conformance#` + - Fork branch: `/conformance#` +- **Scenario** (optional): e.g. `auth/scope-step-up` + +## Workflows + +### A) Install directly from GitHub branch (upstream or fork) + +From `csharp-sdk` root: + +```bash +npm install --no-save @modelcontextprotocol/conformance@github:/conformance# +``` + +Examples: + +```bash +npm install --no-save @modelcontextprotocol/conformance@github:modelcontextprotocol/conformance#main +npm install --no-save @modelcontextprotocol/conformance@github:myuser/conformance#sep-2350-check +``` + +## Run Tests + +### Run client conformance tests with dotnet test filter: + +```bash +dotnet test tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ClientConformanceTests" +``` + +### Run server conformance tests with dotnet test filter: + +```bash +dotnet test tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ServerConformanceTests" +``` + +## Reporting + +Always report: + +1. Installed conformance source (`npm ls @modelcontextprotocol/conformance --depth=0`) +2. Scenario results (pass/fail/warnings) +3. Any new check IDs observed (for traceability) + +## Cleanup / Restore + +Return repo to pinned dependency state: + +```bash +npm ci +``` diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 8dbd3c394..c3b1af736 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -48,6 +48,14 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private string? _tokenEndpointAuthMethod; private ITokenCache _tokenCache; private AuthorizationServerMetadata? _authServerMetadata; + // The accumulated scope set lives for this provider's lifetime and is intentionally not keyed by + // resource or authorization server. This is safe today because one ClientOAuthProvider is created + // per HttpClientTransport, i.e. per endpoint/resource. If a provider were ever reused across + // multiple resources or auth servers, accumulated scopes could be sent to a server that rejects + // them (invalid_scope). Accumulation is scoped per "resource and operation" combination (SEP-2350). + private readonly HashSet _accumulatedScopes = new(StringComparer.Ordinal); + private readonly object _scopeAccumulatorLock = new(); + private bool _hasAttemptedStepUp; /// /// Initializes a new instance of the class using the specified options. @@ -245,6 +253,28 @@ private async Task GetAccessTokenAsync(HttpResponseMessage response, boo ThrowFailedToHandleUnauthorizedResponse("No authorization servers found in authentication challenge"); } + // SEP-2350: A step-up may legitimately introduce new scopes, so at least one interactive + // (re-)authorization attempt is always allowed. However, once a step-up has already been + // attempted, a subsequent insufficient_scope challenge that introduces no scope beyond those + // already requested cannot make progress by re-running authorization. Treat that repeated, + // unproductive challenge as a permanent authorization failure instead of prompting the user + // again for the same resource and operation combination. + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + bool introducesNewScopes = ChallengeIntroducesNewScopes(protectedResourceMetadata); + lock (_scopeAccumulatorLock) + { + if (_hasAttemptedStepUp && !introducesNewScopes) + { + ThrowFailedToHandleUnauthorizedResponse( + "A repeated insufficient_scope challenge added no scope beyond those already requested, " + + "so step-up authorization cannot satisfy the request."); + } + + _hasAttemptedStepUp = true; + } + } + // Convert string URIs to Uri objects for the selector List authServerUris = []; foreach (var serverUriString in availableAuthorizationServers) @@ -729,17 +759,93 @@ private async Task PerformDynamicClientRegistrationAsync( } private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata) + { + // Determine the scopes for the current operation from the challenge or metadata. + var currentOperationScopes = GetCurrentOperationScopes(protectedResourceMetadata); + + if (currentOperationScopes.Count == 0) + { + lock (_scopeAccumulatorLock) + { + // If we have previously requested scopes but nothing new, return the accumulated set. + return _accumulatedScopes.Count > 0 + ? string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)) + : null; + } + } + + // Per SEP-2350: Compute the union of previously requested scopes and newly challenged scopes + // to avoid losing permissions needed for other operations during step-up authorization. + // Note: the accumulator stores only server-challenged / scopes_supported / configured scopes. + // offline_access (AugmentScopeWithOfflineAccess) and any ScopeSelector are applied per request + // in ComputeEffectiveScope and are intentionally not accumulated, so the selector always sees + // the full union and the operation stays idempotent. + lock (_scopeAccumulatorLock) + { + foreach (var scope in currentOperationScopes) + { + _accumulatedScopes.Add(scope); + } + + // Sort scopes for stable, deterministic output (scopes are unordered per RFC 6749 §3.3). + return string.Join(" ", _accumulatedScopes.OrderBy(s => s, StringComparer.Ordinal)); + } + } + + /// + /// Determines the scopes required for the current operation, preferring the WWW-Authenticate + /// challenge scope, then scopes_supported from the protected resource metadata, then the + /// configured scopes. Returns the individual scope tokens so callers can compare and accumulate them + /// without re-joining and re-splitting. This does not mutate the accumulated scope set. + /// + private IReadOnlyList GetCurrentOperationScopes(ProtectedResourceMetadata protectedResourceMetadata) { if (!string.IsNullOrEmpty(protectedResourceMetadata.WwwAuthenticateScope)) { - return protectedResourceMetadata.WwwAuthenticateScope; + return SplitScopes(protectedResourceMetadata.WwwAuthenticateScope!); } - else if (protectedResourceMetadata.ScopesSupported.Count > 0) + + var scopesSupported = protectedResourceMetadata.ScopesSupported; + if (scopesSupported.Count > 0) { - return string.Join(" ", protectedResourceMetadata.ScopesSupported); + // scopes_supported is already a list of individual scopes; avoid join/split round-tripping. + return scopesSupported as IReadOnlyList ?? [.. scopesSupported]; } - return _configuredScopes; + return _configuredScopes is null ? [] : SplitScopes(_configuredScopes); + } + + private static string[] SplitScopes(string scopes) => + scopes.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + /// + /// Returns if the current challenge requires at least one scope that has not + /// already been requested in a previous (re-)authorization. The caller combines this with step-up + /// attempt tracking: per SEP-2350, a step-up that adds a new scope is always allowed, but once a + /// step-up has been attempted, a later challenge that adds no new scope is treated as a permanent + /// failure because re-running interactive authorization cannot make progress. + /// + private bool ChallengeIntroducesNewScopes(ProtectedResourceMetadata protectedResourceMetadata) + { + var currentOperationScopes = GetCurrentOperationScopes(protectedResourceMetadata); + if (currentOperationScopes.Count == 0) + { + // No concrete scope to request, so a re-authorization cannot add anything new. + return false; + } + + lock (_scopeAccumulatorLock) + { + foreach (var scope in currentOperationScopes) + { + if (!_accumulatedScopes.Contains(scope)) + { + return true; + } + } + } + + return false; } /// diff --git a/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs b/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs index b6204fdf5..4b0a9ebe6 100644 --- a/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs +++ b/src/ModelContextProtocol.Core/Authentication/ProtectedResourceMetadata.cs @@ -190,7 +190,8 @@ public sealed class ProtectedResourceMetadata /// The scopes included in the WWW-Authenticate challenge MAY match scopes_supported, be a subset or superset of it, /// or an alternative collection that is neither a strict subset nor superset. Clients MUST NOT assume any particular /// set relationship between the challenged scope set and scopes_supported. Clients MUST treat the scopes provided - /// in the challenge as authoritative for satisfying the current request. + /// in the challenge as authoritative for the current operation. When re-authorizing, clients SHOULD include these + /// scopes alongside any previously granted scopes to avoid losing permissions needed for other operations (SEP-2350). /// /// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 84c25e38c..e49cb5bf5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -409,7 +409,9 @@ public async Task AuthorizationFlow_UsesScopeFromProtectedResourceMetadata() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - Assert.Equal("mcp:tools files:read", requestedScope); + var requestedScopeSet = new HashSet(requestedScope!.Split(' ')); + Assert.Contains("mcp:tools", requestedScopeSet); + Assert.Contains("files:read", requestedScopeSet); } [Fact] @@ -473,9 +475,13 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader() McpServerTool.Create([McpServerTool(Name = "admin-tool")] (ClaimsPrincipal user) => { - // Tool now just checks if user has the required scopes - // If they don't, it shouldn't get here due to middleware - Assert.True(user.HasClaim("scope", adminScopes), "User should have admin scopes when tool executes"); + // Verify the user's scope claim contains all required admin scopes. + // With scope accumulation (SEP-2350), the token scope will be the union + // of previously granted and newly challenged scopes. + var scopeClaim = user.FindFirst("scope")?.Value ?? ""; + var scopeSet = new HashSet(scopeClaim.Split(' ')); + Assert.Contains("admin:read", scopeSet); + Assert.Contains("admin:write", scopeSet); return "Admin tool executed."; }), ]); @@ -510,9 +516,11 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader() if (toolCallParams?.Name == "admin-tool") { - // Check if user has required scopes + // Check if user has required scopes (scope claim contains all admin scopes) var user = context.User; - if (!user.HasClaim("scope", adminScopes)) + var scopeClaim = user.FindFirst("scope")?.Value ?? ""; + var scopeSet = new HashSet(scopeClaim.Split(' ')); + if (!scopeSet.Contains("admin:read") || !scopeSet.Contains("admin:write")) { // User lacks required scopes, return 403 before MapMcp processes the request context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -554,7 +562,315 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader() var adminResult = await client.CallToolAsync("admin-tool", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("Admin tool executed.", adminResult.Content[0].ToString()); - Assert.Equal(adminScopes, requestedScope); + // SEP-2350: Verify that the step-up authorization request includes the union + // of previously requested scopes (mcp:tools) and newly challenged scopes (admin:read admin:write). + var requestedScopeSet = new HashSet(requestedScope!.Split(' ')); + Assert.Contains("mcp:tools", requestedScopeSet); + Assert.Contains("admin:read", requestedScopeSet); + Assert.Contains("admin:write", requestedScopeSet); + } + + [Fact] + public async Task AuthorizationFlow_AccumulatesScopesAcrossMultipleStepUps() + { + // SEP-2350: Verify scope accumulation across multiple step-up authorization challenges. + // First call requires "files:read", second call requires "files:write". + // The second authorization request should include both "mcp:tools files:read files:write". + + Builder.Services.AddMcpServer() + .WithTools([ + McpServerTool.Create([McpServerTool(Name = "read-tool")] + (ClaimsPrincipal user) => + { + return "Read tool executed."; + }), + McpServerTool.Create([McpServerTool(Name = "write-tool")] + (ClaimsPrincipal user) => + { + return "Write tool executed."; + }), + ]); + + List requestedScopes = []; + + await using var app = await StartMcpServerAsync(configureMiddleware: app => + { + app.Use(async (context, next) => + { + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/") + { + context.Request.EnableBuffering(); + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), + context.RequestAborted) as JsonRpcMessage; + + context.Request.Body.Position = 0; + + if (message is JsonRpcRequest request && request.Method == "tools/call") + { + var toolCallParams = JsonSerializer.Deserialize( + request.Params, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; + + var user = context.User; + var scopeClaim = user.FindFirst("scope")?.Value ?? ""; + var scopeSet = new HashSet(scopeClaim.Split(' ')); + + if (toolCallParams?.Name == "read-tool" && !scopeSet.Contains("files:read")) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:read\""; + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + return; + } + + if (toolCallParams?.Name == "write-tool" && !scopeSet.Contains("files:write")) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:write\""; + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + return; + } + } + } + + await next(context); + }); + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScopes.Add(query["scope"].ToString()); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + // Initial auth gets "mcp:tools" from protected resource metadata + Assert.Single(requestedScopes); + Assert.Equal("mcp:tools", requestedScopes[0]); + + // First step-up: read-tool requires "files:read" + var readResult = await client.CallToolAsync("read-tool", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Read tool executed.", readResult.Content[0].ToString()); + Assert.Equal(2, requestedScopes.Count); + var secondScopeSet = new HashSet(requestedScopes[1]!.Split(' ')); + Assert.Contains("mcp:tools", secondScopeSet); + Assert.Contains("files:read", secondScopeSet); + + // Second step-up: write-tool requires "files:write" + var writeResult = await client.CallToolAsync("write-tool", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("Write tool executed.", writeResult.Content[0].ToString()); + Assert.Equal(3, requestedScopes.Count); + var thirdScopeSet = new HashSet(requestedScopes[2]!.Split(' ')); + Assert.Contains("mcp:tools", thirdScopeSet); + Assert.Contains("files:read", thirdScopeSet); + Assert.Contains("files:write", thirdScopeSet); + } + + [Fact] + public async Task AuthorizationFlow_StopsSteppingUpWhenChallengeAddsNoNewScope() + { + // SEP-2350: A misconfigured server repeats the same insufficient_scope challenge even after the + // client has already requested that scope. Re-running interactive authorization cannot make + // progress, so the client must treat it as a permanent failure rather than prompting the user + // again on every call to the same resource and operation. + + Builder.Services.AddMcpServer() + .WithTools([ + McpServerTool.Create([McpServerTool(Name = "deny-tool")] + (ClaimsPrincipal user) => + { + return "Deny tool executed."; + }), + ]); + + List requestedScopes = []; + + await using var app = await StartMcpServerAsync(configureMiddleware: app => + { + app.Use(async (context, next) => + { + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/") + { + context.Request.EnableBuffering(); + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), + context.RequestAborted) as JsonRpcMessage; + + context.Request.Body.Position = 0; + + if (message is JsonRpcRequest request && request.Method == "tools/call") + { + var toolCallParams = JsonSerializer.Deserialize( + request.Params, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; + + // Always reject "deny-tool" with the same challenge, regardless of the token's scopes. + if (toolCallParams?.Name == "deny-tool") + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"files:read\""; + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + return; + } + } + } + + await next(context); + }); + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScopes.Add(query["scope"].ToString()); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + // Initial auth gets "mcp:tools" from protected resource metadata. + Assert.Single(requestedScopes); + + // First call introduces a new scope ("files:read"), so exactly one step-up authorization occurs. + await Assert.ThrowsAnyAsync( + () => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(2, requestedScopes.Count); + + // Second call repeats the same challenge with no new scope. The client must NOT prompt again; + // it surfaces a permanent authorization failure instead of re-running interactive authorization. + var ex = await Assert.ThrowsAnyAsync( + () => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Contains("added no scope beyond those already requested", ex.ToString()); + + // No additional authorization prompt was triggered by the second call. + Assert.Equal(2, requestedScopes.Count); + } + + [Fact] + public async Task AuthorizationFlow_AllowsOneStepUpEvenWhenChallengeAddsNoNewScope() + { + // SEP-2350 (strict reading): A step-up authorization is always allowed at least once, even when + // the challenged scope was already requested during the initial authorization. Only a *repeated* + // challenge that still adds no new scope is treated as permanent. Here the server always rejects + // "deny-tool" with the same "mcp:tools" scope that the client already requested on initial connect. + + Builder.Services.AddMcpServer() + .WithTools([ + McpServerTool.Create([McpServerTool(Name = "deny-tool")] + (ClaimsPrincipal user) => + { + return "Deny tool executed."; + }), + ]); + + List requestedScopes = []; + + await using var app = await StartMcpServerAsync(configureMiddleware: app => + { + app.Use(async (context, next) => + { + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/") + { + context.Request.EnableBuffering(); + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), + context.RequestAborted) as JsonRpcMessage; + + context.Request.Body.Position = 0; + + if (message is JsonRpcRequest request && request.Method == "tools/call") + { + var toolCallParams = JsonSerializer.Deserialize( + request.Params, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; + + // Always reject "deny-tool" challenging "mcp:tools", which the client already + // requested on initial connect, so the challenge never introduces a new scope. + if (toolCallParams?.Name == "deny-tool") + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"mcp:tools\""; + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + return; + } + } + } + + await next(context); + }); + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = (uri, redirect, ct) => + { + var query = QueryHelpers.ParseQuery(uri.Query); + requestedScopes.Add(query["scope"].ToString()); + return HandleAuthorizationUrlAsync(uri, redirect, ct); + }, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + // Initial auth already requests "mcp:tools" from protected resource metadata. + Assert.Single(requestedScopes); + Assert.Equal("mcp:tools", requestedScopes[0]); + + // First call: even though the challenged scope is not new, one step-up attempt is still allowed, + // so a second authorization request is made. + await Assert.ThrowsAnyAsync( + () => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(2, requestedScopes.Count); + + // Second call: the step-up has already been attempted and the challenge still adds no new scope, + // so the client surfaces a permanent failure without prompting again. + var ex = await Assert.ThrowsAnyAsync( + () => client.CallToolAsync("deny-tool", cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Contains("added no scope beyond those already requested", ex.ToString()); + Assert.Equal(2, requestedScopes.Count); } [Fact] From 8ddb3cc36ff5161a1f422607d5746f59f8e01129 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:24:50 -0700 Subject: [PATCH 33/50] Obsolete Roots, Sampling, and Logging surface area per SEP-2577 (#1651) Co-authored-by: Tarek Mahmoud Sayed --- docs/concepts/logging/samples/Directory.Build.props | 9 +++++++++ docs/list-of-diagnostics.md | 1 + .../AspNetCoreMcpServer/AspNetCoreMcpServer.csproj | 3 +++ samples/ChatWithTools/ChatWithTools.csproj | 3 +++ samples/EverythingServer/EverythingServer.csproj | 3 +++ .../TestServerWithHosting.csproj | 3 +++ src/Common/Obsoletions.cs | 11 ++++++++++- src/ModelContextProtocol.Core/AIContentExtensions.cs | 1 + .../Client/McpClient.Methods.cs | 3 +++ .../Client/McpClientHandlers.cs | 4 +++- .../ModelContextProtocol.Core.csproj | 5 +++++ .../Protocol/ClientCapabilities.cs | 2 ++ .../Protocol/ContentBlock.cs | 6 ++++++ .../Protocol/ContextInclusion.cs | 3 +++ .../Protocol/CreateMessageRequestParams.cs | 3 +++ .../Protocol/CreateMessageResult.cs | 3 +++ .../Protocol/InputRequest.cs | 4 ++++ .../Protocol/InputResponse.cs | 4 ++++ .../Protocol/ListRootsRequestParams.cs | 1 + .../Protocol/ListRootsResult.cs | 1 + .../Protocol/LoggingCapability.cs | 1 + .../Protocol/LoggingLevel.cs | 3 ++- .../Protocol/LoggingMessageNotificationParams.cs | 1 + src/ModelContextProtocol.Core/Protocol/ModelHint.cs | 3 +++ .../Protocol/ModelPreferences.cs | 3 +++ .../Protocol/NotificationMethods.cs | 2 ++ .../Protocol/RequestMethods.cs | 3 +++ src/ModelContextProtocol.Core/Protocol/Root.cs | 1 + .../Protocol/RootsCapability.cs | 1 + .../Protocol/RootsListChangedNotificationParams.cs | 1 + .../Protocol/SamplingCapability.cs | 1 + .../Protocol/SamplingContextCapability.cs | 1 + .../Protocol/SamplingMessage.cs | 1 + .../Protocol/SamplingToolsCapability.cs | 1 + .../Protocol/ServerCapabilities.cs | 1 + .../Protocol/SetLevelRequestParams.cs | 1 + src/ModelContextProtocol.Core/Protocol/ToolChoice.cs | 3 +++ .../Server/DestinationBoundMcpServer.cs | 1 + .../Server/McpRequestFilters.cs | 1 + .../Server/McpServer.Methods.cs | 5 +++++ src/ModelContextProtocol.Core/Server/McpServer.cs | 1 + .../Server/McpServerHandlers.cs | 1 + src/ModelContextProtocol.Core/Server/McpServerImpl.cs | 1 + .../Server/McpServerOptions.cs | 1 + .../McpRequestFilterBuilderExtensions.cs | 1 + .../McpServerBuilderExtensions.cs | 1 + src/ModelContextProtocol/ModelContextProtocol.csproj | 5 +++++ tests/Directory.Build.props | 3 +++ .../Server/McpServerTests.cs | 3 +++ 49 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 docs/concepts/logging/samples/Directory.Build.props diff --git a/docs/concepts/logging/samples/Directory.Build.props b/docs/concepts/logging/samples/Directory.Build.props new file mode 100644 index 000000000..22f945ad0 --- /dev/null +++ b/docs/concepts/logging/samples/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + $(NoWarn);MCP9005 + + diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 7ef7c67ab..414118831 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -39,3 +39,4 @@ When APIs are marked as obsolete, a diagnostic is emitted to warn users that the | `MCP9002` | Removed | The `AddXxxFilter` extension methods on `IMcpServerBuilder` (e.g., `AddListToolsFilter`, `AddCallToolFilter`, `AddIncomingMessageFilter`) were superseded by `WithRequestFilters()` and `WithMessageFilters()`. | | `MCP9003` | In place | The `RequestContext(McpServer, JsonRpcRequest)` constructor is obsolete. Use the overload that accepts a `parameters` argument: `RequestContext(McpServer, JsonRpcRequest, TParams)`. | | `MCP9004` | In place | opts into the legacy SSE transport which has no built-in HTTP-level backpressure. Use Streamable HTTP instead. See [Stateless — 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. | diff --git a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj index ad83541dd..29aebc0d2 100644 --- a/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj +++ b/samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj @@ -5,6 +5,9 @@ enable enable true + + $(NoWarn);MCP9005 diff --git a/samples/ChatWithTools/ChatWithTools.csproj b/samples/ChatWithTools/ChatWithTools.csproj index 780f2820f..7a6b93eb1 100644 --- a/samples/ChatWithTools/ChatWithTools.csproj +++ b/samples/ChatWithTools/ChatWithTools.csproj @@ -5,6 +5,9 @@ net8.0 enable enable + + $(NoWarn);MCP9005 + $(NoWarn);MCP9005 diff --git a/samples/TestServerWithHosting/TestServerWithHosting.csproj b/samples/TestServerWithHosting/TestServerWithHosting.csproj index 9eca173cf..e90a1b244 100644 --- a/samples/TestServerWithHosting/TestServerWithHosting.csproj +++ b/samples/TestServerWithHosting/TestServerWithHosting.csproj @@ -6,6 +6,9 @@ enable enable true + + $(NoWarn);MCP9005 diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 46ea782d8..6dfdc15f2 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -28,9 +28,18 @@ internal static class Obsoletions public const string RequestContextParamsConstructor_DiagnosticId = "MCP9003"; public const string RequestContextParamsConstructor_Message = "Use the constructor overload that accepts a parameters argument."; - public const string RequestContextParamsConstructor_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcp9003"; + public const string RequestContextParamsConstructor_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; public const string EnableLegacySse_DiagnosticId = "MCP9004"; public const string EnableLegacySse_Message = "Legacy SSE transport has no built-in request backpressure and should only be used with completely trusted clients in isolated processes. Use Streamable HTTP instead."; public const string EnableLegacySse_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; + + // SEP-2577 deprecates the Roots, Sampling, and Logging features as a single coordinated + // deprecation. They share one diagnostic ID (MCP9005) so consumers can opt out with a single + // suppression, while the feature-specific messages keep the diagnostics distinguishable. + public const string Deprecated_DiagnosticId = "MCP9005"; + public const string Deprecated_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; + public const string DeprecatedRoots_Message = "The Roots 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 DeprecatedSampling_Message = "The Sampling 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 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."; } diff --git a/src/ModelContextProtocol.Core/AIContentExtensions.cs b/src/ModelContextProtocol.Core/AIContentExtensions.cs index bf5fd05de..affb8b20a 100644 --- a/src/ModelContextProtocol.Core/AIContentExtensions.cs +++ b/src/ModelContextProtocol.Core/AIContentExtensions.cs @@ -34,6 +34,7 @@ public static class AIContentExtensions /// /// /// is . + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static Func, CancellationToken, ValueTask> CreateSamplingHandler( this IChatClient chatClient, JsonSerializerOptions? serializerOptions = null) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index f3355e76d..22245a924 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -1208,6 +1208,7 @@ public async ValueTask> CallToolRawAsync( /// The to monitor for cancellation requests. The default is . /// A task representing the asynchronous operation. /// The request failed or the server returned an error response. + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public Task SetLoggingLevelAsync(LogLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) => SetLoggingLevelAsync(McpServerImpl.ToLoggingLevel(level), options, cancellationToken); @@ -1219,6 +1220,7 @@ public Task SetLoggingLevelAsync(LogLevel level, RequestOptions? options = null, /// The to monitor for cancellation requests. The default is . /// A task representing the asynchronous operation. /// The request failed or the server returned an error response. + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public Task SetLoggingLevelAsync(LoggingLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) { return SetLoggingLevelAsync( @@ -1238,6 +1240,7 @@ public Task SetLoggingLevelAsync(LoggingLevel level, RequestOptions? options = n /// The result of the request. /// is . /// The request failed or the server returned an error response. + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public Task SetLoggingLevelAsync( SetLevelRequestParams requestParams, CancellationToken cancellationToken = default) diff --git a/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs b/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs index 0866e4aef..396b5876b 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientHandlers.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; @@ -50,6 +50,7 @@ public sealed class McpClientHandlers /// This handler is invoked when the server sends a request to retrieve available roots. /// The handler receives request parameters and should return a containing the collection of available roots. /// + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public Func>? RootsHandler { get; set; } /// @@ -85,5 +86,6 @@ public sealed class McpClientHandlers /// method with any implementation of . /// /// + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public Func, CancellationToken, ValueTask>? SamplingHandler { get; set; } } diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 23045b317..455c36798 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -9,6 +9,11 @@ README.md $(NoWarn);MCPEXP001 + + $(NoWarn);MCP9005 diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index f41f50fd8..fd93dcee7 100644 --- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs @@ -52,6 +52,7 @@ public sealed class ClientCapabilities /// /// [JsonPropertyName("roots")] + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public RootsCapability? Roots { get; set; } /// @@ -59,6 +60,7 @@ public sealed class ClientCapabilities /// supports issuing requests to an LLM on behalf of the server. /// [JsonPropertyName("sampling")] + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public SamplingCapability? Sampling { get; set; } /// diff --git a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs index 83cc9d16b..d0b1b80ec 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContentBlock.cs @@ -760,6 +760,9 @@ public sealed class ResourceLinkBlock : ContentBlock /// Represents a request from the assistant to call a tool. [DebuggerDisplay("Name = {Name}, Id = {Id}")] +// Sampling support type: this content block only appears inside sampling messages (an assistant tool call), +// so it is deprecated together with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ToolUseContentBlock : ContentBlock { /// @@ -789,6 +792,9 @@ public sealed class ToolUseContentBlock : ContentBlock /// Represents the result of a tool use, provided by the user back to the assistant. [DebuggerDisplay("{DebuggerDisplay,nq}")] +// Sampling support type: this content block only appears inside sampling messages (a tool result returned to +// the assistant), so it is deprecated together with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ToolResultContentBlock : ContentBlock { /// diff --git a/src/ModelContextProtocol.Core/Protocol/ContextInclusion.cs b/src/ModelContextProtocol.Core/Protocol/ContextInclusion.cs index fbe6be56f..5979fa868 100644 --- a/src/ModelContextProtocol.Core/Protocol/ContextInclusion.cs +++ b/src/ModelContextProtocol.Core/Protocol/ContextInclusion.cs @@ -16,6 +16,9 @@ namespace ModelContextProtocol.Protocol; /// /// [JsonConverter(typeof(JsonStringEnumConverter))] +// Sampling support type: only used to select what context to include on sampling (createMessage) requests, +// so it is deprecated together with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public enum ContextInclusion { /// diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs index bb27d70fd..7a4f32984 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs @@ -11,6 +11,9 @@ namespace ModelContextProtocol.Protocol; /// /// See the schema for details. /// +// Sampling support type: "createMessage" is the sampling request, so this is deprecated together with +// sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class CreateMessageRequestParams : RequestParams { /// diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs index 94472421b..7568b4552 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs @@ -8,6 +8,9 @@ namespace ModelContextProtocol.Protocol; /// /// See the schema for details. /// +// Sampling support type: "createMessage" is the sampling request, so this result is deprecated together +// with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class CreateMessageResult : Result { /// diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs index bd9161423..68d767d99 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs @@ -54,6 +54,7 @@ public sealed class InputRequest /// /// The deserialized sampling parameters, or if the method does not match or params are absent. [JsonIgnore] + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public CreateMessageRequestParams? SamplingParams => string.Equals(Method, RequestMethods.SamplingCreateMessage, StringComparison.Ordinal) && Params is { } p ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams) @@ -76,6 +77,7 @@ public sealed class InputRequest /// /// The deserialized roots list parameters, or if the method does not match or params are absent. [JsonIgnore] + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ListRootsRequestParams? RootsParams => string.Equals(Method, RequestMethods.RootsList, StringComparison.Ordinal) && Params is { } p ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams) @@ -86,6 +88,7 @@ public sealed class InputRequest /// /// The sampling request parameters. /// A new instance. + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static InputRequest ForSampling(CreateMessageRequestParams requestParams) { Throw.IfNull(requestParams); @@ -116,6 +119,7 @@ public static InputRequest ForElicitation(ElicitRequestParams requestParams) /// /// The roots list request parameters. /// A new instance. + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static InputRequest ForRootsList(ListRootsRequestParams requestParams) { Throw.IfNull(requestParams); diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs index 465ea3235..d2e51eeb1 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -58,6 +58,7 @@ public sealed class InputResponse /// when the corresponding is /// . /// + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static JsonTypeInfo CreateMessageResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult; /// @@ -65,6 +66,7 @@ public sealed class InputResponse /// when the corresponding is /// . /// + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static JsonTypeInfo ListRootsResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult; /// @@ -72,6 +74,7 @@ public sealed class InputResponse /// /// The sampling result. /// A new instance. + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static InputResponse FromSamplingResult(CreateMessageResult result) { Throw.IfNull(result); @@ -100,6 +103,7 @@ public static InputResponse FromElicitResult(ElicitResult result) /// /// The roots list result. /// A new instance. + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static InputResponse FromRootsResult(ListRootsResult result) { Throw.IfNull(result); diff --git a/src/ModelContextProtocol.Core/Protocol/ListRootsRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ListRootsRequestParams.cs index 5f3bf5d0f..602ee502b 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListRootsRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListRootsRequestParams.cs @@ -8,4 +8,5 @@ namespace ModelContextProtocol.Protocol; /// The client responds with a containing the client's roots. /// See the schema for details. /// +[Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ListRootsRequestParams : RequestParams; diff --git a/src/ModelContextProtocol.Core/Protocol/ListRootsResult.cs b/src/ModelContextProtocol.Core/Protocol/ListRootsResult.cs index 115283e98..66debfef8 100644 --- a/src/ModelContextProtocol.Core/Protocol/ListRootsResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ListRootsResult.cs @@ -16,6 +16,7 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// +[Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ListRootsResult : Result { /// diff --git a/src/ModelContextProtocol.Core/Protocol/LoggingCapability.cs b/src/ModelContextProtocol.Core/Protocol/LoggingCapability.cs index 18d8f0c28..f33072929 100644 --- a/src/ModelContextProtocol.Core/Protocol/LoggingCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/LoggingCapability.cs @@ -18,6 +18,7 @@ namespace ModelContextProtocol.Protocol; /// specification may extend this capability with additional configuration options. /// /// +[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class LoggingCapability { } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/LoggingLevel.cs b/src/ModelContextProtocol.Core/Protocol/LoggingLevel.cs index 5fadf7fbc..c8d39064c 100644 --- a/src/ModelContextProtocol.Core/Protocol/LoggingLevel.cs +++ b/src/ModelContextProtocol.Core/Protocol/LoggingLevel.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol; @@ -9,6 +9,7 @@ namespace ModelContextProtocol.Protocol; /// These values map to syslog message severities, as specified in RFC-5424. /// [JsonConverter(typeof(JsonStringEnumConverter))] +[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public enum LoggingLevel { /// Detailed debug information, typically only valuable to developers. diff --git a/src/ModelContextProtocol.Core/Protocol/LoggingMessageNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/LoggingMessageNotificationParams.cs index 600f620a5..0840c9c7c 100644 --- a/src/ModelContextProtocol.Core/Protocol/LoggingMessageNotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/LoggingMessageNotificationParams.cs @@ -20,6 +20,7 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// +[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class LoggingMessageNotificationParams : NotificationParams { /// diff --git a/src/ModelContextProtocol.Core/Protocol/ModelHint.cs b/src/ModelContextProtocol.Core/Protocol/ModelHint.cs index 37e1001a3..9be4c755f 100644 --- a/src/ModelContextProtocol.Core/Protocol/ModelHint.cs +++ b/src/ModelContextProtocol.Core/Protocol/ModelHint.cs @@ -14,6 +14,9 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// +// Sampling support type: only used inside ModelPreferences to hint a model for sampling (createMessage) +// requests, so it is deprecated together with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ModelHint { /// diff --git a/src/ModelContextProtocol.Core/Protocol/ModelPreferences.cs b/src/ModelContextProtocol.Core/Protocol/ModelPreferences.cs index 5c7a50acc..f20b33956 100644 --- a/src/ModelContextProtocol.Core/Protocol/ModelPreferences.cs +++ b/src/ModelContextProtocol.Core/Protocol/ModelPreferences.cs @@ -22,6 +22,9 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// +// Sampling support type: only used to express model selection preferences on sampling (createMessage) +// requests, so it is deprecated together with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ModelPreferences { /// diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index cab98a5bc..825abd92b 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -63,6 +63,7 @@ public static class NotificationMethods /// method to get the updated list of roots from the client. /// /// + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public const string RootsListChangedNotification = "notifications/roots/list_changed"; /// @@ -80,6 +81,7 @@ public static class NotificationMethods /// the server can determine which messages to send based on its own configuration. /// /// + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public const string LoggingMessageNotification = "notifications/message"; /// diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index 6967dd07d..a6f22148e 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -55,6 +55,7 @@ public static class RequestMethods /// /// The name of the request method sent from the server to request a list of the client's roots. /// + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public const string RootsList = "roots/list"; /// @@ -71,6 +72,7 @@ public static class RequestMethods /// send log messages with severity at or above the specified level to the client as /// notifications. /// + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public const string LoggingSetLevel = "logging/setLevel"; /// @@ -91,6 +93,7 @@ public static class RequestMethods /// based on provided messages. It is part of the sampling capability in the Model Context Protocol and enables servers to access /// client-side AI models without needing direct API access to those models. /// + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public const string SamplingCreateMessage = "sampling/createMessage"; /// diff --git a/src/ModelContextProtocol.Core/Protocol/Root.cs b/src/ModelContextProtocol.Core/Protocol/Root.cs index 622dbddb9..debeefd57 100644 --- a/src/ModelContextProtocol.Core/Protocol/Root.cs +++ b/src/ModelContextProtocol.Core/Protocol/Root.cs @@ -14,6 +14,7 @@ namespace ModelContextProtocol.Protocol; /// guidance rather than an access-control mechanism. Each root has a URI that uniquely identifies /// it and optional metadata like a human-readable name. /// +[Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class Root { /// diff --git a/src/ModelContextProtocol.Core/Protocol/RootsCapability.cs b/src/ModelContextProtocol.Core/Protocol/RootsCapability.cs index 0b2f9e762..eebcb741d 100644 --- a/src/ModelContextProtocol.Core/Protocol/RootsCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/RootsCapability.cs @@ -21,6 +21,7 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// +[Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class RootsCapability { /// diff --git a/src/ModelContextProtocol.Core/Protocol/RootsListChangedNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/RootsListChangedNotificationParams.cs index 62312ab32..b4fe33b5f 100644 --- a/src/ModelContextProtocol.Core/Protocol/RootsListChangedNotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RootsListChangedNotificationParams.cs @@ -12,4 +12,5 @@ namespace ModelContextProtocol.Protocol; /// See the schema for details. /// /// +[Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class RootsListChangedNotificationParams : NotificationParams; diff --git a/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs b/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs index cb530e795..7a4d015de 100644 --- a/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs @@ -17,6 +17,7 @@ namespace ModelContextProtocol.Protocol; /// using an AI model. The client must set a to process these requests. /// /// +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class SamplingCapability { /// diff --git a/src/ModelContextProtocol.Core/Protocol/SamplingContextCapability.cs b/src/ModelContextProtocol.Core/Protocol/SamplingContextCapability.cs index bae960f3a..a26bc5ccc 100644 --- a/src/ModelContextProtocol.Core/Protocol/SamplingContextCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/SamplingContextCapability.cs @@ -3,4 +3,5 @@ namespace ModelContextProtocol.Protocol; /// /// Represents the sampling context capability. /// +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class SamplingContextCapability; \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/SamplingMessage.cs b/src/ModelContextProtocol.Core/Protocol/SamplingMessage.cs index d929a6877..1e2fa4c10 100644 --- a/src/ModelContextProtocol.Core/Protocol/SamplingMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/SamplingMessage.cs @@ -28,6 +28,7 @@ namespace ModelContextProtocol.Protocol; /// /// [DebuggerDisplay("{DebuggerDisplay,nq}")] +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class SamplingMessage { /// diff --git a/src/ModelContextProtocol.Core/Protocol/SamplingToolsCapability.cs b/src/ModelContextProtocol.Core/Protocol/SamplingToolsCapability.cs index f93b79725..276c36089 100644 --- a/src/ModelContextProtocol.Core/Protocol/SamplingToolsCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/SamplingToolsCapability.cs @@ -3,4 +3,5 @@ namespace ModelContextProtocol.Protocol; /// /// Represents the sampling tools capability. /// +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class SamplingToolsCapability; \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs index 92ffff424..0f1c4f540 100644 --- a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs @@ -41,6 +41,7 @@ public sealed class ServerCapabilities /// Gets or sets a server's logging capability for sending log messages to the client. /// [JsonPropertyName("logging")] + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public LoggingCapability? Logging { get; set; } /// diff --git a/src/ModelContextProtocol.Core/Protocol/SetLevelRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/SetLevelRequestParams.cs index 9441d39ac..2352eb966 100644 --- a/src/ModelContextProtocol.Core/Protocol/SetLevelRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/SetLevelRequestParams.cs @@ -10,6 +10,7 @@ namespace ModelContextProtocol.Protocol; /// This request allows clients to configure the level of logging information they want to receive from the server. /// The server will send notifications for log events at the specified level and all higher (more severe) levels. /// +[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class SetLevelRequestParams : RequestParams { /// diff --git a/src/ModelContextProtocol.Core/Protocol/ToolChoice.cs b/src/ModelContextProtocol.Core/Protocol/ToolChoice.cs index ebb80552f..903e978c6 100644 --- a/src/ModelContextProtocol.Core/Protocol/ToolChoice.cs +++ b/src/ModelContextProtocol.Core/Protocol/ToolChoice.cs @@ -5,6 +5,9 @@ namespace ModelContextProtocol.Protocol; /// /// Controls tool selection behavior for sampling requests. /// +// Sampling support type: only used to configure tool selection on sampling (createMessage) requests, +// so it is deprecated together with sampling per SEP-2577. +[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public sealed class ToolChoice { /// diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index bf87980e5..b8f96237a 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -14,6 +14,7 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public override LoggingLevel? LoggingLevel => server.LoggingLevel; /// diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index e778d9d1b..9d9774e8b 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -264,6 +264,7 @@ public IList> Unsubscrib /// at or above the specified level to the client as notifications/message notifications. /// /// + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public IList> SetLoggingLevelFilters { get => field ??= []; diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index a0d788a27..99cd7e80d 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -67,6 +67,7 @@ public static McpServer Create( /// then returns to when the response is received. /// /// + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ValueTask SampleAsync( CreateMessageRequestParams requestParams, CancellationToken cancellationToken = default) @@ -107,6 +108,7 @@ public ValueTask SampleAsync( /// is . /// The client does not support sampling. /// The request failed or the client returned an error response. + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public async Task SampleAsync( IEnumerable messages, ChatOptions? chatOptions = default, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) { @@ -243,6 +245,7 @@ public async Task SampleAsync( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions = null) { ThrowIfSamplingUnsupported(); @@ -252,6 +255,7 @@ public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions /// Gets an on which logged messages will be sent as notifications to the client. /// An that can be used to log to the client. + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ILoggerProvider AsClientLoggerProvider() => new ClientLoggerProvider(this); @@ -271,6 +275,7 @@ public ILoggerProvider AsClientLoggerProvider() => /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// + [Obsolete(Obsoletions.DeprecatedRoots_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ValueTask RequestRootsAsync( ListRootsRequestParams requestParams, CancellationToken cancellationToken = default) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 444365361..fb6d7074a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -62,6 +62,7 @@ protected McpServer() public abstract IServiceProvider? Services { get; } /// Gets the last logging level set by the client, or if it's never been set. + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public abstract LoggingLevel? LoggingLevel { get; } /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index f650a0011..4f9509b9d 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -199,6 +199,7 @@ public McpRequestHandler /// + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public McpRequestHandler? SetLoggingLevelHandler { get; set; } /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index c66b24c79..fc5a709bf 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -169,6 +169,7 @@ void Register(McpServerPrimitiveCollection? collection, public override IServiceProvider? Services { get; } /// + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public override LoggingLevel? LoggingLevel => _loggingLevel?.Value; /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 32c13da27..eb99913d5 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -187,6 +187,7 @@ public McpServerFilters Filters /// This value is used in /// when is not set in the request options. /// + [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public int MaxSamplingOutputTokens { get; set; } = 1000; /// diff --git a/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs b/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs index 8ee7fb064..2ed06355f 100644 --- a/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpRequestFilterBuilderExtensions.cs @@ -165,6 +165,7 @@ public static IMcpRequestFilterBuilder AddUnsubscribeFromResourcesFilter(this IM /// The request filter builder instance. /// The filter function that wraps the handler. /// The builder provided in . + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static IMcpRequestFilterBuilder AddSetLoggingLevelFilter(this IMcpRequestFilterBuilder builder, McpRequestFilter filter) { Throw.IfNull(builder); diff --git a/src/ModelContextProtocol/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/McpServerBuilderExtensions.cs index da63dc31d..7f07be67e 100644 --- a/src/ModelContextProtocol/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/McpServerBuilderExtensions.cs @@ -878,6 +878,7 @@ public static IMcpServerBuilder WithUnsubscribeFromResourcesHandler(this IMcpSer /// most recently set level. /// /// + [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public static IMcpServerBuilder WithSetLoggingLevelHandler(this IMcpServerBuilder builder, McpRequestHandler handler) { Throw.IfNull(builder); diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 07167c438..0d717ef10 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -16,6 +16,11 @@ true + + + $(NoWarn);CS0436 + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index bc169333f..b4a79a31e 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -7,5 +7,8 @@ $(NoWarn);MCPEXP001 $(NoWarn);MCP9004 + + $(NoWarn);MCP9005 diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 7ed3c35e3..87f363f03 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -1368,7 +1368,10 @@ public override Task SendRequestAsync(JsonRpcRequest request, C public override string? NegotiatedProtocolVersion => throw new NotImplementedException(); public override Implementation? ClientInfo => throw new NotImplementedException(); public override IServiceProvider? Services => throw new NotImplementedException(); + // McpServer.LoggingLevel is obsolete (SEP-2577) but abstract, so this test double must override it. +#pragma warning disable CS0672 // Member overrides obsolete member public override LoggingLevel? LoggingLevel => throw new NotImplementedException(); +#pragma warning restore CS0672 public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public override Task RunAsync(CancellationToken cancellationToken = default) => From 9ba1f47d77a742489f9a3ff8dec72db0ee33b4de Mon Sep 17 00:00:00 2001 From: Kirill Osenkov Date: Mon, 22 Jun 2026 16:52:47 -0700 Subject: [PATCH 34/50] Enable netfx UI MCP clients (#1639) --- .../Client/StdioClientTransport.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs index b0af062b1..2e44ee34f 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs @@ -187,14 +187,29 @@ public async Task ConnectAsync(CancellationToken cancellationToken = lock (s_consoleEncodingLock) { Encoding originalInputEncoding = Console.InputEncoding; + bool encodingChanged = false; try { - Console.InputEncoding = StreamClientSessionTransport.NoBomUtf8Encoding; + try + { + Console.InputEncoding = StreamClientSessionTransport.NoBomUtf8Encoding; + encodingChanged = true; + } + catch + { + // Host has no usable console (e.g. WPF/WinForms on .NET Framework with no + // AllocConsole). The child inherits the current Console.InputEncoding; + // non-ASCII stdin may be misencoded, but the connect itself proceeds. + } + processStarted = process.Start(); } finally { - Console.InputEncoding = originalInputEncoding; + if (encodingChanged) + { + Console.InputEncoding = originalInputEncoding; + } } } #endif From 3368bb7349cc0825e5eb5c6abe86fe21cc9e4f9a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:12:15 -0700 Subject: [PATCH 35/50] Use `TimeToLive` (TimeSpan?) instead of `TtlMs` (long?) in public APIs (#1644) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: PranavSenthilnathan <12225508+PranavSenthilnathan@users.noreply.github.com> Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> Co-authored-by: Pranav Senthilnathan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompatibilitySuppressions.xml | 56 ------------------- .../Protocol/CreateTaskResult.cs | 5 +- .../Protocol/GetTaskResult.cs | 10 ++-- .../Protocol/TaskStatusNotificationParams.cs | 10 ++-- .../Server/InMemoryMcpTaskStore.cs | 6 +- .../Server/McpServerImpl.cs | 12 ++-- .../Server/McpTaskInfo.cs | 2 +- .../Protocol/TaskSerializationTests.cs | 6 +- .../Server/InMemoryMcpTaskStoreTests.cs | 6 +- .../TaskCancellationIntegrationTests.cs | 2 +- 10 files changed, 30 insertions(+), 85 deletions(-) diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index 994399364..e686b454f 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -827,13 +827,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) @@ -848,13 +841,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1212,13 +1198,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) @@ -1233,13 +1212,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1597,13 +1569,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) @@ -1618,13 +1583,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1982,13 +1940,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_TimeToLive - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) @@ -2003,13 +1954,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_TimeToLive(System.Nullable{System.TimeSpan}) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs index 2e5bc0c41..6d9bac23c 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs @@ -55,10 +55,11 @@ public sealed class CreateTaskResult : Result public required DateTimeOffset LastUpdatedAt { get; set; } /// - /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// Gets or sets the time-to-live duration from creation, or for unlimited. /// [JsonPropertyName("ttlMs")] - public long? TtlMs { get; set; } + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } /// /// Gets or sets the suggested polling interval in milliseconds. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs index 9366f2374..3fea8cc6b 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs @@ -64,10 +64,10 @@ private protected GetTaskResult() public required DateTimeOffset LastUpdatedAt { get; set; } /// - /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// Gets or sets the time-to-live duration from creation, or for unlimited. /// [JsonPropertyName("ttlMs")] - public long? TtlMs { get; set; } + public TimeSpan? TimeToLive { get; set; } /// /// Gets or sets the suggested polling interval in milliseconds. @@ -245,7 +245,7 @@ internal sealed class Converter : JsonConverter }; taskResult.StatusMessage = statusMessage; - taskResult.TtlMs = ttlMs; + taskResult.TimeToLive = ttlMs is null ? null : TimeSpan.FromMilliseconds(ttlMs.Value); taskResult.PollIntervalMs = pollIntervalMs; taskResult.ResultType = resultType; taskResult.Meta = meta; @@ -287,9 +287,9 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria writer.WriteString("createdAt", value.CreatedAt); writer.WriteString("lastUpdatedAt", value.LastUpdatedAt); - if (value.TtlMs is not null) + if (value.TimeToLive is not null) { - writer.WriteNumber("ttlMs", value.TtlMs.Value); + writer.WriteNumber("ttlMs", (long)value.TimeToLive.Value.TotalMilliseconds); } if (value.PollIntervalMs is not null) diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs index e1bbb2f73..4e859a22c 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs @@ -70,10 +70,10 @@ private protected TaskStatusNotificationParams() public required DateTimeOffset LastUpdatedAt { get; set; } /// - /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// Gets or sets the time-to-live duration from creation, or for unlimited. /// [JsonPropertyName("ttlMs")] - public long? TtlMs { get; set; } + public TimeSpan? TimeToLive { get; set; } /// /// Gets or sets the suggested polling interval in milliseconds. @@ -247,7 +247,7 @@ internal sealed class Converter : JsonConverter }; notification.StatusMessage = statusMessage; - notification.TtlMs = ttlMs; + notification.TimeToLive = ttlMs is null ? null : TimeSpan.FromMilliseconds(ttlMs.Value); notification.PollIntervalMs = pollIntervalMs; notification.Meta = meta; @@ -283,9 +283,9 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v writer.WriteString("createdAt", value.CreatedAt); writer.WriteString("lastUpdatedAt", value.LastUpdatedAt); - if (value.TtlMs is not null) + if (value.TimeToLive is not null) { - writer.WriteNumber("ttlMs", value.TtlMs.Value); + writer.WriteNumber("ttlMs", (long)value.TimeToLive.Value.TotalMilliseconds); } if (value.PollIntervalMs is not null) diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index a13af13e9..00c69844b 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -32,9 +32,9 @@ public class InMemoryMcpTaskStore : IMcpTaskStore public long DefaultPollIntervalMs { get; set; } = 1000; /// - /// Gets or sets the default time-to-live in milliseconds for new tasks, or for unlimited. + /// Gets or sets the default time-to-live for new tasks, or for unlimited. /// - public long? DefaultTtlMs { get; set; } + public TimeSpan? DefaultTimeToLive { get; set; } /// public Task CreateTaskAsync(CancellationToken cancellationToken = default) @@ -42,7 +42,7 @@ public Task CreateTaskAsync(CancellationToken cancellationToken = d var taskId = Guid.NewGuid().ToString("N"); var now = DateTimeOffset.UtcNow; - var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTtlMs, DefaultPollIntervalMs); + var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs); _tasks[taskId] = info; return Task.FromResult(info); diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index fc5a709bf..0a0721c7c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1023,7 +1023,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) Status = info.Status, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "task", @@ -1036,7 +1036,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", @@ -1046,7 +1046,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), @@ -1057,7 +1057,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), @@ -1068,7 +1068,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", @@ -1078,7 +1078,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs index ab71d6505..87fa8ef1c 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs @@ -20,7 +20,7 @@ public sealed record McpTaskInfo( McpTaskStatus Status, DateTimeOffset CreatedAt, DateTimeOffset LastUpdatedAt, - long? TtlMs = null, + TimeSpan? TimeToLive = null, long? PollIntervalMs = null, string? StatusMessage = null, JsonElement? Result = null, diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 556403add..86acc57f6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -21,7 +21,7 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie StatusMessage = "Processing...", CreatedAt = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero), LastUpdatedAt = new DateTimeOffset(2025, 6, 1, 12, 5, 0, TimeSpan.Zero), - TtlMs = 3600000, + TimeToLive = TimeSpan.FromHours(1), PollIntervalMs = 5000, ResultType = "task", Meta = new JsonObject { ["key"] = "value" } @@ -36,7 +36,7 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie Assert.Equal("Processing...", deserialized.StatusMessage); Assert.Equal(original.CreatedAt, deserialized.CreatedAt); Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); - Assert.Equal(3600000, deserialized.TtlMs); + Assert.Equal(TimeSpan.FromHours(1), deserialized.TimeToLive); Assert.Equal(5000, deserialized.PollIntervalMs); Assert.Equal("task", deserialized.ResultType); Assert.NotNull(deserialized.Meta); @@ -52,7 +52,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames() Status = McpTaskStatus.Working, CreatedAt = DateTimeOffset.UtcNow, LastUpdatedAt = DateTimeOffset.UtcNow, - TtlMs = 60000, + TimeToLive = TimeSpan.FromMinutes(1), PollIntervalMs = 1000, ResultType = "task", }; diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 7c87786eb..83afbfe23 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -55,13 +55,13 @@ public async Task CreateTaskAsync_UsesDefaultPollInterval() } [Fact] - public async Task CreateTaskAsync_UsesDefaultTtl() + public async Task CreateTaskAsync_UsesDefaultTimeToLive() { - var store = new InMemoryMcpTaskStore { DefaultTtlMs = 30000 }; + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromSeconds(30) }; var result = await store.CreateTaskAsync(CT); - Assert.Equal(30000, result.TtlMs); + Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index 1b424d002..0e8693353 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -34,7 +34,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer options.TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - DefaultTtlMs = 5000, + DefaultTimeToLive = TimeSpan.FromSeconds(5), }; }); From 6c323e783bf724622335c4ca572718daf5174b3c Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 23 Jun 2026 21:24:22 -0700 Subject: [PATCH 36/50] Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610) --- docs/concepts/elicitation/elicitation.md | 6 +- docs/concepts/mrtr/mrtr.md | 29 +- docs/concepts/roots/roots.md | 6 +- docs/concepts/sampling/sampling.md | 6 +- docs/concepts/stateless/stateless.md | 71 ++- docs/concepts/tools/tools.md | 2 +- docs/list-of-diagnostics.md | 5 + package-lock.json | 28 +- package.json | 2 +- src/Common/McpHttpHeaders.cs | 2 +- src/Common/Obsoletions.cs | 4 + ...ibutedCacheEventStreamStoreOptionsSetup.cs | 2 + ...edCacheEventStreamStoreOptionsValidator.cs | 2 + .../HttpMcpServerBuilderExtensions.cs | 3 + .../HttpServerTransportOptions.cs | 19 +- .../HttpServerTransportOptionsSetup.cs | 2 + .../IdleTrackingBackgroundService.cs | 4 +- .../SseEventStreamReaderExtensions.cs | 2 + .../StatefulSessionManager.cs | 4 +- .../StreamableHttpHandler.cs | 204 +++++++- .../AutoDetectingClientSessionTransport.cs | 27 +- .../Client/McpClient.Methods.cs | 38 +- .../Client/McpClientImpl.cs | 284 +++++++++-- .../Client/McpClientOptions.cs | 92 +++- .../StreamableHttpClientSessionTransport.cs | 98 +++- src/ModelContextProtocol.Core/McpErrorCode.cs | 25 + .../McpJsonUtilities.cs | 11 + src/ModelContextProtocol.Core/McpSession.cs | 32 ++ .../McpSessionHandler.cs | 195 +++++++- ...issingRequiredClientCapabilityException.cs | 67 +++ .../Protocol/DiscoverRequestParams.cs | 16 + .../Protocol/DiscoverResult.cs | 67 +++ .../Protocol/ICacheableResult.cs | 5 +- .../Protocol/JsonRpcMessage.cs | 27 +- .../Protocol/JsonRpcMessageContext.cs | 31 ++ .../Protocol/MetaKeys.cs | 76 +++ ...issingRequiredClientCapabilityErrorData.cs | 21 + .../Protocol/NotificationMethods.cs | 12 +- .../Protocol/RequestId.cs | 9 +- .../Protocol/RequestMethods.cs | 38 ++ ...criptionsAcknowledgedNotificationParams.cs | 27 ++ .../SubscriptionsListenRequestParams.cs | 71 +++ .../UnsupportedProtocolVersionErrorData.cs | 26 + .../Server/ISseEventStreamReader.cs | 1 + .../Server/ISseEventStreamStore.cs | 1 + .../Server/ISseEventStreamWriter.cs | 1 + .../Server/McpServerImpl.cs | 446 ++++++++++++++++-- .../Server/SseEventStreamMode.cs | 1 + .../Server/SseEventStreamOptions.cs | 1 + .../Server/StreamableHttpPostTransport.cs | 4 + .../Server/StreamableHttpServerTransport.cs | 5 + .../UnsupportedProtocolVersionException.cs | 74 +++ .../ModelContextProtocol.csproj | 7 +- ...DistributedCacheEventStreamStoreOptions.cs | 1 + tests/Common/Utils/NodeHelpers.cs | 92 ++-- .../AddKnownToolsHeaderTests.cs | 18 +- .../CachingConformanceTests.cs | 16 +- .../DraftHttpFallbackTests.cs | 305 ++++++++++++ .../DraftHttpHandlerTests.cs | 200 ++++++++ .../DraftStatefulFallbackTests.cs | 130 +++++ .../HttpHeaderConformanceTests.cs | 46 +- .../HttpMcpServerBuilderExtensionsTests.cs | 4 +- .../HttpServerIntegrationTests.cs | 8 +- .../MapMcpSseTests.cs | 8 +- .../MapMcpStreamableHttpTests.cs | 40 +- .../MapMcpTests.Mrtr.cs | 93 +++- .../MapMcpTests.cs | 20 +- ...delContextProtocol.AspNetCore.Tests.csproj | 4 +- .../MrtrProtocolTests.cs | 295 ++++++------ .../OAuth/OAuthTestBase.cs | 2 +- .../RawHttpConformanceTests.cs | 219 +++++++++ .../RequestAbortCancellationTests.cs | 162 +++++++ .../ResumabilityIntegrationTestsBase.cs | 8 +- .../ServerConformanceTests.cs | 50 +- .../SessionMigrationTests.cs | 2 +- .../SseIntegrationTests.cs | 13 +- .../StatelessServerTests.cs | 62 +++ .../StreamableHttpClientConformanceTests.cs | 34 +- .../StreamableHttpServerConformanceTests.cs | 63 ++- .../Program.cs | 10 + ...elContextProtocol.ConformanceServer.csproj | 1 + .../Prompts/IncompleteResultPrompts.cs | 4 +- .../Tools/IncompleteResultTools.cs | 151 +++++- .../Program.cs | 8 +- .../Client/DraftConnectionTests.cs | 91 ++++ .../Client/DraftListMetaEmissionTests.cs | 181 +++++++ .../Client/DraftProtocolFallbackTests.cs | 272 +++++++++++ .../Client/McpClientCreationTests.cs | 21 +- .../Client/McpClientMetaTests.cs | 12 +- .../Client/McpClientTests.cs | 13 +- .../Client/McpRequestHeadersTests.cs | 2 +- .../Client/MrtrIntegrationTests.cs | 85 ++-- .../ClientIntegrationTests.cs | 5 +- ...rverBuilderExtensionsMessageFilterTests.cs | 117 ++--- .../McpServerBuilderExtensionsPromptsTests.cs | 9 +- ...cpServerBuilderExtensionsResourcesTests.cs | 9 +- .../McpServerBuilderExtensionsToolsTests.cs | 8 +- .../McpServerResourceRoutingTests.cs | 14 +- .../DiagnosticTests.cs | 9 +- .../ModelContextProtocol.Tests.csproj | 4 +- .../Protocol/CancellationTests.cs | 3 + .../Protocol/DiscoverProtocolTests.cs | 80 ++++ .../Protocol/DiscoverResultCacheableTests.cs | 125 +++++ .../Protocol/DraftErrorDataTests.cs | 67 +++ .../Protocol/ElicitationTypedTests.cs | 4 +- .../Protocol/JsonRpcMessageConverterTests.cs | 67 +++ .../Protocol/RequestIdTests.cs | 28 ++ .../SubscriptionsListenProtocolTests.cs | 63 +++ .../Server/DraftProtocolBackcompatTests.cs | 14 +- .../Server/MrtrHandlerLifecycleTests.cs | 39 +- .../Server/MrtrInputRequiredExceptionTests.cs | 2 +- .../Server/MrtrMessageFilterTests.cs | 12 +- .../Server/MrtrServerBackcompatTests.cs | 2 +- .../Server/MrtrSessionLimitTests.cs | 6 +- .../Server/NegotiatedProtocolVersionTests.cs | 153 ++++++ .../Server/PingProtocolGatingTests.cs | 53 +++ .../Server/RawStreamConformanceTests.cs | 188 ++++++++ .../Server/SubscriptionsListenTests.cs | 161 +++++++ .../Server/TaskDraftGatingTests.cs | 189 ++++++++ 119 files changed, 5704 insertions(+), 712 deletions(-) create mode 100644 src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/MetaKeys.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs create mode 100644 src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 78782bfbb..003723e39 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,10 +172,10 @@ Here's an example implementation of how a console application might handle elici ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -196,7 +196,7 @@ public static string ElicitWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request user input diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 1d1ebce32..2168a300a 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -9,7 +9,7 @@ uid: mrtr > [!WARNING] -> MRTR is part of the **`DRAFT-2026-v1`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. +> MRTR is part of the **`2026-07-28`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. @@ -33,13 +33,12 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`DRAFT-2026-v1`** during `initialize`. The C# SDK opts in by listing `DRAFT-2026-v1` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers the draft revision by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support draft. Servers accept the draft automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. ```csharp -// Client +// Client — the SDK prefers the 2026-07-28 draft (and therefore MRTR) by default. var clientOptions = new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", Handlers = new McpClientHandlers { ElicitationHandler = HandleElicitationAsync, @@ -48,7 +47,7 @@ var clientOptions = new McpClientOptions }; ``` -Under `DRAFT-2026-v1`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `DRAFT-2026-v1` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. +Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (which is stateless-only under the draft revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. @@ -60,7 +59,7 @@ A tool participates in MRTR by throwing before throwing `InputRequiredException`. It returns `true` when either: -- The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or +- The negotiated protocol revision is `2026-07-28` (MRTR is native), or - The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). ```csharp @@ -71,7 +70,7 @@ public static string MyTool( { if (!server.IsMrtrSupported) { - return "This tool requires a client that negotiates DRAFT-2026-v1, " + return "This tool requires a client that negotiates 2026-07-28, " + "or a stateful current-protocol session."; } @@ -258,7 +257,7 @@ When MRTR is not supported, you can provide domain-specific guidance: if (!server.IsMrtrSupported) { return "This tool requires interactive input. To use it:\n" - + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" + + "1. Connect with a client that negotiates MCP protocol revision 2026-07-28, or\n" + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; } @@ -270,22 +269,18 @@ The SDK supports `InputRequiredException` across two protocol revisions and two | Negotiated protocol | Session mode | Behavior | |---|---|---| -| `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | -| `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | +| `2026-07-28` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | +| `2026-07-28` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | | Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | | Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | > [!NOTE] -> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). +> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `2026-07-28` (check `IsMrtrSupported`). ### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). -Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `DRAFT-2026-v1`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. +Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft 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. -### Future direction - -The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `DRAFT-2026-v1` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. - -This work is a follow-up to the present PR. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP runs statelessly whenever a client speaks the draft. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves draft requests sessionlessly and creates a legacy session only when an older client falls back to `initialize`. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 213d317c0..220887fae 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -106,10 +106,10 @@ server.RegisterNotificationHandler( ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -128,7 +128,7 @@ public static string ListRootsWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request the client's root list diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index bac6ed5ab..1dd0b90ec 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -123,10 +123,10 @@ Sampling requires the client to advertise the `sampling` capability. This is han ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -146,7 +146,7 @@ public static string SampleWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; + return "This tool requires MRTR support (2026-07-28, or a stateful current-protocol session)."; } // First call — request LLM completion from the client diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 68ce52f33..861a1cd6b 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -7,15 +7,15 @@ 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 send requests _to_ the client, push [unsolicited notifications](#how-streamable-http-delivers-messages), or maintain per-client state across requests. +The MCP [Streamable HTTP transport] uses an `Mcp-Session-Id` HTTP header to associate multiple requests with a single logical session. However, **we recommend most servers disable sessions entirely by setting to `true`**. Stateless mode avoids the complexity, memory overhead, and deployment constraints that come with sessions. Sessions are only necessary when the server needs to push [unsolicited notifications](#how-streamable-http-delivers-messages), maintain per-client state across requests, or send requests _to_ clients that don't support [MRTR](xref:mrtr). -When sessions are enabled (the current C# SDK default), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). +When sessions are enabled (`Stateless = false`), the server creates and tracks an in-memory session for each client, while the client automatically includes the session ID in subsequent requests. The [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header — this is not optional for the client. Session expiry detection and reconnection are the responsibility of the application using the client SDK (see [Client-side session behavior](#client-side-session-behavior)). [Streamable HTTP transport]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http **Quick guide — which mode should I use?** -- Does your server need to send requests _to_ the client (sampling, elicitation, roots)? → **Use stateful.** +- Does your server need to send requests _to_ the client (elicitation, sampling, roots) and can't rely on clients supporting [MRTR](xref:mrtr)? → **Use stateful.** - Does your server send [unsolicited notifications](#how-streamable-http-delivers-messages) or support resource subscriptions? → **Use stateful.** - 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.** @@ -24,19 +24,44 @@ When sessions are enabled (the current C# SDK default), the server creates and t > [!NOTE] -> **Why isn't stateless the C# SDK default?** Stateful mode remains the default for backward compatibility and because it is the only HTTP mode with full feature parity with [stdio](xref:transports) (server-to-client requests, unsolicited notifications, subscriptions). Stateless is the recommended choice when you don't need those features — see [Forward and backward compatibility](#forward-and-backward-compatibility) for guidance on choosing an explicit setting. +> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful for back-compat with the `2025-11-25` (and older) protocol revisions, which require the `Mcp-Session-Id` header. The `2026-07-28` draft revision removes that header (SEP-2567) and the `initialize` handshake (SEP-2575) entirely, so the SDK now defaults to `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The current C# SDK default is `Stateless = false` (sessions enabled), but **we expect this default to change** once mechanisms like [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) bring server-to-client interactions (sampling, elicitation, roots) to stateless mode. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` draft revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` draft and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: -- **`Stateless = true`** — the best forward-compatible choice. Your server opts out of sessions entirely. No matter how the SDK default changes in the future, your behavior stays the same. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` draft 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 draft clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for features like sampling, elicitation, roots, unsolicited notifications, or per-client isolation. Setting this explicitly protects your server from a future default change. 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 will always honor your server's session. Once [MRTR](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) or a similar mechanism is available, you may be able to migrate server-to-client interactions to stateless mode and drop sessions entirely — but until then, explicit `Stateless = false` is the safe choice. See [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions) for more on MRTR. +- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` draft, however, so keep a session if you need server-to-client requests against clients that don't speak the draft. Note that even with `Stateless = false`, draft requests are still served sessionlessly because the protocol forbids the session header — the stateful path activates only when a client falls back to a legacy revision. > [!TIP] -> If you're not sure which to pick, start with `Stateless = true`. You can switch to `Stateless = false` later if you discover you need server-to-client requests or unsolicited notifications. 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 (`Stateless = true`). You can switch to `Stateless = false` later if you discover you need unsolicited notifications or resource subscriptions. Either way, setting the property explicitly means your server's behavior won't silently change when the SDK default is updated. + +### The 2026-07-28 draft revision + +The `2026-07-28` draft revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). + +**Server side.** With `Stateless = true` (the default), the SDK already meets the draft on the wire. Any HTTP POST that arrives with the draft `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a sessionless draft request on a stateful server is refused with a `-32004 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A draft request that carries an `Mcp-Session-Id` is always rejected, since the draft revision has no session concept. + +**Stateful options marked obsolete.** Because the draft revision is unconditionally sessionless, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. + +**Client side — automatic fallback.** Clients automatically probe the draft revision first and fall back to the `initialize` handshake when the server doesn't support it: + +- **HTTP**: the client sends its first request with the draft `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32004` / `-32003` / `-32001` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms the draft revision; a `-32004` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. + +The era is cached per instance, so the probe cost is paid only on the first connect. + +**Opting out of fallback.** Set to when you want the client to refuse to fall back. The connect call throws an instead of silently degrading. This is useful for strict-modern production code and for tests that need to assert draft-only behavior. + +```csharp +var clientOptions = new McpClientOptions +{ + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, +}; +``` ### Migrating from legacy SSE @@ -93,7 +118,7 @@ When - [Roots](xref:roots) (`RequestRootsAsync`) - Ping — the server cannot ping the client to verify connectivity - The proposed [MRTR mechanism](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is designed to bring these capabilities to stateless mode, but it is not yet available. + [MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak the `2026-07-28` draft — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). - **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. - **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. - **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means. @@ -115,17 +140,13 @@ Most MCP servers fall into this category. Tools that call APIs, query databases, ### Stateless alternatives for server-to-client interactions - -> [!NOTE] -> Multi Round-Trip Requests (MRTR) is a proposed experimental feature that is not yet available. See PR [#1458](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for the reference implementation and specification proposal. +The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a sessionless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. -The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) is a proposed alternative that works with stateless servers by inverting the communication model — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. - -This means servers that need user confirmation, LLM reasoning, or other client input can still run in stateless mode when both sides support MRTR. +This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` draft, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak the draft. ## Stateful mode (sessions) -When is `false` (the default), the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: +When is `false`, the server assigns an `Mcp-Session-Id` to each client during the `initialize` handshake when the client speaks the `2025-11-25` (or earlier) protocol revision. The client must include this header in all subsequent requests. The server maintains an in-memory session for each connected client, enabling: - Server-to-client requests (sampling, elicitation, roots) via an open HTTP response stream - [Unsolicited notifications](#how-streamable-http-delivers-messages) (resource updates, logging messages) via the GET stream @@ -154,7 +175,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing | | **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize | | **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) | -| **Server-to-client requests** | Not supported (see [MRTR proposal](https://github.com/modelcontextprotocol/csharp-sdk/pull/1458) for a stateless alternative) | Supported (sampling, elicitation, roots) | +| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (draft-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) | | **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | | **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | @@ -396,14 +417,16 @@ builder.Services.AddMcpServer() | Property | Type | Default | Description | |----------|------|---------|-------------| -| | `bool` | `false` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests. | -| | `TimeSpan` | 2 hours | Duration of inactivity before a session is closed. Checked every 5 seconds. | -| | `int` | 10,000 | Maximum idle sessions before the oldest are forcibly terminated. | -| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode, this runs on every HTTP request. | +| | `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` draft revision. | +| | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | +| | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. | +| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all draft-revision requests), this runs on every HTTP request. | | | `Func?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. | -| | `ISessionMigrationHandler?` | `null` | Enables cross-instance session migration. Can also be registered in DI. | -| | `ISseEventStreamStore?` | `null` | Stores SSE events for session resumability via `Last-Event-ID`. Can also be registered in DI. | -| | `bool` | `false` | Uses a single `ExecutionContext` for the entire session instead of per-request. Enables session-scoped `AsyncLocal` values but prevents `IHttpContextAccessor` from working in handlers. | +| | `ISessionMigrationHandler?` | `null` | _Stateful only (`MCP9006`)._ Enables cross-instance session migration. Can also be registered in DI. | +| | `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 sessionlessly (every draft-revision request, plus every request on a server with `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 4936f4e5d..ae552e32c 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -339,7 +339,7 @@ Rules and constraints: - The header name must contain only visible ASCII characters (0x21–0x7E) excluding colon (`:`). - Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. - Header names must be case-insensitively unique within the tool's input schema. -- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later). +- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `2026-07-28` and later). ### Pre-loading tool definitions on the client diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 414118831..9bf6c982c 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -1,3 +1,7 @@ +--- +uid: list-of-diagnostics +--- + # List of Diagnostics Produced by MCP C# SDK This document provides information about each of the diagnostics produced by the MCP C# SDK analyzers and source generators. @@ -40,3 +44,4 @@ 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 — 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`. The draft protocol revision (`2026-07-28`) is sessionless, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | diff --git a/package-lock.json b/package-lock.json index 521815617..77ce83884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "csharp-sdk", + "name": "halter73-expert-train", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.1.16", + "@modelcontextprotocol/conformance": "0.2.0-alpha.2", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,18 +23,18 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.16", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.16.tgz", - "integrity": "sha512-GI7qiN0r39/MH2srVUR3AXaEN0YLCro20lIBbnvc1frBhszenxvUifBuTzxeVQVagILfBzCIcnungUOma8OrgA==", + "version": "0.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.2.tgz", + "integrity": "sha512-/8bde9d0mfsvgd9IwQgNIl1AS9uNOp/+ZG+2nNRWXtPs6xrz/cNp4ObBMmGY9kP8dkDaF3bvjtC/2Hj8TStMRg==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", "commander": "^14.0.2", - "eventsource-parser": "^3.0.6", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "jose": "^6.1.2", - "undici": "^7.19.0", + "undici": "^7.25.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, @@ -602,9 +602,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -1428,9 +1428,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index dd8dedfe3..21d33001c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.1.16", + "@modelcontextprotocol/conformance": "0.2.0-alpha.2", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 0768cb442..ae5c84d6f 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -30,7 +30,7 @@ internal static class McpHttpHeaders /// The associated helpers perform exact ordinal matches against this single value rather /// than any ordered comparison. /// - public const string DraftProtocolVersion = "DRAFT-2026-v1"; + public const string DraftProtocolVersion = "2026-07-28"; /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; diff --git a/src/Common/Obsoletions.cs b/src/Common/Obsoletions.cs index 6dfdc15f2..542fddb8d 100644 --- a/src/Common/Obsoletions.cs +++ b/src/Common/Obsoletions.cs @@ -42,4 +42,8 @@ internal static class Obsoletions public const string DeprecatedRoots_Message = "The Roots 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 DeprecatedSampling_Message = "The Sampling 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 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_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#obsolete-apis"; } diff --git a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs index 9433eea7e..c3293a5c6 100644 --- a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsSetup.cs @@ -4,6 +4,8 @@ namespace ModelContextProtocol.AspNetCore; +#pragma warning disable MCP9006 // This type only exists to configure the obsolete legacy resumability store. + /// /// Configures by resolving /// the from DI when not explicitly set. diff --git a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs index 1b4786163..47c51f07a 100644 --- a/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs +++ b/src/ModelContextProtocol.AspNetCore/DistributedCacheEventStreamStoreOptionsValidator.cs @@ -5,6 +5,8 @@ namespace ModelContextProtocol.AspNetCore; +#pragma warning disable MCP9006 // This type only exists to validate the obsolete legacy resumability store options. + /// /// Validates that is set. /// diff --git a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs index bcdf53584..732821baa 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpMcpServerBuilderExtensions.cs @@ -84,6 +84,8 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b /// set the property in the callback. /// /// + [Obsolete(ModelContextProtocol.Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = ModelContextProtocol.Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = ModelContextProtocol.Obsoletions.LegacyStatefulHttp_Url)] +#pragma warning disable MCP9006 // The method is itself obsolete and intentionally wires up the legacy resumability store. public static IMcpServerBuilder WithDistributedCacheEventStreamStore(this IMcpServerBuilder builder, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(builder); @@ -99,4 +101,5 @@ public static IMcpServerBuilder WithDistributedCacheEventStreamStore(this IMcpSe return builder; } +#pragma warning restore MCP9006 } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 648cb86df..b24bb6f17 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -50,7 +50,9 @@ public class HttpServerTransportOptions /// allowing for load balancing without session affinity. /// /// - /// if the server runs in a stateless mode; if the server tracks state between requests. The default is . + /// if the server runs in a stateless mode; if the server tracks state between requests. + /// The default is as of the 2026-07-28 draft protocol revision (SEP-2567); + /// 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, @@ -58,8 +60,16 @@ public class HttpServerTransportOptions /// 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. + /// + /// The 2026-07-28 draft protocol revision is sessionless and removes Mcp-Session-Id entirely + /// (SEP-2567), so over HTTP draft requests are only ever served when . When this + /// property is , a sessionless draft request is refused with a + /// -32004 UnsupportedProtocolVersion error so that a dual-era client downgrades to the legacy + /// initialize handshake and obtains the session that the server was configured to provide. A draft + /// request that carries an Mcp-Session-Id is always rejected, regardless of this property's value. + /// /// - public bool Stateless { get; set; } + public bool Stateless { get; set; } = true; /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) @@ -112,6 +122,7 @@ public class HttpServerTransportOptions /// If this property is not set, the server will attempt to resolve an from DI. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISseEventStreamStore? EventStreamStore { get; set; } /// @@ -128,6 +139,7 @@ public class HttpServerTransportOptions /// If this property is not set, the server will attempt to resolve an from DI. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISessionMigrationHandler? SessionMigrationHandler { get; set; } /// @@ -144,6 +156,7 @@ public class HttpServerTransportOptions /// Enabling a per-session can be useful for setting variables /// that persist for the entire session, but it prevents you from using IHttpContextAccessor in handlers. /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public bool PerSessionExecutionContext { get; set; } /// @@ -162,6 +175,7 @@ public class HttpServerTransportOptions /// tied to the open GET /sse request, and they are removed immediately when the client disconnects. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2); /// @@ -182,6 +196,7 @@ public class HttpServerTransportOptions /// exactly as long as the SSE connection is open. /// /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public int MaxIdleSessionCount { get; set; } = 10_000; /// diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs index b4ce545f8..b5fad97a7 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptionsSetup.cs @@ -12,7 +12,9 @@ internal sealed class HttpServerTransportOptionsSetup(IServiceProvider servicePr { public void Configure(HttpServerTransportOptions options) { +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. options.EventStreamStore ??= serviceProvider.GetService(); options.SessionMigrationHandler ??= serviceProvider.GetService(); +#pragma warning restore MCP9006 } } diff --git a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs index 645253d6f..b11fe81cd 100644 --- a/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs +++ b/src/ModelContextProtocol.AspNetCore/IdleTrackingBackgroundService.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -18,12 +18,14 @@ public IdleTrackingBackgroundService( ILogger logger) { // Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown. +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (options.Value.IdleTimeout != Timeout.InfiniteTimeSpan) { ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.IdleTimeout, TimeSpan.Zero); } ArgumentOutOfRangeException.ThrowIfLessThan(options.Value.MaxIdleSessionCount, 0); +#pragma warning restore MCP9006 _sessions = sessions; _options = options; diff --git a/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs b/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs index 7c6970c70..f17df37a1 100644 --- a/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/SseEventStreamReaderExtensions.cs @@ -7,6 +7,8 @@ namespace ModelContextProtocol.AspNetCore; +#pragma warning disable MCP9006 // These extensions only operate on the obsolete legacy resumability reader. + /// /// Provides extension methods for . /// diff --git a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs index 880bd04a5..06573ec9f 100644 --- a/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs +++ b/src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; @@ -17,9 +17,11 @@ internal sealed partial class StatefulSessionManager( private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider; +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout; private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider); private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount; +#pragma warning restore MCP9006 private readonly object _idlePruningLock = new(); private readonly List _idleTimestamps = []; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index ad4930e80..0b1a9df60 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -1,4 +1,4 @@ -using System.Buffers; +using System.Buffers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; @@ -12,6 +12,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Security.Cryptography; +using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -39,9 +40,17 @@ internal sealed class StreamableHttpHandler( "2025-03-26", "2025-06-18", "2025-11-25", - "DRAFT-2026-v1", + McpHttpHeaders.DraftProtocolVersion, ]; + /// + /// The supported protocol versions excluding the draft revision. Used when refusing a sessionless + /// draft request on a stateful (Stateless = false) server so a dual-era client falls back to a + /// legacy initialize handshake instead of retrying the draft version. + /// + private static readonly string[] s_supportedProtocolVersionsExcludingDraft = + [.. s_supportedProtocolVersions.Where(static v => !string.Equals(v, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal))]; + private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -54,9 +63,9 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; } @@ -73,16 +82,31 @@ await WriteJsonRpcErrorAsync(context, return; } - var message = await ReadJsonRpcMessageAsync(context); + JsonRpcMessage? message; + try + { + message = await ReadJsonRpcMessageAsync(context); + } + catch (JsonException) + { + // The POST body was not a well-formed JSON-RPC message (malformed JSON, or a request whose + // id was explicitly null, which MCP forbids). Surface a conformant JSON-RPC error response + // with a null id rather than letting the exception bubble up as an opaque 500. + await WriteJsonRpcErrorAsync(context, + "Bad Request: The POST body did not contain a valid JSON-RPC message.", + StatusCodes.Status400BadRequest, (int)McpErrorCode.InvalidRequest); + return; + } + if (message is null) { await WriteJsonRpcErrorAsync(context, "Bad Request: The POST body did not contain a valid JSON-RPC message.", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, (int)McpErrorCode.InvalidRequest); return; } - if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out errorMessage)) + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); return; @@ -108,9 +132,23 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); + return; + } + + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + + // Under the draft protocol revision the standalone HTTP GET endpoint for unsolicited + // server-to-client messages is removed (SEP-2575); clients use subscriptions/listen (POST) + // instead. The draft is also sessionless (SEP-2567), so a draft GET is invalid whether or + // not it carries an Mcp-Session-Id. + if (IsDraftProtocolRequest(context)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead.", + StatusCodes.Status400BadRequest); return; } @@ -122,7 +160,6 @@ await WriteJsonRpcErrorAsync(context, return; } - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); var session = await GetSessionAsync(context, sessionId); if (session is null) { @@ -203,7 +240,9 @@ await WriteJsonRpcErrorAsync(context, } } +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private static async Task HandleResumePostResponseStreamAsync(HttpContext context, ISseEventStreamReader eventStreamReader) +#pragma warning restore MCP9006 { InitializeSseResponse(context); await eventStreamReader.CopyToAsync(context.Response.Body, context.RequestAborted); @@ -211,13 +250,24 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var errorMessage)) + if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) { - await WriteJsonRpcErrorAsync(context, errorMessage!, StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; } var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + + // Under the draft revision there are no sessions to terminate (SEP-2567). A draft DELETE is + // invalid whether or not it carries an Mcp-Session-Id. + if (IsDraftProtocolRequest(context)) + { + await WriteJsonRpcErrorAsync(context, + "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (the draft protocol is sessionless).", + StatusCodes.Status400BadRequest); + return; + } + if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session)) { return; @@ -280,10 +330,12 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask TryMigrateSessionAsync(HttpContext context, string sessionId) { +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler) { return null; } +#pragma warning restore MCP9006 var migrationLock = _migrationLocks.GetOrAdd(sessionId, static _ => new SemaphoreSlim(1, 1)); await migrationLock.WaitAsync(context.RequestAborted); @@ -319,6 +371,34 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); + bool isDraftRequest = IsDraftProtocolRequest(context); + + // Under the draft protocol revision, the draft is sessionless: SEP-2567 removes the + // Mcp-Session-Id header (and the session concept) and SEP-2575 removes the initialize + // handshake. So over HTTP, draft <=> sessionless, with no exceptions: + if (isDraftRequest) + { + if (!string.IsNullOrEmpty(sessionId)) + { + // A draft request carrying an Mcp-Session-Id is non-conformant (SEP-2567). + await WriteJsonRpcErrorAsync(context, + "Bad Request: Mcp-Session-Id is not supported under the draft protocol revision; the draft protocol is sessionless (SEP-2567).", + StatusCodes.Status400BadRequest); + return null; + } + + if (HttpServerTransportOptions.Stateless) + { + // The default (stateless) HTTP transport serves sessionless draft requests natively. + return await StartNewSessionAsync(context); + } + + // The author explicitly opted into sessions (Stateless = false), which the draft revision + // cannot provide. Refuse the draft version so a dual-era client falls back to the legacy + // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). + await WriteUnsupportedDraftVersionErrorAsync(context); + return null; + } if (string.IsNullOrEmpty(sessionId)) { @@ -350,14 +430,28 @@ await WriteJsonRpcErrorAsync(context, } } + /// + /// Returns when the request declares the draft protocol revision via + /// the MCP-Protocol-Version header. Draft requests are always sessionless and do not perform + /// the legacy initialize handshake (SEP-2575 + SEP-2567). + /// + private static bool IsDraftProtocolRequest(HttpContext context) + { + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + return string.Equals(protocolVersionHeader, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); + } + private async ValueTask StartNewSessionAsync(HttpContext context) { string sessionId; StreamableHttpServerTransport transport; - if (!HttpServerTransportOptions.Stateless) + bool isStateless = HttpServerTransportOptions.Stateless; + + if (!isStateless) { sessionId = MakeNewSessionId(); +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. transport = new(loggerFactory) { SessionId = sessionId, @@ -367,12 +461,13 @@ private async ValueTask StartNewSessionAsync(HttpContext ? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct) : null, }; +#pragma warning restore MCP9006 context.Response.Headers[McpSessionIdHeaderName] = sessionId; } else { - // In stateless mode, each request is independent. Don't set any session ID on the transport. + // In stateless mode (legacy or draft), each request is independent. Don't set any session ID on the transport. // If in the future we support resuming stateless requests, we should populate // the event stream store and retry interval here as well. sessionId = ""; @@ -393,11 +488,13 @@ private async ValueTask CreateSessionAsync( { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + bool effectivelyStateless = HttpServerTransportOptions.Stateless; + + if (effectivelyStateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (HttpServerTransportOptions.Stateless) + if (effectivelyStateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -434,8 +531,10 @@ private async ValueTask MigrateSessionAsync( var transport = new StreamableHttpServerTransport(loggerFactory) { SessionId = sessionId, +#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext, EventStreamStore = HttpServerTransportOptions.EventStreamStore, +#pragma warning restore MCP9006 }; // Initialize the transport with the migrated session's init params. @@ -450,9 +549,11 @@ private async ValueTask MigrateSessionAsync( }); } +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private async ValueTask GetEventStreamReaderAsync(HttpContext context, string lastEventId) { if (HttpServerTransportOptions.EventStreamStore is not { } eventStreamStore) +#pragma warning restore MCP9006 { await WriteJsonRpcErrorAsync(context, "Bad Request: This server does not support resuming streams.", @@ -559,22 +660,60 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// /// 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. + /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. Per SEP-2575, the + /// rejection uses the error code with a data payload + /// listing the server's supported versions so the client can select a fallback. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) + private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && !s_supportedProtocolVersions.Contains(protocolVersionHeader)) { - errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; + errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported.", + Data = JsonSerializer.SerializeToNode( + new UnsupportedProtocolVersionErrorData + { + Supported = [.. s_supportedProtocolVersions], + Requested = protocolVersionHeader, + }, + GetRequiredJsonTypeInfo()), + }; return false; } - errorMessage = null; + errorDetail = null; return true; } + /// + /// Refuses a sessionless draft request on a stateful (Stateless = false) server. The draft revision + /// is sessionless (SEP-2567) and cannot honor the author's opt-in to sessions, so we return + /// with a supported-versions list that excludes + /// the draft. A dual-era client then falls back to the legacy initialize handshake (SEP-2575). + /// + private static Task WriteUnsupportedDraftVersionErrorAsync(HttpContext context) + { + var errorDetail = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = $"Bad Request: The draft protocol revision '{McpHttpHeaders.DraftProtocolVersion}' is sessionless and is not supported when the server is configured with sessions (HttpServerTransportOptions.Stateless = false). " + + "Use the initialize handshake with a supported non-draft protocol version instead.", + Data = JsonSerializer.SerializeToNode( + new UnsupportedProtocolVersionErrorData + { + Supported = s_supportedProtocolVersionsExcludingDraft, + Requested = McpHttpHeaders.DraftProtocolVersion, + }, + GetRequiredJsonTypeInfo()), + }; + + return WriteJsonRpcErrorDetailAsync(context, errorDetail, StatusCodes.Status400BadRequest); + } + /// /// Validates standard MCP request headers (Mcp-Method, Mcp-Name) and custom parameter headers /// (Mcp-Param-*) against the JSON-RPC request body. @@ -640,6 +779,23 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess var mcpNameInHeader = context.Request.Headers[McpHttpHeaders.Name].ToString().Trim(); + // Per SEP-2243, non-ASCII Mcp-Name values MUST be Base64-encoded using the + // "=?base64?...?=" wrapper. Reject raw values containing characters outside the valid + // HTTP header value range, then decode so the comparison below is against the + // decoded value (mirrors the Mcp-Param-* validation in ValidateCustomParamHeaders). + if (!IsValidHeaderValue(mcpNameInHeader)) + { + errorMessage = "Header mismatch: Mcp-Name header contains invalid characters."; + return false; + } + + var decodedMcpNameInHeader = McpHeaderEncoder.DecodeValue(mcpNameInHeader); + if (decodedMcpNameInHeader is null) + { + errorMessage = "Header mismatch: Mcp-Name header contains invalid Base64 encoding."; + return false; + } + // Extract the params and name value from the body based on the method, if present. var bodyParams = message switch { @@ -656,7 +812,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess }; // Check that the header value matches the body value if the body value is present. - if (!string.Equals(mcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) + if (!string.Equals(decodedMcpNameInHeader, mcpNameInBody, StringComparison.Ordinal)) { errorMessage = $"Header mismatch: Mcp-Name header value '{mcpNameInHeader}' does not match body value '{mcpNameInBody}'."; return false; @@ -997,6 +1153,12 @@ private static SafeIntegerParse ParseSafeInteger(string text, out long value) return SafeIntegerParse.NotNumeric; } + private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode) + { + var jsonRpcError = new JsonRpcError { Error = detail }; + return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context); + } + private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue) => acceptHeaderValue.MatchesMediaType("application/json"); diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 209d644d2..3247dd8a7 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Net; +using System.Net.Http; using System.Threading.Channels; namespace ModelContextProtocol.Client; @@ -73,19 +74,37 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; } + 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., -32004 UnsupportedProtocolVersion, + // -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other + // application-level error). Don't fall back to SSE — that would mask the real signal + // and surface a misleading "session id required" error from the SSE GET path. + // Adopt the Streamable HTTP transport and throw the structured exception so the + // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport + // first makes the catch filter below leave the now-owned transport alone. + LogUsingStreamableHttp(_name); + ActiveTransport = streamableHttpTransport; + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } else { - // If the status code is not success, fall back to SSE + // Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this + // is an older deployment that expects the SSE transport (which establishes its + // protocol via GET /sse rather than POST). Fall back to SSE per the original + // behavior. LogStreamableHttpFailed(_name, response.StatusCode); await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false); } } - catch + catch when (ActiveTransport is null) { - // If nothing threw inside the try block, we've either set streamableHttpTransport as the - // ActiveTransport, or else we will have disposed it in the !IsSuccessStatusCode else block. + // Only dispose the Streamable HTTP transport when we didn't adopt it. If we set + // ActiveTransport above (success path OR structured-error path), the transport's + // lifetime is owned by the outer transport from this point on. await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); throw; } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 22245a924..218cd55d0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -1174,7 +1174,10 @@ public async ValueTask> CallToolRawAsync( { Name = requestParams.Name, Arguments = requestParams.Arguments, - Meta = GetMetaWithTaskCapability(requestParams.Meta), + // The SEP-2663 Tasks extension is draft-only. On a legacy session, send a plain tools/call + // (no task capability envelope) so the server returns a direct CallToolResult and never + // creates a task. + Meta = IsDraftProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, }; JsonRpcRequest jsonRpcRequest = new() @@ -1285,6 +1288,7 @@ public ValueTask GetTaskAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfTasksNotSupported(nameof(GetTaskAsync)); return SendRequestAsync( RequestMethods.TasksGet, @@ -1307,6 +1311,7 @@ public ValueTask UpdateTaskAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); return SendRequestAsync( RequestMethods.TasksUpdate, @@ -1346,6 +1351,7 @@ public ValueTask CancelTaskAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); return SendRequestAsync( RequestMethods.TasksCancel, @@ -1376,30 +1382,42 @@ public ValueTask CancelTaskAsync( // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - // TODO: replace the literal with a shared NotificationMethods.ClientCapabilitiesMetaKey once - // the SEP-2575 plumbing lands and drop the local consts. - private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; - private const string ExtensionsKey = "extensions"; - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) { JsonObject meta = existingMeta is not null ? (JsonObject)existingMeta.DeepClone() : []; - if (meta[ClientCapabilitiesMetaKey] is not JsonObject capsRoot) + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) { capsRoot = []; - meta[ClientCapabilitiesMetaKey] = capsRoot; + meta[MetaKeys.ClientCapabilities] = capsRoot; } - if (capsRoot[ExtensionsKey] is not JsonObject extensionsRoot) + if (capsRoot["extensions"] is not JsonObject extensionsRoot) { extensionsRoot = []; - capsRoot[ExtensionsKey] = extensionsRoot; + capsRoot["extensions"] = extensionsRoot; } extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); return meta; } + + /// + /// Throws when the negotiated protocol version is not the draft revision. The SEP-2663 Tasks + /// extension is draft-only, and a task id only ever exists when the session negotiated draft, so + /// invoking tasks/get, tasks/update, or tasks/cancel on a legacy session is a + /// programming error rather than a recoverable protocol condition. + /// + private void ThrowIfTasksNotSupported(string operationName) + { + if (!IsDraftProtocol()) + { + throw new InvalidOperationException( + $"'{operationName}' requires the draft protocol revision ('{DraftProtocolVersion}'). " + + $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'. " + + "The Tasks extension is only available under the draft revision."); + } + } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 894ca6945..eb3ced9a4 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -289,55 +289,145 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - // Send initialize request - string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; - var initializeResponse = await SendRequestAsync( - RequestMethods.Initialize, - new InitializeRequestParams - { - ProtocolVersion = requestProtocol, - Capabilities = _options.Capabilities ?? new ClientCapabilities(), - ClientInfo = _options.ClientInfo ?? DefaultImplementation, - Meta = _options.InitializeMeta, - }, - McpJsonUtilities.JsonContext.Default.InitializeRequestParams, - McpJsonUtilities.JsonContext.Default.InitializeResult, - cancellationToken: initializationCts.Token).ConfigureAwait(false); - - // Store server information - if (_logger.IsEnabled(LogLevel.Information)) + // The draft protocol revision (SEP-2575) is the default: there is no initialize + // handshake. Instead, the client calls server/discover to learn the server's + // capabilities and then begins sending normal RPCs that carry protocolVersion / + // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion + // prefers the draft revision and automatically falls back to the legacy initialize + // handshake when the server doesn't support it. The legacy branch below runs only + // when the caller explicitly pins a non-draft version (opting out of draft). + if (_options.ProtocolVersion is null || _options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion) { - LogServerCapabilitiesReceived(_endpointName, - capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), - serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); - } + string draftVersion = McpSessionHandler.DraftProtocolVersion; + + // Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as + // a draft client when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = draftVersion; + _sessionHandler.NegotiatedProtocolVersion = draftVersion; + + DiscoverResult? discoverResult = null; + bool fallbackToLegacy = false; + IList? serverSupportedVersions = null; + + // Apply a probe timeout so dual-era clients don't block forever waiting for a + // legacy server that silently drops unknown methods (per stdio.mdx fallback rules). + // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is + // always bounded by InitializationTimeout (only applied when it is the tighter bound). + var probeTimeout = _options.DiscoverProbeTimeout; + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token); + if (_options.InitializationTimeout > probeTimeout) + { + probeCts.CancelAfter(probeTimeout); + } - _serverCapabilities = initializeResponse.Capabilities; - _serverInfo = initializeResponse.ServerInfo; - _serverInstructions = initializeResponse.Instructions; + try + { + discoverResult = await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: probeCts.Token).ConfigureAwait(false); + } + catch (UnsupportedProtocolVersionException ex) + { + // Spec-recognized modern-server signal: -32004 with data.supported[]. The server is + // modern but doesn't speak our preferred version. Retry with a mutually supported + // version from data.supported[] instead of falling back to legacy initialize. + fallbackToLegacy = true; + serverSupportedVersions = (IList)ex.Supported; + } + catch (MissingRequiredClientCapabilityException) + { + // Spec-recognized modern-server signal: -32003. The server is modern but rejected + // our capability set. Surface as-is (no fallback): the user must add capabilities. + throw; + } + catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) + { + // Spec-recognized modern-server signal: -32001. The server is modern but rejected + // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match + // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no + // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. + throw; + } + catch (McpProtocolException) + { + // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code — + // any non-modern JSON-RPC error from the probe indicates a legacy server. + // Common causes include MethodNotFound from a server that has no + // server/discover handler, InvalidParams from a server confused by the + // SEP-2575 _meta envelope, ParseError from a server that can't handle our + // payload shape, or any other transport-defined error. The three modern-server + // signals (-32004 UnsupportedProtocolVersion, -32003 + // MissingRequiredClientCapability, -32001 HeaderMismatch) are caught above and + // never reach here. + fallbackToLegacy = true; + } + catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) + { + // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no + // response within a reasonable timeout means the server is legacy. Fall back. + fallbackToLegacy = true; + } - // Validate protocol version - bool isResponseProtocolValid = - _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); - if (!isResponseProtocolValid) + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion)) + { + // Server is reachable and supports server/discover, but doesn't support the + // experimental version. Fall back to legacy initialize with the highest + // mutually-supported version from supportedVersions[]. + fallbackToLegacy = true; + serverSupportedVersions = discoverResult.SupportedVersions; + } + + if (fallbackToLegacy) + { + // Reset negotiated state and try legacy initialize. + _negotiatedProtocolVersion = null; + _sessionHandler.NegotiatedProtocolVersion = null; + + string fallbackVersion = serverSupportedVersions? + .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault() + ?? McpSessionHandler.LatestProtocolVersion; + + // Honor MinProtocolVersion: refuse to fall back below the configured minimum. + // String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions. + if (_options.MinProtocolVersion is { } minVersion && + StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0) + { + throw new McpException( + $"Server does not support the configured minimum protocol version '{minVersion}'. " + + (serverSupportedVersions is null + ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); + } + + await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + } + else + { + 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)); + } + + _serverCapabilities = discoverResult!.Capabilities; + _serverInfo = discoverResult.ServerInfo; + _serverInstructions = discoverResult.Instructions; + } + } + else { - LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); - throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); + // Legacy initialize handshake. Reached only when the caller explicitly pinned a + // non-draft ProtocolVersion (opting out of the draft default), so + // _options.ProtocolVersion is non-null here. + string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; + await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } - - _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; - - // Update session handler with the negotiated protocol version for telemetry - _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; - - // Send initialized notification - await this.SendNotificationAsync( - NotificationMethods.InitializedNotification, - new InitializedNotificationParams(), - McpJsonUtilities.JsonContext.Default.InitializedNotificationParams, - cancellationToken: initializationCts.Token).ConfigureAwait(false); - } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -355,6 +445,75 @@ await this.SendNotificationAsync( LogClientConnected(_endpointName); } + /// + /// Performs the legacy initialize handshake (initialize request + initialized notification), + /// records the negotiated protocol version, and stores the server capabilities/info/instructions. + /// + private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) + { + var initializeResponse = await SendRequestAsync( + RequestMethods.Initialize, + new InitializeRequestParams + { + ProtocolVersion = requestProtocol, + Capabilities = _options.Capabilities ?? new ClientCapabilities(), + ClientInfo = _options.ClientInfo ?? DefaultImplementation, + Meta = _options.InitializeMeta, + }, + McpJsonUtilities.JsonContext.Default.InitializeRequestParams, + McpJsonUtilities.JsonContext.Default.InitializeResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Information)) + { + LogServerCapabilitiesReceived(_endpointName, + capabilities: JsonSerializer.Serialize(initializeResponse.Capabilities, McpJsonUtilities.JsonContext.Default.ServerCapabilities), + serverInfo: JsonSerializer.Serialize(initializeResponse.ServerInfo, McpJsonUtilities.JsonContext.Default.Implementation)); + } + + _serverCapabilities = initializeResponse.Capabilities; + _serverInfo = initializeResponse.ServerInfo; + _serverInstructions = initializeResponse.Instructions; + + // When the user explicitly pinned a legacy (non-draft) protocol version, the server MUST + // respect it. When the user pinned the draft version but we fell back (e.g., legacy server + // rejected server/discover), or when no version was pinned, accept any supported response. + // This is the spec-mandated behavior: a draft client must be able to downgrade to whatever + // legacy version the server advertises. + bool isResponseProtocolValid; + if (_options.ProtocolVersion is { } optionsProtocol && optionsProtocol != McpSessionHandler.DraftProtocolVersion) + { + isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; + } + else + { + isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + } + if (!isResponseProtocolValid) + { + LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); + throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); + } + + // If the user set a MinProtocolVersion, also enforce it against the negotiated response + // (the server could have downgraded further than the version we asked for). + if (_options.MinProtocolVersion is { } minVersion && + StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, minVersion) < 0) + { + throw new McpException( + $"Server negotiated protocol version '{initializeResponse.ProtocolVersion}' is below the configured minimum '{minVersion}'."); + } + + _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; + _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; + + await this.SendNotificationAsync( + NotificationMethods.InitializedNotification, + new InitializedNotificationParams(), + McpJsonUtilities.JsonContext.Default.InitializedNotificationParams, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + /// /// Configures the client to use an already initialized session without performing the handshake. /// @@ -467,6 +626,8 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && const int maxRetries = 10; + InjectDraftMetaIfNeeded(request); + for (int attempt = 0; attempt <= maxRetries; attempt++) { JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); @@ -504,6 +665,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && } request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + InjectDraftMetaIfNeeded(request); } else if (inputRequiredResult.RequestState is not null) { @@ -513,9 +675,13 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && paramsObj.Remove("inputResponses"); request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + InjectDraftMetaIfNeeded(request); } else { + // An input_required result carrying neither inputRequests nor requestState is + // malformed: there is nothing to resolve and nothing to continue, so retrying the + // unchanged request would just loop until maxRetries. Fail fast instead. throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState."); } @@ -528,6 +694,32 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times."); } + /// + /// Injects the draft-protocol per-request _meta fields (protocol version, client info, + /// client capabilities) into the request when this client is using the draft protocol revision + /// (SEP-2575). No-op for legacy clients. + /// + private void InjectDraftMetaIfNeeded(JsonRpcRequest request) + { + if (!IsDraftProtocol()) + { + return; + } + + // Initialize is never sent under the draft revision, but guard defensively in case a caller + // routes it through here (e.g., during back-compat fallback negotiation). + if (request.Method == RequestMethods.Initialize) + { + return; + } + + McpSessionHandler.InjectDraftMeta( + request, + _negotiatedProtocolVersion!, + _options.ClientInfo ?? DefaultImplementation, + _options.Capabilities ?? new ClientCapabilities()); + } + /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => _sessionHandler.SendMessageAsync(message, cancellationToken); @@ -563,7 +755,7 @@ public override async ValueTask DisposeAsync() /// Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request. private void WarnIfLegacyRequestOnMrtrSession(string method) { - if (_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion) + if (IsDraftProtocol()) { LogLegacyRequestOnMrtrSession(_endpointName, method); } @@ -572,7 +764,7 @@ private void WarnIfLegacyRequestOnMrtrSession(string method) /// Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult. private void WarnIfInputRequiredResultOnNonMrtrSession(string method) { - if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion) + if (!IsDraftProtocol()) { LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 1e3bdc4bf..4e9a7acce 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,18 +52,52 @@ public sealed class McpClientOptions /// /// /// - /// The protocol version is a key part of the initialization handshake. The client and server must - /// agree on a compatible protocol version to communicate successfully. + /// When non-, this version is requested from the server. Setting it to a + /// legacy (non-draft) version such as 2025-11-25 opts out of the draft revision and forces + /// the initialize handshake; the handshake then fails if the server's negotiated version + /// does not match. /// /// - /// If non-, this version will be sent to the server, and the handshake - /// will fail if the version in the server's response does not match this version. - /// If , the client will request the latest version supported by the server - /// but will allow any supported version that the server advertises in its response. + /// When (the default), the client prefers the draft revision + /// (): it probes with server/discover and + /// automatically falls back to a legacy initialize handshake, downgrading to any version + /// the server advertises, when the server does not support the draft revision. /// /// public string? ProtocolVersion { get; set; } + /// + /// Gets or sets the minimum protocol version the client will accept during version negotiation. + /// + /// + /// + /// When negotiating with a server that advertises multiple supported versions, or when falling back + /// to a legacy server, the client will refuse any version older than this minimum and surface an + /// instead. + /// + /// + /// This is useful when the client requires features (such as the draft revision's removal of the + /// initialize handshake or Mcp-Session-Id) that are not available in older protocol + /// revisions. Because the client already prefers the draft revision by default, setting this to + /// disables the automatic legacy-server fallback + /// that otherwise switches to the initialize handshake. + /// + /// + /// If (the default), the client falls back to any version the server + /// advertises, including legacy versions such as 2025-11-25. + /// + /// + /// + /// // The draft revision is already the default; pin the minimum to refuse the legacy fallback. + /// var clientOptions = new McpClientOptions + /// { + /// MinProtocolVersion = McpSession.DraftProtocolVersion, + /// }; + /// + /// + /// + public string? MinProtocolVersion { get; set; } + /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// @@ -83,6 +117,52 @@ public sealed class McpClientOptions /// public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(60); + /// + /// Gets or sets the timeout applied to the server/discover probe that the client issues + /// before falling back to the legacy initialize handshake. + /// + /// + /// The probe timeout. The default value is 5 seconds. Use + /// to disable the separate probe timeout + /// and rely solely on . + /// + /// + /// + /// This timeout only has an effect when the client prefers the draft protocol revision — that is, + /// when is (the default) or + /// . In that mode the client first probes the server + /// with a server/discover request. A legacy server that predates the draft revision may + /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the + /// client concludes the server is legacy and falls back to the initialize handshake on the + /// same connection. When the caller pins a legacy , no probe is issued + /// and this value has no effect. + /// + /// + /// The default is intentionally short so that dual-era clients fall back quickly against legacy + /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or + /// satellite links) where a short probe could trigger the legacy fallback before a draft-capable + /// server has had a chance to respond. The probe is always also bounded by + /// , which governs the overall connect budget: if this value is + /// greater than or equal to , the probe is effectively bounded by + /// alone. + /// + /// + /// + /// The value is not positive and is not . + /// + public TimeSpan DiscoverProbeTimeout + { + get; + set + { + if (value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "must be positive or Timeout.InfiniteTimeSpan."); + } + field = value; + } + } = TimeSpan.FromSeconds(5); + /// /// Gets or sets the container of handlers used by the client for processing protocol messages. /// diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 2cebccb3b..c02d5eaea 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -63,9 +63,74 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation { // Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it. using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false); + + // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a + // JSON-RPC error envelope means the peer is signalling something application-level about + // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the + // connect-time logic can react — for example, the three modern draft-protocol error codes + // (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability, + // -32001 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from + // legacy servers that don't understand the draft _meta envelope) become generic + // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. + // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue + // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. + // The three modern draft-protocol error codes are also surfaced for non-400 status codes + // for robustness — servers occasionally emit them with 4xx codes other than 400. + if (!response.IsSuccessStatusCode && + await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && + (response.StatusCode == HttpStatusCode.BadRequest || + IsModernDraftErrorCode((McpErrorCode)parsedError.Error.Code))) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } + private static bool IsModernDraftErrorCode(McpErrorCode code) => + code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; + + /// + /// Reads a JSON-RPC error envelope from an application/json response body, returning + /// when the response isn't JSON, is empty, or doesn't parse to a + /// . Shared with the auto-detecting transport so it can tell an MCP + /// server that rejected the request apart from a non-MCP endpoint without throwing. + /// + internal static async Task TryReadJsonRpcErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentType?.MediaType != "application/json") + { + return null; + } + + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + return null; + } + + if (string.IsNullOrEmpty(body)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(body, McpJsonUtilities.JsonContext.Default.JsonRpcMessage) as JsonRpcError; + } + catch + { + // Not a valid JSON-RPC error response — fall through to the standard HTTP exception path. + return null; + } + } + // This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception. internal async Task SendHttpRequestAsync(JsonRpcMessage message, CancellationToken cancellationToken) { @@ -79,6 +144,12 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes LogTransportSendingMessageSensitive(message); + // Under the draft protocol revision (SEP-2575), every request carries its protocol version in + // _meta/io.modelcontextprotocol/protocolVersion (and the matching MCP-Protocol-Version HTTP + // header). Pick the value off the message so the first draft request (server/discover) can + // include the header even before we've recorded a negotiated version from an initialize reply. + var protocolVersionForRequest = ExtractProtocolVersionFromMeta(message) ?? _negotiatedProtocolVersion; + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token); cancellationToken = sendCts.Token; @@ -90,7 +161,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes }, }; - CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion); + CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, protocolVersionForRequest); AddMcpRequestHeaders(httpRequestMessage.Headers, message); @@ -156,10 +227,35 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); } + else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse) + { + // Under the draft protocol revision (SEP-2575), server/discover replaces the initialize + // handshake. The transport caches the protocol version from the outgoing request's _meta + // so subsequent requests carry the matching MCP-Protocol-Version header without re-parsing. + _negotiatedProtocolVersion ??= ExtractProtocolVersionFromMeta(message); + } return response; } + /// + /// Reads the protocol version from a request's _meta/io.modelcontextprotocol/protocolVersion field, + /// introduced by the draft protocol revision (SEP-2575). Returns for messages that + /// don't have that field. + /// + private static string? ExtractProtocolVersionFromMeta(JsonRpcMessage message) + { + if (message is JsonRpcRequest { Params: System.Text.Json.Nodes.JsonObject paramsObj } && + paramsObj["_meta"] is System.Text.Json.Nodes.JsonObject metaObj && + metaObj[MetaKeys.ProtocolVersion] is System.Text.Json.Nodes.JsonValue versionValue && + versionValue.TryGetValue(out string? version)) + { + return version; + } + + return null; + } + public override async ValueTask DisposeAsync() { using var _ = await _disposeLock.LockAsync().ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 54b9eeebf..74f9110bb 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -43,6 +43,31 @@ public enum McpErrorCode /// ResourceNotFound = -32002, + /// + /// Indicates that a request requires a client capability that was not declared in the request's + /// _meta/io.modelcontextprotocol/clientCapabilities field. + /// + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// requiredCapabilities object describing the capabilities the server requires from the client + /// to process the request. For HTTP, the response status code is 400 Bad Request. + /// + /// + MissingRequiredClientCapability = -32003, + + /// + /// Indicates that the request's declared protocol version is not supported by the server. + /// + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// supported array of protocol version strings the server supports and the original + /// requested protocol version. For HTTP, the response status code is 400 Bad Request. + /// + /// + UnsupportedProtocolVersion = -32004, + /// /// Indicates that URL-mode elicitation is required to complete the requested operation. /// diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 414d0dafc..32a68104b 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -134,8 +134,14 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(CompleteResult))] [JsonSerializable(typeof(CreateMessageRequestParams))] [JsonSerializable(typeof(CreateMessageResult))] + [JsonSerializable(typeof(DiscoverRequestParams))] + [JsonSerializable(typeof(DiscoverResult))] [JsonSerializable(typeof(ElicitRequestParams))] [JsonSerializable(typeof(ElicitResult))] + [JsonSerializable(typeof(MissingRequiredClientCapabilityErrorData))] + [JsonSerializable(typeof(SubscriptionsListenRequestParams))] + [JsonSerializable(typeof(SubscriptionsAcknowledgedNotificationParams))] + [JsonSerializable(typeof(UnsupportedProtocolVersionErrorData))] [JsonSerializable(typeof(UrlElicitationRequiredErrorData))] [JsonSerializable(typeof(EmptyResult))] [JsonSerializable(typeof(GetPromptRequestParams))] @@ -201,6 +207,11 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(ProgressToken))] + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(Implementation))] + [JsonSerializable(typeof(ClientCapabilities))] + [JsonSerializable(typeof(ServerCapabilities))] + [JsonSerializable(typeof(LoggingLevel))] [JsonSerializable(typeof(ProtectedResourceMetadata))] [JsonSerializable(typeof(AuthorizationServerMetadata))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 73d99da71..4804e1ad3 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,6 +28,27 @@ namespace ModelContextProtocol; /// public abstract partial class McpSession : IAsyncDisposable { + /// The latest stable protocol revision this SDK supports. + /// + /// Set or + /// to this value to explicitly pin to the current stable revision instead of accepting whatever + /// the runtime negotiates. + /// + public const string LatestProtocolVersion = McpSessionHandler.LatestProtocolVersion; + + /// The in-progress draft protocol revision this SDK supports. + /// + /// The draft revision removes the initialize handshake (SEP-2575) and the + /// Mcp-Session-Id header (SEP-2567), so it is sessionless on the wire and over HTTP is only + /// served when the server is stateless. A stateful (HttpServerTransportOptions.Stateless = false) + /// server refuses a sessionless draft request so that a dual-era client downgrades to the legacy + /// initialize flow. Clients prefer this revision by default and automatically fall back to the + /// legacy flow when the server does not support it; pin + /// to a legacy version to opt out, or set to this + /// value to keep the draft preference while refusing the legacy fallback. + /// + public const string DraftProtocolVersion = McpSessionHandler.DraftProtocolVersion; + /// Gets an identifier associated with the current MCP session. /// /// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE. @@ -45,6 +66,17 @@ public abstract partial class McpSession : IAsyncDisposable /// public abstract string? NegotiatedProtocolVersion { get; } + /// + /// Gets a value indicating whether the negotiated protocol version is the draft revision + /// (, which carries SEP-2575 + SEP-2567 + MRTR). + /// + /// + /// Returns when no version has been negotiated yet. This is the shared + /// definition of "is this peer speaking the draft revision" used by both the client and server. + /// + internal bool IsDraftProtocol() => + NegotiatedProtocolVersion == DraftProtocolVersion; + /// /// Sends a JSON-RPC request to the connected session and waits for a response. /// diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 00f2231d6..1a33fe670 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -32,11 +32,15 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable internal const string LatestProtocolVersion = "2025-11-25"; /// - /// The draft protocol version that enables MRTR (Multi Round-Trip Requests) per SEP-2322. - /// Clients and servers opt in by setting - /// or to this value. + /// The draft protocol version (SEP-2575 + SEP-2567) that removes the initialize handshake + /// and Mcp-Session-Id and enables MRTR (Multi Round-Trip Requests) per SEP-2322. + /// Clients prefer this revision by default and fall back to the legacy initialize handshake + /// when the server does not support it; pin to a + /// legacy version to opt out. Servers remain reactive: with + /// left they honor whichever + /// supported revision each peer requests, so a single server serves both draft and legacy clients. /// - internal const string DraftProtocolVersion = "DRAFT-2026-v1"; + internal const string DraftProtocolVersion = "2026-07-28"; /// /// All protocol versions supported by this implementation. @@ -165,10 +169,25 @@ public McpSessionHandler( _outgoingMessageFilter = outgoingMessageFilter ?? (next => next); _logger = logger; - // Per the MCP spec, ping may be initiated by either party and must always be handled. + // ping was removed in the draft protocol revision (SEP-2575). Under draft, return + // MethodNotFound; under legacy, the per-spec behavior is to always answer with PingResult. + // Liveness on draft sessions belongs to transport- and request-level timeouts, not a + // dedicated MCP RPC. _requestHandlers.Set( RequestMethods.Ping, - (request, _, cancellationToken) => new ValueTask(new PingResult()), + (request, jsonRpcRequest, cancellationToken) => + { + string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; + if (perRequestVersion is not null && + StringComparer.Ordinal.Compare(perRequestVersion, DraftProtocolVersion) >= 0) + { + throw new McpProtocolException( + $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", + McpErrorCode.MethodNotFound); + } + + return new ValueTask(new PingResult()); + }, McpJsonUtilities.JsonContext.Default.JsonNode, McpJsonUtilities.JsonContext.Default.PingResult); @@ -287,6 +306,18 @@ ex is OperationCanceledException && Message = urlException.Message, Data = urlException.CreateErrorDataNode(), }, + UnsupportedProtocolVersionException upvException => new() + { + Code = (int)upvException.ErrorCode, + Message = upvException.Message, + Data = upvException.CreateErrorDataNode(), + }, + MissingRequiredClientCapabilityException mrccException => new() + { + Code = (int)mrccException.ErrorCode, + Message = mrccException.Message, + Data = mrccException.CreateErrorDataNode(), + }, McpProtocolException mcpProtocolException => new() { Code = (int)mcpProtocolException.ErrorCode, @@ -395,6 +426,14 @@ private static async Task GetCompletionDetailsAsync(Tas private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { + // Project the draft-protocol 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); @@ -530,6 +569,127 @@ await SendMessageAsync(new JsonRpcResponse return result; } + /// + /// Reads the draft-protocol 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 + // -32001 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), + // which conformant draft clients recognize as a modern-server signal and surface as-is rather + // than mistaking it for a legacy server and falling back to the initialize handshake. + if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", + McpErrorCode.HeaderMismatch); + } + + context.ProtocolVersion = protocolVersionValue; + } + + if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) + { + context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); + } + + if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) + { + context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); + } + + if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) + { + context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + } + + /// + /// Injects the draft-protocol per-request _meta fields into an outgoing request. + /// Protocol version and client info overwrite any existing values; client capabilities are merged + /// so per-request capability opt-ins already present in the envelope are preserved. + /// + /// + /// Used by in draft mode to carry protocol version, client info, and + /// client capabilities on every outgoing request (replacing what the legacy initialize handshake + /// previously negotiated once). + /// + internal static void InjectDraftMeta( + JsonRpcRequest request, + string protocolVersion, + Implementation clientInfo, + ClientCapabilities clientCapabilities, + LoggingLevel? logLevel = null) + { + var paramsObj = request.Params as JsonObject; + if (paramsObj is null) + { + paramsObj = new JsonObject(); + request.Params = paramsObj; + } + + if (paramsObj["_meta"] is not JsonObject metaObj) + { + metaObj = new JsonObject(); + paramsObj["_meta"] = metaObj; + } + + metaObj[MetaKeys.ProtocolVersion] = protocolVersion; + metaObj[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.JsonContext.Default.Implementation); + + // Overlay the session-level standard capabilities onto whatever the request already carried + // in _meta.clientCapabilities. A caller higher up the pipeline (e.g. CallToolRawAsync via + // GetMetaWithTaskCapability) may have already written per-request capability opt-ins such as + // extensions/io.modelcontextprotocol/tasks. Blindly overwriting the node would drop those + // additions, so merge instead: set the standard capability fields from the session + // capabilities while preserving any extra keys (extensions) the request envelope already had. + var serializedCapabilities = (JsonObject)JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities)!; + if (metaObj[MetaKeys.ClientCapabilities] is JsonObject existingCapabilities) + { + foreach (var property in serializedCapabilities.ToArray()) + { + existingCapabilities[property.Key] = property.Value?.DeepClone(); + } + } + else + { + metaObj[MetaKeys.ClientCapabilities] = serializedCapabilities; + } + + if (logLevel is { } level) + { + metaObj[MetaKeys.LogLevel] = JsonSerializer.SerializeToNode(level, McpJsonUtilities.JsonContext.Default.LoggingLevel); + } + else + { + metaObj.Remove(MetaKeys.LogLevel); + } + } + private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) @@ -1018,6 +1178,17 @@ private static TimeSpan GetElapsed(long startingTimestamp) => } private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error) + => CreateRemoteProtocolExceptionFromError(error); + + /// + /// Creates a typed from a JSON-RPC error response. + /// + /// + /// Exposed internally so transports that surface an HTTP-level error containing a JSON-RPC error + /// body (e.g., a 400 with ) can convert + /// the error to the same typed exception that JSON-RPC-level error responses produce. + /// + internal static McpProtocolException CreateRemoteProtocolExceptionFromError(JsonRpcError error) { string formattedMessage = $"Request failed (remote): {error.Error.Message}"; var errorCode = (McpErrorCode)error.Error.Code; @@ -1028,6 +1199,16 @@ private static McpProtocolException CreateRemoteProtocolException(JsonRpcError e { exception = urlException; } + else if (errorCode == McpErrorCode.UnsupportedProtocolVersion && + UnsupportedProtocolVersionException.TryCreateFromError(formattedMessage, error.Error, out var upvException)) + { + exception = upvException; + } + else if (errorCode == McpErrorCode.MissingRequiredClientCapability && + MissingRequiredClientCapabilityException.TryCreateFromError(formattedMessage, error.Error, out var mrccException)) + { + exception = mrccException; + } else { exception = new McpProtocolException(formattedMessage, errorCode); diff --git a/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs new file mode 100644 index 000000000..aca6d4902 --- /dev/null +++ b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol; + +/// +/// Represents an exception used to signal that a request requires a client capability that was not declared +/// in the request's per-request _meta/io.modelcontextprotocol/clientCapabilities field. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when a handler cannot +/// proceed because the client did not declare a required capability for the request. The exception is converted +/// to a JSON-RPC error response with code (-32003) +/// and a payload. +/// +public sealed class MissingRequiredClientCapabilityException : McpProtocolException +{ + /// + /// Initializes a new instance of the class. + /// + /// The capabilities the server requires for the request. + /// A human-readable description of the error. If , a default message is used. + public MissingRequiredClientCapabilityException(ClientCapabilities requiredCapabilities, string? message = null) + : base(message ?? "The request requires client capabilities that were not declared in _meta/clientCapabilities.", + McpErrorCode.MissingRequiredClientCapability) + { + Throw.IfNull(requiredCapabilities); + RequiredCapabilities = requiredCapabilities; + } + + /// Gets the client capabilities required for the request. + public ClientCapabilities RequiredCapabilities { get; } + + internal JsonNode CreateErrorDataNode() + { + var payload = new MissingRequiredClientCapabilityErrorData + { + RequiredCapabilities = RequiredCapabilities, + }; + + return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData)!; + } + + internal static bool TryCreateFromError( + string formattedMessage, + JsonRpcErrorDetail detail, + [NotNullWhen(true)] out MissingRequiredClientCapabilityException? exception) + { + exception = null; + + if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object) + { + return false; + } + + var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.MissingRequiredClientCapabilityErrorData); + if (payload?.RequiredCapabilities is null) + { + return false; + } + + exception = new MissingRequiredClientCapabilityException(payload.RequiredCapabilities, formattedMessage); + return true; + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs new file mode 100644 index 000000000..e9a343f46 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs @@ -0,0 +1,16 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request. +/// +/// +/// +/// The discover RPC takes no payload of its own. Per-request metadata +/// (protocol version, client info, client capabilities) flows through the +/// inherited property under the +/// io.modelcontextprotocol/* keys defined by the draft protocol revision (SEP-2575). +/// +/// +public sealed class DiscoverRequestParams : RequestParams +{ +} diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs new file mode 100644 index 000000000..7a4e75453 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result returned from a request. +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575) as the canonical way for a client +/// to learn what a server supports without performing the legacy initialize handshake. +/// +/// +public sealed class DiscoverResult : Result, ICacheableResult +{ + /// + /// Gets or sets the list of MCP protocol version strings that the server supports. + /// + /// + /// The client should choose a version from this list for use in subsequent requests. + /// + [JsonPropertyName("supportedVersions")] + public required IList SupportedVersions { get; set; } + + /// + /// Gets or sets the capabilities of the server. + /// + [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. + /// + /// + /// This can be used by clients to improve an LLM's understanding of the server, + /// for example by including it in a system prompt. + /// + [JsonPropertyName("instructions")] + public string? Instructions { get; set; } + + /// + /// + /// Spec PR #2855 makes ttlMs a required field on . The + /// server emits a safe default (, i.e. immediately stale) on + /// draft sessions when the application has not set an explicit value, preserving today's + /// "do not cache" behavior while satisfying the wire requirement. + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + /// + /// Spec PR #2855 makes cacheScope a required field on . The + /// server emits a safe default () on draft sessions + /// when the application has not set an explicit value. + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs index ecf39e976..93797df05 100644 --- a/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs @@ -7,8 +7,9 @@ namespace ModelContextProtocol.Protocol; /// /// /// This interface corresponds to the CacheableResult type in the Model Context Protocol -/// schema and is implemented by the results of tools/list, prompts/list, -/// resources/list, resources/templates/list, and resources/read. +/// schema and is implemented by the results of server/discover, tools/list, +/// prompts/list, resources/list, resources/templates/list, and +/// resources/read. /// /// /// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 1dfef5de1..646dac75e 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -83,6 +83,7 @@ public sealed class Converter : JsonConverter // Local variables for parsed message data bool hasJsonRpc = false; RequestId id = default; + bool hasId = false; string? method = null; JsonNode? parameters = null; JsonRpcErrorDetail? error = null; @@ -118,6 +119,7 @@ public sealed class Converter : JsonConverter case "id": id = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + hasId = true; break; case "method": @@ -153,6 +155,16 @@ public sealed class Converter : JsonConverter // Determine message type based on presence of id and method properties if (method is not null) { + if (hasId && id.Id is null) + { + // A request that carries an explicit `id: null` is malformed. The MCP base protocol + // states "Unlike base JSON-RPC, the ID MUST NOT be null", and a null id does NOT denote + // a notification — per JSON-RPC 2.0 a Notification is a Request object *without* an id + // member. Reject it rather than silently downgrading to a notification (which would + // drop the malformed id and skip sending any response). + throw new JsonException("Request id must not be null. Per MCP, a request id must be a non-null string or number; omit the id member entirely to send a notification."); + } + if (id.Id is not null) { // Messages with both method and id are requests @@ -165,7 +177,7 @@ public sealed class Converter : JsonConverter } else { - // Messages with a method but no id are notifications + // Messages with a method but no id member are notifications return new JsonRpcNotification { Method = method, @@ -200,6 +212,19 @@ public sealed class Converter : JsonConverter throw new JsonException("Response must have either result or error"); } + if (error is not null) + { + // Per JSON-RPC 2.0, when an error occurs before the request id can be determined + // (e.g. parse error or invalid request), the server MUST respond with id=null. + // Accept null-id error responses so callers can recognize the structured signal + // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-modern error code). + return new JsonRpcError + { + Id = id, + Error = error + }; + } + // Error: Messages with neither id nor method are invalid throw new JsonException("Invalid JSON-RPC message format"); } diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index e5c0f3931..b804c288a 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -85,4 +85,35 @@ public sealed class JsonRpcMessageContext /// to flow the protocol version header so the server can determine client capabilities. /// public string? ProtocolVersion { get; set; } + + /// + /// Gets or sets the client info derived from the per-request + /// _meta/io.modelcontextprotocol/clientInfo field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). When the request was made under the draft revision, + /// the server uses this in lieu of the value previously captured during the initialize handshake. + /// + public Implementation? ClientInfo { get; set; } + + /// + /// Gets or sets the client capabilities derived from the per-request + /// _meta/io.modelcontextprotocol/clientCapabilities field. + /// + /// + /// Introduced by the draft 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. + /// + public ClientCapabilities? ClientCapabilities { get; set; } + + /// + /// Gets or sets the per-request log level derived from the + /// _meta/io.modelcontextprotocol/logLevel field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Replaces the legacy + /// RPC. When absent, the server MUST NOT emit log notifications + /// for the request. + /// + public LoggingLevel? LogLevel { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs new file mode 100644 index 000000000..495f71553 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -0,0 +1,76 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Provides constants for well-known _meta field keys defined by the MCP protocol and its extensions. +/// +public static class MetaKeys +{ + /// + /// The metadata key used to carry the MCP protocol version in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). For HTTP transports, the value MUST + /// match the MCP-Protocol-Version header. Servers reject mismatched versions with + /// . + /// + public const string ProtocolVersion = "io.modelcontextprotocol/protocolVersion"; + + /// + /// The metadata key used to identify the client software in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries an + /// describing the client; replaces the clientInfo previously sent only with initialize. + /// + public const string ClientInfo = "io.modelcontextprotocol/clientInfo"; + + /// + /// The metadata key used to declare client capabilities in a request's _meta field. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries a + /// describing what optional features the client supports for this specific request. Servers MUST NOT + /// infer capabilities from previous requests. + /// + public const string ClientCapabilities = "io.modelcontextprotocol/clientCapabilities"; + + /// + /// The metadata key used to specify the desired log level for a request's resulting log notifications. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Carries a . + /// Replaces the legacy RPC. When absent, the server + /// MUST NOT send log notifications for the request. + /// + public const string LogLevel = "io.modelcontextprotocol/logLevel"; + + /// + /// The metadata key used to associate a notification with the request ID of an active + /// subscription. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). Allows clients to demultiplex notifications + /// belonging to different subscriptions on a shared channel (especially STDIO). + /// + public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId"; + + /// + /// The metadata key used to associate requests, responses, and notifications with a task. + /// + /// + /// + /// This constant defines the key "io.modelcontextprotocol/related-task" used in the + /// _meta field to associate messages with their originating task across the entire + /// request lifecycle. + /// + /// + /// For example, an elicitation that a task-augmented tool call depends on must share the + /// same related task ID with that tool call's task. + /// + /// + /// For tasks/get, tasks/list, and tasks/cancel operations, this + /// metadata should not be included as the taskId is already present in the message structure. + /// + /// + public const string RelatedTask = "io.modelcontextprotocol/related-task"; +} diff --git a/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs new file mode 100644 index 000000000..8370aaf9a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the JSON-RPC error. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). When a server cannot fulfill a request because +/// the client did not declare a required capability in its per-request +/// _meta/io.modelcontextprotocol/clientCapabilities field, it MUST return this error so clients +/// know which capabilities to advertise on a retry. +/// +public sealed class MissingRequiredClientCapabilityErrorData +{ + /// + /// Gets or sets the client capabilities the server requires to process the request. + /// + [JsonPropertyName("requiredCapabilities")] + public required ClientCapabilities RequiredCapabilities { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 825abd92b..39fe0780f 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -152,5 +152,15 @@ public static class NotificationMethods /// Each notification carries a complete task state for the current status, identical to what /// tasks/get would have returned at that moment. /// - public const string TaskStatusNotification = "notifications/tasks"; + public const string TaskStatusNotification = "notifications/tasks/status"; + + /// + /// The name of the notification sent first on a + /// response stream to indicate which notification types the server agreed to deliver. + /// + /// + /// Introduced by the draft protocol revision (SEP-2575). The notification's params mirror the shape + /// of the requested notifications and include only the entries the server actually supports. + /// + public const string SubscriptionsAcknowledgedNotification = "notifications/subscriptions/acknowledged"; } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestId.cs b/src/ModelContextProtocol.Core/Protocol/RequestId.cs index 47a6fde61..692d9b7b9 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestId.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestId.cs @@ -66,7 +66,8 @@ public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, Js { JsonTokenType.String => new(reader.GetString()!), JsonTokenType.Number => new(reader.GetInt64()), - _ => throw new JsonException("requestId must be a string or an integer"), + JsonTokenType.Null => default, + _ => throw new JsonException("requestId must be a string, integer, or null"), }; } @@ -86,7 +87,11 @@ public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerialize return; case null: - writer.WriteStringValue(string.Empty); + // A null Id represents a JSON-RPC error response whose request id could not be + // determined (JSON-RPC 2.0 §5; the MCP base protocol permits an error response to a + // malformed request to carry a null id). Emit JSON null — not "" — so the wire form + // is spec-conformant and round-trips losslessly with the Null-accepting Read above. + writer.WriteNullValue(); return; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a6f22148e..028a5629d 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -151,4 +151,42 @@ public static class RequestMethods /// Cancellation is cooperative — the server decides whether and when to honor it. /// public const string TasksCancel = "tasks/cancel"; + + /// + /// The name of the request method sent from the client to discover the server's protocol versions, + /// capabilities, and metadata. + /// + /// + /// + /// This RPC is introduced in the draft protocol revision (SEP-2575) as the canonical way for a client + /// to learn what a server supports without performing the legacy initialize handshake. + /// + /// + /// The server's response includes its supported protocol versions, capabilities, implementation + /// information, and optional usage instructions. + /// + /// + /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Draft-revision clients + /// typically call this once during connection establishment. + /// + /// + public const string ServerDiscover = "server/discover"; + + /// + /// The name of the request method sent from the client to open a long-lived subscription for + /// receiving server-to-client notifications outside of a specific request's response stream. + /// + /// + /// + /// This RPC is introduced in the draft protocol revision (SEP-2575) and replaces the unsolicited + /// HTTP GET endpoint and the legacy / + /// request methods. + /// + /// + /// The request opens a response stream on which the server first sends a + /// describing the granted + /// notifications, and then streams matching notifications until the subscription is cancelled. + /// + /// + public const string SubscriptionsListen = "subscriptions/listen"; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs new file mode 100644 index 000000000..f4212b2b7 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters sent with a . +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). This notification is the first message on a +/// response stream and informs the client which +/// subset of requested notification types the server has agreed to deliver. +/// +/// +public sealed class SubscriptionsAcknowledgedNotificationParams +{ + /// + /// Gets or sets the notification subscriptions the server has agreed to honor. + /// + /// + /// Only includes notification types the server actually supports. If the client requested an + /// unsupported notification type (e.g., promptsListChanged when the server has no prompts), + /// it is omitted from this set. + /// + [JsonPropertyName("notifications")] + public required SubscriptionsListenNotifications Notifications { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs new file mode 100644 index 000000000..a81d45669 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request. +/// +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). The client uses this request to open a +/// long-lived channel for receiving notifications outside the context of a specific request. +/// +/// +/// Per-request metadata (protocol version, client info, client capabilities, optional log level) +/// flows through the inherited property under the +/// io.modelcontextprotocol/* keys. +/// +/// +public sealed class SubscriptionsListenRequestParams : RequestParams +{ + /// + /// Gets or sets the notifications the client wants to receive on this subscription stream. + /// + /// + /// Each notification type is opt-in; the server MUST NOT send notification types the client + /// has not explicitly requested here. The server's + /// reports the subset + /// of requested notifications the server actually supports. + /// + [JsonPropertyName("notifications")] + public required SubscriptionsListenNotifications Notifications { get; set; } +} + +/// +/// Describes the set of notification types a client wants to receive (or that a server has agreed +/// to deliver) for a subscription. +/// +public sealed class SubscriptionsListenNotifications +{ + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("toolsListChanged")] + public bool? ToolsListChanged { get; set; } + + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("promptsListChanged")] + public bool? PromptsListChanged { get; set; } + + /// + /// Gets or sets a value indicating whether to receive + /// notifications. + /// + [JsonPropertyName("resourcesListChanged")] + public bool? ResourcesListChanged { get; set; } + + /// + /// Gets or sets the list of resource URIs to subscribe to for + /// notifications. + /// + /// + /// Replaces the legacy / + /// RPCs from prior protocol revisions. + /// + [JsonPropertyName("resourceSubscriptions")] + public IList? ResourceSubscriptions { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs new file mode 100644 index 000000000..ac394db90 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the payload for the JSON-RPC error. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). When a server receives a request whose +/// declared protocol version it does not implement, it MUST return this error so clients can +/// fall back to a mutually supported version. +/// +public sealed class UnsupportedProtocolVersionErrorData +{ + /// + /// Gets or sets the protocol version strings that the server supports. + /// + [JsonPropertyName("supported")] + public required IList Supported { get; set; } + + /// + /// Gets or sets the protocol version requested by the client. + /// + [JsonPropertyName("requested")] + public required string Requested { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs b/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs index 01c642355..dc3a7426c 100644 --- a/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs +++ b/src/ModelContextProtocol.Core/Server/ISseEventStreamReader.cs @@ -6,6 +6,7 @@ namespace ModelContextProtocol.Server; /// /// Provides read access to an SSE event stream, allowing events to be consumed asynchronously. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public interface ISseEventStreamReader { /// diff --git a/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs b/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs index 3d9d9b948..20dda4a18 100644 --- a/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs +++ b/src/ModelContextProtocol.Core/Server/ISseEventStreamStore.cs @@ -3,6 +3,7 @@ namespace ModelContextProtocol.Server; /// /// Provides storage and retrieval of SSE event streams, enabling resumability and redelivery of events. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public interface ISseEventStreamStore { /// diff --git a/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs b/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs index 43ddb2361..20d9c747c 100644 --- a/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs +++ b/src/ModelContextProtocol.Core/Server/ISseEventStreamWriter.cs @@ -6,6 +6,7 @@ namespace ModelContextProtocol.Server; /// /// Provides write access to an SSE event stream, allowing events to be written and tracked with unique IDs. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public interface ISseEventStreamWriter : IAsyncDisposable { /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 0a0721c7c..2cc5d4621 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -87,11 +87,13 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact // Configure all request handlers based on the supplied options. ServerCapabilities = new(); ConfigureInitialize(options); + ConfigureDiscover(options); ConfigureTools(options); ConfigurePrompts(options); ConfigureResources(options); ConfigureLogging(options); ConfigureCompletion(options); + ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); ConfigureTasks(options); ConfigureMrtr(); @@ -102,20 +104,10 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact _notificationHandlers.RegisterRange(notificationHandlers); } - // In stateless mode, the server cannot send unsolicited notifications, - // so listChanged should not be advertised. - if (transport is StreamableHttpServerTransport { Stateless: true }) - { - 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; - } - - // Now that everything has been configured, subscribe to any necessary notifications. - if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false) + // 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. + if (IsStatefulSession()) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification); @@ -126,16 +118,27 @@ void Register(McpServerPrimitiveCollection? collection, { if (collection is not null) { - EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(notificationMethod); + EventHandler changed = (sender, e) => _ = SendListChangedNotificationAsync(notificationMethod); collection.Changed += changed; _disposables.Add(() => collection.Changed -= changed); } } } + 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. - var incomingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters); + // And initialize the session. The built-in draft state-sync filter runs ahead of any + // user-supplied incoming filters; see PrependDraftStateSyncFilter for what it records and why. + var incomingMessageFilter = PrependDraftStateSyncFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters)); var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); + _sessionHandler = new McpSessionHandler( isServer: true, _sessionTransport, @@ -147,12 +150,106 @@ void Register(McpServerPrimitiveCollection? collection, _logger); } + /// + /// 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. + /// + /// + /// Under the draft protocol revision (SEP-2575) there is no initialize handshake, so these values + /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in + /// filter is a no-op (the values were captured during the initialize handler). + /// + private JsonRpcMessageFilter PrependDraftStateSyncFilter(JsonRpcMessageFilter inner) + { + JsonRpcMessageFilter draftStateSync = next => async (message, cancellationToken) => + { + if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) + { + bool endpointNameNeedsRefresh = false; + + if (context.ProtocolVersion is { } 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 (-32004) carrying the supported list. + if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: McpSessionHandler.SupportedProtocolVersions); + } + + SetNegotiatedProtocolVersion(protocolVersion); + } + + if (context.ClientCapabilities is { } clientCapabilities && IsDraftProtocol() && IsStatefulSession()) + { + // Under the draft revision the per-request _meta envelope carries the client's FULL + // capabilities (SEP-2575), so a plain overwrite is correct. The IsDraftProtocol() gate + // makes any legacy per-request envelope a no-op (legacy capabilities stay as the + // initialize handshake established them); the IsStatefulSession() gate keeps + // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } + // (where the same server instance handles every request, so persisting per-request + // capability state would both leak across requests and break the StatelessServerTests + // invariant that surfaces the "X is not supported in stateless mode" errors). + _clientCapabilities = clientCapabilities; + } + + if (context.ClientInfo is { } clientInfo && + (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || + !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) + { + _clientInfo = clientInfo; + endpointNameNeedsRefresh = true; + } + + if (endpointNameNeedsRefresh) + { + UpdateEndpointNameWithClientInfo(); + _sessionHandler.EndpointName = _endpointName; + } + } + + await next(message, cancellationToken).ConfigureAwait(false); + }; + + return next => draftStateSync(inner(next)); + } + /// public override string? SessionId => _sessionTransport.SessionId; /// public override string? NegotiatedProtocolVersion => _negotiatedProtocolVersion; + /// + /// Records the negotiated MCP protocol version for the session. The version is established exactly + /// once: the initial -to-value transition is allowed (and racing requests that + /// select the same version are idempotent no-ops), but any later attempt to switch to a different + /// version throws. A single session MUST NOT change protocol versions, so a conflicting per-request + /// _meta protocol version (or Mcp-Protocol-Version header) is a client error rather than + /// something we silently overwrite. + /// + private void SetNegotiatedProtocolVersion(string protocolVersion) + { + string? previous = Interlocked.CompareExchange(ref _negotiatedProtocolVersion, protocolVersion, null); + if (previous is null) + { + // We won the initial null-to-value transition; publish it to the session handler for telemetry. + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + } + else if (!string.Equals(previous, protocolVersion, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"The negotiated protocol version cannot change within a session. " + + $"The session negotiated '{previous}', but a request specified '{protocolVersion}'.", + McpErrorCode.InvalidRequest); + } + } + /// public ServerCapabilities ServerCapabilities { get; } @@ -273,9 +370,12 @@ private void ConfigureInitialize(McpServerOptions options) clientProtocolVersion : McpSessionHandler.LatestProtocolVersion; + // The legacy initialize handshake is authoritative: it may supersede a protocol version + // a prior draft server/discover probe established on the same connection (the dual-era + // fallback path a permissive client takes against an unknown server). Unlike the + // per-request draft version - which SetNegotiatedProtocolVersion locks once negotiated - + // initialize force-sets the version. _negotiatedProtocolVersion = protocolVersion; - - // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = protocolVersion; return new InitializeResult @@ -290,6 +390,228 @@ private void ConfigureInitialize(McpServerOptions options) McpJsonUtilities.JsonContext.Default.InitializeResult); } + /// + /// Registers the server/discover request handler introduced by the draft protocol revision (SEP-2575). + /// + /// + /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's + /// supported protocol versions (), server + /// capabilities, server info, and optional instructions. + /// + private void ConfigureDiscover(McpServerOptions options) + { + _requestHandlers.Set(RequestMethods.ServerDiscover, + (request, _, _) => + { + return new ValueTask(new DiscoverResult + { + SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions], + 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 + // their "do not cache" behavior while satisfying the wire requirement. + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }); + }, + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult); + } + + /// + /// Registers the subscriptions/listen request handler introduced by the draft protocol revision (SEP-2575). + /// + /// + /// + /// The handler opens a long-lived response stream (over the per-request + /// for HTTP, or the shared STDIO channel) that first sends + /// reporting which subscriptions the + /// server agreed to honor, and then streams matching notifications until the request is cancelled. + /// + /// + /// Subscription-bound notifications carry the listen request's id in their + /// _meta/io.modelcontextprotocol/subscriptionId field per SEP-2575 so clients can demultiplex. + /// + /// + private void ConfigureSubscriptions(McpServerOptions options) + { + _requestHandlers.Set(RequestMethods.SubscriptionsListen, + async (request, jsonRpcRequest, cancellationToken) => + { + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); + + // A stateless session (Streamable HTTP with no session) cannot deliver out-of-band + // notifications: each request is isolated and nothing outlives it to push later list/resource + // changes back to the client (tracked by #1662). Rather than hold the POST open forever only + // to deliver nothing - pinning the connection and its request scope - acknowledge the listen + // request granting no notifications and complete immediately. This runs after protocol + // negotiation, so it is not a legacy-server signal and never triggers a client fallback to the + // initialize handshake. + if (!IsStatefulSession()) + { + var statelessSubscription = new ActiveSubscription( + jsonRpcRequest.Id, + new SubscriptionsListenNotifications(), + jsonRpcRequest.Context?.RelatedTransport); + + await SendSubscriptionAckAsync(statelessSubscription, cancellationToken).ConfigureAwait(false); + + return new EmptyResult(); + } + + // Filter the requested notifications against what the server actually supports. + var granted = new SubscriptionsListenNotifications + { + ToolsListChanged = requested.ToolsListChanged == true && ServerCapabilities?.Tools?.ListChanged == true ? true : null, + PromptsListChanged = requested.PromptsListChanged == true && ServerCapabilities?.Prompts?.ListChanged == true ? true : null, + ResourcesListChanged = requested.ResourcesListChanged == true && ServerCapabilities?.Resources?.ListChanged == true ? true : null, + ResourceSubscriptions = requested.ResourceSubscriptions is { Count: > 0 } subs && ServerCapabilities?.Resources?.Subscribe == true + ? new List(subs) + : null, + }; + + // Track this subscription so list-changed notifications can be fanned out to it, tagged with + // the right subscriptionId, and routed back over the stream this request opened. + var subscription = new ActiveSubscription( + jsonRpcRequest.Id, + granted, + jsonRpcRequest.Context?.RelatedTransport); + _activeSubscriptions[jsonRpcRequest.Id] = subscription; + + try + { + // Send the acknowledgement notification first, as required by SEP-2575. Like every other + // notification delivered on the subscription it is routed back over this request's own + // stream and tagged with the subscription id so shared-channel clients can demultiplex it. + await SendSubscriptionAckAsync(subscription, cancellationToken).ConfigureAwait(false); + + // Keep the subscription open until the request is cancelled (client disconnect on HTTP, + // or notifications/cancelled on STDIO). + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), tcs); + await tcs.Task.ConfigureAwait(false); + } + finally + { + _activeSubscriptions.TryRemove(jsonRpcRequest.Id, out _); + } + + return new EmptyResult(); + }, + McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, + McpJsonUtilities.JsonContext.Default.EmptyResult); + } + + /// Tracks an active subscriptions/listen subscription for notification fan-out. + /// The id of the subscriptions/listen request, reused as the SEP-2575 subscription id. + /// The notification types the server agreed to deliver on this subscription. + /// + /// The transport the subscriptions/listen request arrived on. For Streamable HTTP this is the + /// per-request response stream the subscription must be delivered on; for stdio it is , + /// so notifications fall back to the shared session channel. + /// + private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifications Granted, ITransport? RelatedTransport); + + private readonly ConcurrentDictionary _activeSubscriptions = new(); + + /// + /// Delivers a */list_changed notification triggered by a server-side collection change. + /// + /// + /// Pre-SEP-2575 clients do not open subscriptions/listen streams, so they keep receiving a single + /// session-wide broadcast. Draft clients instead receive only the change notifications they explicitly + /// requested, each routed back over the originating subscription stream and tagged with its id; the server + /// MUST NOT send a draft client notification types it never subscribed to. + /// + private async Task SendListChangedNotificationAsync(string notificationMethod) + { + // Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. + // subscriptions/listen is a SEP-2575 draft feature, so draft clients instead get a fan-out limited + // to the notification types they explicitly subscribed to. + if (!IsDraftProtocol()) + { + await this.SendNotificationAsync(notificationMethod).ConfigureAwait(false); + return; + } + + foreach (var subscription in _activeSubscriptions.Values) + { + if (!GrantsListChanged(subscription.Granted, notificationMethod)) + { + continue; + } + + try + { + await SendSubscriptionNotificationAsync(subscription, notificationMethod, paramsNode: null, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + // A single closed or faulted subscription stream must not prevent fan-out to the others. + SubscriptionNotificationFailed(notificationMethod, subscription.Id.ToString(), ex); + } + } + } + + /// + /// Sends over 's stream, tagging it with the + /// SEP-2575 _meta subscription id so clients sharing a channel (notably stdio) can demultiplex it. + /// + private Task SendSubscriptionNotificationAsync(ActiveSubscription subscription, string method, JsonNode? paramsNode, CancellationToken cancellationToken) + { + var paramsObject = paramsNode as JsonObject ?? new JsonObject(); + if (paramsObject["_meta"] is not JsonObject meta) + { + meta = new JsonObject(); + paramsObject["_meta"] = meta; + } + + meta[MetaKeys.SubscriptionId] = subscription.Id.Id switch + { + string stringId => JsonValue.Create(stringId), + long longId => JsonValue.Create(longId), + _ => null, + }; + + var notification = new JsonRpcNotification + { + Method = method, + Params = paramsObject, + Context = new JsonRpcMessageContext { RelatedTransport = subscription.RelatedTransport }, + }; + + return SendMessageAsync(notification, cancellationToken); + } + + /// + /// Sends the SEP-2575 subscriptions/acknowledged notification for a subscription, carrying the + /// notification types the server agreed to deliver. Routed back over the subscription's own stream and + /// tagged with its id like every other subscription notification. + /// + private Task SendSubscriptionAckAsync(ActiveSubscription subscription, CancellationToken cancellationToken) + { + var ackParams = JsonSerializer.SerializeToNode( + new SubscriptionsAcknowledgedNotificationParams { Notifications = subscription.Granted }, + McpJsonUtilities.JsonContext.Default.SubscriptionsAcknowledgedNotificationParams); + + return SendSubscriptionNotificationAsync( + subscription, + NotificationMethods.SubscriptionsAcknowledgedNotification, + ackParams, + cancellationToken); + } + + /// Maps a */list_changed method to the subscription filter flag that enables it. + private static bool GrantsListChanged(SubscriptionsListenNotifications granted, string method) => method switch + { + NotificationMethods.ToolListChangedNotification => granted.ToolsListChanged == true, + NotificationMethods.PromptListChangedNotification => granted.PromptsListChanged == true, + NotificationMethods.ResourceListChangedNotification => granted.ResourcesListChanged == true, + _ => false, + }; + private void ConfigureCompletion(McpServerOptions options) { var completeHandler = options.Handlers.CompleteHandler; @@ -487,6 +809,13 @@ private void ConfigureTasks(McpServerOptions options) updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); + // The tasks/* methods do not exist before the draft revision (SEP-2663). Reject them with + // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers + // stay registered so a dual-era server still serves them for draft requests. + getTaskHandler = GateTaskMethodToDraft(getTaskHandler, RequestMethods.TasksGet); + updateTaskHandler = GateTaskMethodToDraft(updateTaskHandler, RequestMethods.TasksUpdate); + cancelTaskHandler = GateTaskMethodToDraft(cancelTaskHandler, RequestMethods.TasksCancel); + // Advertise tasks extension in server capabilities. ServerCapabilities.Extensions ??= new Dictionary(); ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); @@ -510,6 +839,26 @@ private void ConfigureTasks(McpServerOptions options) McpJsonUtilities.JsonContext.Default.CancelTaskResult); } + /// + /// Wraps a tasks/* request handler so it throws unless the + /// request was negotiated under the draft revision. The tasks extension (SEP-2663) only interoperates + /// under draft, and these methods don't exist on legacy peers. + /// + private McpRequestHandler GateTaskMethodToDraft( + McpRequestHandler inner, string method) + => (request, cancellationToken) => + { + if (!IsDraftProtocolRequest(request.JsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{method}' requires the draft protocol revision ('{DraftProtocolVersion}'); " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + + return inner(request, cancellationToken); + }; + private void ConfigureExperimentalAndExtensions(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; @@ -911,7 +1260,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) var innerTaskHandler = callToolWithTaskHandler; callToolWithTaskHandler = async (request, cancellationToken) => { - if (HasTaskExtensionOptIn(request.Params?.Meta)) + // The SEP-2663 Tasks extension is draft-only: the task wire shapes we ship do not + // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the + // request was negotiated under the draft revision AND the client opted in; otherwise + // run the inner handler and return the direct result (best-effort downgrade, which also + // defends against a non-conformant legacy client that forges the opt-in envelope). + if (IsDraftProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) { var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); var taskId = taskInfo.TaskId; @@ -1353,15 +1707,10 @@ private static McpRequestHandler BuildFilterPipeline meta is not null && - meta[ClientCapabilitiesMetaKey] is JsonObject caps && - caps[ExtensionsKey] is JsonObject exts && + meta[MetaKeys.ClientCapabilities] is JsonObject caps && + caps["extensions"] is JsonObject exts && exts.ContainsKey(McpExtensions.Tasks); private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) @@ -1421,10 +1770,12 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => }; /// - /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1). + /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (2026-07-28). MRTR rides on + /// the draft revision, so this is the MRTR-meaning alias of - + /// use it at the input-required/handler-suspension sites where the intent is "the client understands + /// " rather than "the peer speaks the draft revision". /// - internal bool ClientSupportsMrtr() => - _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; + internal bool ClientSupportsMrtr() => IsDraftProtocol(); /// /// Returns when the session is stateful - the same server instance handles @@ -1436,6 +1787,18 @@ internal bool ClientSupportsMrtr() => internal bool IsStatefulSession() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; + /// + /// Returns when the given request was negotiated under the draft protocol + /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works + /// for sessionless draft over stateless HTTP) and falling back to the session-negotiated version. + /// Used to gate the SEP-2663 Tasks extension, which only interoperates under the draft revision. + /// + private bool IsDraftProtocolRequest(JsonRpcRequest? request) => + string.Equals( + request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion, + DraftProtocolVersion, + StringComparison.Ordinal); + /// public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession(); @@ -1453,14 +1816,6 @@ internal bool IsStatefulSession() => { const int MaxRetries = 10; - // In stateless mode, pick up the negotiated draft protocol version from the - // transport-provided request context because there is no long-lived initialize handshake state. - if (_negotiatedProtocolVersion is null && - request.Context?.ProtocolVersion is { } headerProtocolVersion) - { - _negotiatedProtocolVersion = headerProtocolVersion; - } - for (int retry = 0; ; retry++) { try @@ -1479,7 +1834,6 @@ internal bool IsStatefulSession() => // 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. - // TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. if (!IsStatefulSession()) { throw new McpException( @@ -1646,15 +2000,6 @@ private void WrapHandlerWithMrtr(string method) _requestHandlers[method] = async (request, cancellationToken) => { - // In stateless mode, each request creates a new server instance that never saw the - // initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the - // Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext. - if (_negotiatedProtocolVersion is null && - request.Context?.ProtocolVersion is { } headerProtocolVersion) - { - _negotiatedProtocolVersion = headerProtocolVersion; - } - // Check for MRTR retry: if requestState is present, look up the continuation. if (request.Params is JsonObject paramsObj && paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) && @@ -1706,7 +2051,7 @@ private void WrapHandlerWithMrtr(string method) } // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits - // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, + // InputRequiredResult on the wire, which only 2026-07-28 clients understand, // and requires the same server instance to handle the retry (stateful session). // For all other cases - legacy clients, stateless sessions - fall through to the // exception-based path, which transparently resolves InputRequiredException via @@ -1873,4 +2218,7 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); + + [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/SseEventStreamMode.cs b/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs index 2b7704d3d..3439acc60 100644 --- a/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs +++ b/src/ModelContextProtocol.Core/Server/SseEventStreamMode.cs @@ -3,6 +3,7 @@ namespace ModelContextProtocol.Server; /// /// Represents the mode of an SSE event stream. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public enum SseEventStreamMode { /// diff --git a/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs b/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs index 6d5be24ef..7eea0973c 100644 --- a/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs +++ b/src/ModelContextProtocol.Core/Server/SseEventStreamOptions.cs @@ -3,6 +3,7 @@ namespace ModelContextProtocol.Server; /// /// Configuration options for creating an SSE event stream. /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public sealed class SseEventStreamOptions { /// diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 568afd223..ed8c3293a 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -22,7 +22,9 @@ internal sealed partial class StreamableHttpPostTransport( private readonly SseEventWriter _httpSseWriter = new(responseStream); private TaskCompletionSource? _storeStreamTcs; +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private ISseEventStreamWriter? _storeSseWriter; +#pragma warning restore MCP9006 private RequestId _pendingRequest; private bool _finalResponseMessageSent; @@ -176,7 +178,9 @@ public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationTo // Set the mode to 'Polling' so that the replay stream ends as soon as all available messages have been sent. // This prevents the client from immediately establishing another long-lived connection. +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. await _storeSseWriter.SetModeAsync(SseEventStreamMode.Polling, cancellationToken).ConfigureAwait(false); +#pragma warning restore MCP9006 // Signal completion so HandlePostAsync can return. _httpResponseTcs.TrySetResult(true); diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index 87d353426..131c836dc 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -40,7 +40,9 @@ public sealed partial class StreamableHttpServerTransport : ITransport private readonly ILogger _logger; private SseEventWriter? _httpSseWriter; +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. private ISseEventStreamWriter? _storeSseWriter; +#pragma warning restore MCP9006 private TaskCompletionSource? _httpResponseTcs; private string? _negotiatedProtocolVersion; private bool _getHttpRequestStarted; @@ -80,6 +82,7 @@ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null) /// Gets or sets the event store for resumability support. /// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header. /// + [Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public ISseEventStreamStore? EventStreamStore { get; init; } /// @@ -357,6 +360,7 @@ public async ValueTask DisposeAsync() } } +#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally. internal async ValueTask TryCreateEventStreamAsync(string streamId, CancellationToken cancellationToken) { if (EventStreamStore is null || !McpSessionHandler.SupportsPrimingEvent(_negotiatedProtocolVersion)) @@ -377,6 +381,7 @@ public async ValueTask DisposeAsync() return sseEventStreamWriter; } +#pragma warning restore MCP9006 [LoggerMessage(Level = LogLevel.Warning, Message = "Sending server-to-client JSON-RPC request '{Method}' over the standalone GET SSE stream (SessionId: '{SessionId}'). " + diff --git a/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs new file mode 100644 index 000000000..fc37e05cd --- /dev/null +++ b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs @@ -0,0 +1,74 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol; + +/// +/// Represents an exception used to signal that a request's declared protocol version is not supported by the server. +/// +/// +/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when they cannot process +/// a request because the per-request _meta/io.modelcontextprotocol/protocolVersion (or the equivalent +/// transport-level header) names a version the server does not implement. The exception is converted to a +/// JSON-RPC error response with code (-32004) and +/// a payload. +/// +public sealed class UnsupportedProtocolVersionException : McpProtocolException +{ + /// + /// Initializes a new instance of the class. + /// + /// The protocol version the client requested. + /// The protocol versions the server supports. + /// A human-readable description of the error. If , a default message is used. + public UnsupportedProtocolVersionException(string requested, IEnumerable supported, string? message = null) + : base(message ?? $"Unsupported protocol version '{requested}'.", McpErrorCode.UnsupportedProtocolVersion) + { + Throw.IfNull(requested); + Throw.IfNull(supported); + + Requested = requested; + Supported = new List(supported); + } + + /// Gets the protocol version the client requested. + public string Requested { get; } + + /// Gets the protocol versions the server supports. + public IReadOnlyList Supported { get; } + + internal JsonNode CreateErrorDataNode() + { + var payload = new UnsupportedProtocolVersionErrorData + { + Requested = Requested, + Supported = (IList)Supported, + }; + + return JsonSerializer.SerializeToNode(payload, McpJsonUtilities.JsonContext.Default.UnsupportedProtocolVersionErrorData)!; + } + + internal static bool TryCreateFromError( + string formattedMessage, + JsonRpcErrorDetail detail, + [NotNullWhen(true)] out UnsupportedProtocolVersionException? exception) + { + exception = null; + + if (detail.Data is not JsonElement dataElement || dataElement.ValueKind is not JsonValueKind.Object) + { + return false; + } + + var payload = dataElement.Deserialize(McpJsonUtilities.JsonContext.Default.UnsupportedProtocolVersionErrorData); + if (payload is null) + { + return false; + } + + exception = new UnsupportedProtocolVersionException(payload.Requested, payload.Supported, formattedMessage); + return true; + } +} diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj index 0d717ef10..36c6a1736 100644 --- a/src/ModelContextProtocol/ModelContextProtocol.csproj +++ b/src/ModelContextProtocol/ModelContextProtocol.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0;netstandard2.0 @@ -10,6 +10,11 @@ True $(NoWarn);MCPEXP001 + + $(NoWarn);MCP9006 + + $(NoWarn);CS0436 diff --git a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs index f434e12c3..227bf132e 100644 --- a/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs +++ b/src/ModelContextProtocol/Server/DistributedCacheEventStreamStoreOptions.cs @@ -5,6 +5,7 @@ namespace ModelContextProtocol.Server; /// /// Configuration options for . /// +[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)] public sealed class DistributedCacheEventStreamStoreOptions { /// diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 374184cfa..b549bdd76 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests.Utils; @@ -187,8 +188,12 @@ public static bool IsNodeInstalled() /// the pinned version in package.json) means this also returns /// when a newer private build has been installed locally via /// npm install --no-save <path-to-conformance>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// - public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + public static bool HasSep2243Scenarios() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance @@ -197,8 +202,47 @@ public static bool IsNodeInstalled() /// Reading the installed version (rather than the pinned version in package.json) means /// this also returns when a newer private build has been installed /// locally via npm install --no-save <path-to-conformance>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// - public static bool HasCachingScenario() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + public static bool HasCachingScenario() + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); + + /// + /// Returns when the installed conformance package's bundled + /// dist emits the same draft protocol version string as this SDK + /// (). Used to suppress draft-only + /// conformance scenarios when the published conformance binary is still pinned to a + /// stale wire string (for example, conformance 0.2.0-alpha.2 ships + /// "DRAFT-2026-v1" while this SDK speaks "2026-07-28"). + /// + /// + /// This check is a pragmatic alternative to inspecting the conformance package's + /// internal constants: the bundled dist/index.js is minified so we can't grep + /// the constant name, but the literal version string survives bundling and is unique + /// enough to be a reliable signal. + /// + public static bool HasMatchingDraftWireVersion() + { + try + { + var repoRoot = FindRepoRoot(); + var distPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); + if (!File.Exists(distPath)) + { + return false; + } + + var bundled = File.ReadAllText(distPath); + return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); + } + catch + { + return false; + } + } /// /// Returns when the conformance package installed in node_modules @@ -373,42 +417,20 @@ private static bool ConformanceOutputIndicatesSuccess(string output) } /// - /// Checks whether the SEP-2322 (Multi Round-Trip Requests / IncompleteResult) - /// conformance scenarios are available by reading the conformance package version - /// from the repo's package.json. MRTR scenarios require a conformance package version - /// that includes SEP-2322 support (see + /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) + /// conformance scenarios are available, by reading the installed conformance + /// package version from node_modules. The incomplete-result-* scenarios were + /// introduced in conformance package 0.2.0 (see /// https://github.com/modelcontextprotocol/conformance/pull/188). + /// Reading the installed version (rather than the pinned version in package.json) means + /// this also returns when a newer private build has been installed + /// locally via npm install --no-save <path-to-conformance>. + /// Additionally requires that the installed conformance package emits the draft wire + /// version this SDK speaks — see . /// public static bool HasMrtrScenarios() - { - try - { - var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine(repoRoot, "package.json"); - if (!File.Exists(packageJsonPath)) - { - return false; - } - - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("dependencies", out var deps) && - deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) - { - var versionStr = versionElement.GetString(); - if (versionStr is not null && Version.TryParse(versionStr, out var version)) - { - // SEP-2322 scenarios are expected in conformance package >= 0.2.0 - return version >= new Version(0, 2, 0); - } - } - - return false; - } - catch - { - return false; - } - } + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) + && HasMatchingDraftWireVersion(); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs index 852fb122e..9e2040d1b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -46,7 +46,7 @@ private async Task StartAsync() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) @@ -146,7 +146,7 @@ public async Task AddKnownTools_ThenCallTool_SendsMcpParamHeaders_WithoutListToo TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register the tool WITHOUT calling ListToolsAsync first — this is the core scenario from issue #1577 @@ -186,7 +186,7 @@ public async Task CallTool_EmitsCanonicalIntegerHeader(string bodyValue, string TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); client.AddKnownTools([CreateToolWithHeaders()]); @@ -222,7 +222,7 @@ public async Task CallTool_ThrowsForInvalidIntegerHeaderValue(string bodyValue) TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); client.AddKnownTools([CreateToolWithHeaders()]); @@ -252,7 +252,7 @@ public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Call the tool without AddKnownTools or ListToolsAsync — no Mcp-Param-* headers should be sent @@ -285,7 +285,7 @@ public async Task AddKnownTools_SurvivesListToolsAsync_HeadersStillSent() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register the tool first @@ -322,7 +322,7 @@ public async Task RemoveKnownTools_ThenCallTool_NoMcpParamHeaders() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register then remove — headers should no longer be sent @@ -376,7 +376,7 @@ public async Task AddKnownTools_ServerReturnsEmptyList_RegisteredToolStillUsedFo TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register tool, then ListToolsAsync returns empty list from server @@ -411,7 +411,7 @@ public async Task AddKnownTools_ReRegisterOverwrite_LastWriteWinsHeaders() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Register with header "SchemaA", then overwrite with "SchemaB" diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 5cdd2948a..27cba0d40 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -105,12 +105,14 @@ public async ValueTask DisposeAsync() /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// /// -/// The scenario is draft-only (introduced in DRAFT-2026-v1) and uses the stateless lifecycle. -/// It is gated on the installed conformance package version (>= 0.2.0) and is skipped when -/// running against the currently-pinned package, so it activates automatically once a -/// conformance package containing the caching scenario is installed (including a local private -/// build installed via npm install --no-save <path-to-conformance>). The stateless -/// server is started only after the gates pass, so a skipped run binds no port. +/// The scenario is draft-only (introduced in spec wire version 2026-07-28) and uses the +/// stateless lifecycle. It is gated on the installed conformance package version (>= 0.2.0) +/// AND on the installed package emitting the draft wire string this SDK speaks (so it stays +/// skipped under conformance 0.2.0-alpha.2 which still ships the placeholder +/// DRAFT-2026-v1). It activates automatically once a conformance package emitting +/// 2026-07-28 is installed (e.g. via +/// npm install --no-save <path-to-conformance>). The stateless server is +/// started only after the gates pass, so a skipped run binds no port. /// public class CachingConformanceTests(ITestOutputHelper output) { @@ -128,7 +130,7 @@ public async Task RunCachingConformanceTest() // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a // conflicting duplicate --spec-version flag). var result = await NodeHelpers.RunServerConformanceAsync( - $"server --url {server.ServerUrl} --scenario caching --spec-version DRAFT-2026-v1", + $"server --url {server.ServerUrl} --scenario caching --spec-version 2026-07-28", line => { try { output.WriteLine(line); } catch { } }, appendProtocolVersionFromEnv: false, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs new file mode 100644 index 000000000..55401c15a --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs @@ -0,0 +1,305 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Regression tests for the draft-protocol-to-legacy fallback path over Streamable HTTP. These +/// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's +/// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body +/// on a draft probe; vanilla Go does the same on POST /) so the client's HTTP-fallback +/// logic can be exercised in isolation without the cross-SDK harness. +/// +/// +/// +/// Two latent bugs were discovered during cross-SDK testing and fixed by the SEP-2575 / SEP-2567 +/// branch: +/// +/// +/// +/// only surfaced the three modern draft +/// error codes (-32004, -32003, -32001) as ; +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// that doesn't understand the draft _meta envelope) threw +/// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger +/// on ANY non-modern JSON-RPC error in a 400 body. +/// +/// +/// treated any non-2xx HTTP response as a +/// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked +/// application-level errors (including the three modern codes) because the SSE GET would +/// either fail with "session id required" or succeed against a different endpoint and lose +/// the actual signal. +/// +/// +/// +public class DraftHttpFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private async Task StartServerAsync(RequestDelegate handler) + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + + _app = Builder.Build(); + _app.MapPost("/mcp", handler); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatusCode statusCode, int code, string message) + { + var rpcError = new JsonRpcError + { + Id = default, + Error = new JsonRpcErrorDetail { Code = code, Message = message }, + }; + + context.Response.StatusCode = (int)statusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), context.RequestAborted); + } + + /// + /// Mimics Python's simple-streamablehttp-stateless on a draft probe: returns + /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the + /// initial server/discover, then performs a normal legacy initialize handshake + /// when the client falls back. + /// + [Fact] + public async Task DraftClient_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is not JsonRpcRequest request) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + // Draft probe: simulate a legacy server that rejects the unknown protocol version with + // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). + if (request.Method == RequestMethods.ServerDiscover) + { + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: -32600, message: "Bad Request: Unsupported protocol version: draft"); + return; + } + + // Legacy initialize: respond with the highest version the legacy server speaks. + if (request.Method == RequestMethods.Initialize) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-06-18", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + if (request.Method == RequestMethods.ToolsList) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + // Default AutoDetect transport — exercises BOTH fixes (AutoDetect adopting StreamableHttp + // on JSON-RPC-error 400, and SendMessageAsync surfacing -32600 as McpProtocolException). + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + + // Sanity: subsequent traffic still works post-fallback. + var tools = await client.ListToolsAsync(cancellationToken: ct); + Assert.Empty(tools); + } + + /// + /// Mimics vanilla Go: returns 400 + JSON-RPC -32004 with + /// data.supported[] on a draft probe so the client retries legacy + /// initialize with one of the advertised versions. + /// + [Fact] + public async Task DraftClient_OnUnsupportedProtocolVersion_AdoptsStreamableHttp_NoSseFallback() + { + var ct = TestContext.Current.CancellationToken; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is not JsonRpcRequest request) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return; + } + + if (request.Method == RequestMethods.ServerDiscover) + { + // -32004 with the spec-shaped data: client should retry with one of supported[]. + // Use the typed payload type so the source-generated serializer can handle it. + var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData + { + Supported = new List { "2025-11-25" }, + Requested = "draft", + }, GetJsonTypeInfo()); + + var rpcError = new JsonRpcError + { + Id = request.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = data, + }, + }; + + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(rpcError, GetJsonTypeInfo()), ct); + return; + } + + if (request.Method == RequestMethods.Initialize) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-11-25", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + /// + /// A 400 with a JSON-RPC -32001 HeaderMismatch envelope must be surfaced to the + /// caller (no legacy fallback) — falling back wouldn't fix a malformed envelope. + /// + [Fact] + public async Task DraftClient_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() + { + var ct = TestContext.Current.CancellationToken; + bool initializeReceived = false; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeReceived = true; + } + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }) + { + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, + code: (int)McpErrorCode.HeaderMismatch, + message: "Header mismatch: MCP-Protocol-Version did not match body _meta"); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); + Assert.False(initializeReceived); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs new file mode 100644 index 000000000..b6f3352ae --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs @@ -0,0 +1,200 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// HTTP-level tests for the draft protocol revision (SEP-2575 + SEP-2567): verify that the server +/// suppresses the Mcp-Session-Id header for draft requests and returns structured +/// errors instead of plain 400s. +/// +public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private WebApplication? _app; + + private async Task StartAsync(bool stateless = false) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; + }).WithHttpTransport(options => + { + // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions, which the + // draft revision cannot honor (so sessionless draft requests are refused). Stateless = true (the + // default) serves sessionless draft natively. + options.Stateless = stateless; + }); + + _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")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task DraftRequest_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() + { + await StartAsync(stateless: true); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + + // On a stateless server, sessionless draft server/discover succeeds without creating a session. + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Draft responses must not include Mcp-Session-Id"); + } + + [Fact] + public async Task DraftRequest_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() + { + // The draft revision is sessionless (SEP-2567), so it cannot honor a server configured with + // sessions (Stateless = false). The server refuses the draft version with + // UnsupportedProtocolVersion (excluding draft from Supported) so a dual-era client falls back + // to the legacy initialize handshake. + await StartAsync(stateless: false); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var rpcMessage = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + var rpcError = Assert.IsType(rpcMessage); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code); + + var dataElement = (JsonElement)rpcError.Error.Data!; + var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); + Assert.NotNull(errorData); + Assert.Equal(DraftVersion, errorData.Requested); + // The draft version is excluded from Supported so the client downgrades to a legacy version. + Assert.NotEmpty(errorData.Supported); + Assert.DoesNotContain(DraftVersion, errorData.Supported); + } + + [Fact] + public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var rpcMessage = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + var rpcError = Assert.IsType(rpcMessage); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, rpcError.Error.Code); + + // Validate the structured data payload (SEP-2575 §"Unsupported Protocol Versions"). + var dataElement = (JsonElement)rpcError.Error.Data!; + var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); + Assert.NotNull(errorData); + Assert.Equal("2099-12-31", errorData.Requested); + Assert.NotEmpty(errorData.Supported); + } + + [Fact] + public async Task DraftRequest_WithMcpSessionIdHeader_IsRejected() + { + // The draft revision is sessionless (SEP-2567): a draft request carrying an Mcp-Session-Id is + // non-conformant and is rejected with 400 regardless of the Stateless setting. + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + var content = new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftGet_WithoutSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftGet_WithSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftDelete_WithoutSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + + using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DraftDelete_WithSessionId_IsRejected() + { + await StartAsync(); + + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); + + using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs new file mode 100644 index 000000000..18815fd00 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// End-to-end coverage for a default (draft-first) client connecting to a real C# Streamable HTTP +/// server that deliberately opted into sessions ( +/// is false). Draft is sessionless (SEP-2567 / SEP-2575), so the server refuses the +/// sessionless draft probe with -32004 UnsupportedProtocolVersion. The client must then +/// auto-downgrade to the legacy initialize handshake, obtain the stateful session the server +/// author opted into, and continue to work — including a server→client elicitation round-trip +/// resolved over the stateful session via the legacy backcompat resolver. +/// +public class DraftStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [McpServerTool(Name = "greet")] + private static string Greet([System.ComponentModel.Description("Name to greet")] string name) => $"Hello, {name}!"; + + [McpServerTool(Name = "greet_via_elicit")] + private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) + { + // Server→client round-trip: only works when the session is stateful, which is exactly what + // the legacy fallback re-establishes for the draft-first client. + 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}!"; + } + + private async Task StartStatefulServerAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftStatefulFallbackTests), Version = "1" }; + }) + // Stateless = false is a deliberate opt-in to sessions. Draft can never be served + // statefully, so the server refuses the sessionless draft probe and the client downgrades. + .WithHttpTransport(options => options.Stateless = false) + .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + private async Task ConnectDefaultClientAsync(Action? configureClient = null) + { + await using var transport = new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:5000/"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + // Default options: ProtocolVersion is null, which now prefers the draft revision and probes + // with server/discover before falling back to a legacy initialize handshake. + var clientOptions = new McpClientOptions(); + configureClient?.Invoke(clientOptions); + return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DefaultDraftClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + { + await StartStatefulServerAsync(); + + await using var client = await ConnectDefaultClientAsync(); + + // The sessionless draft probe was refused (-32004), so the client downgraded to legacy. + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("greet", + new Dictionary { ["name"] = "Alice" }, + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("Hello, Alice!", text); + } + + [Fact] + public async Task DefaultDraftClient_AgainstStatefulServer_ServerToClientElicitation_RoundTrips() + { + await StartStatefulServerAsync(); + + await using var client = await ConnectDefaultClientAsync(options => + { + options.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse("\"Bob\"").RootElement.Clone(), + }, + }); + }); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("greet_via_elicit", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("Hello, Bob!", text); + Assert.True(result.IsError is not true); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index b950553f5..db751af6b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -122,7 +122,7 @@ public async Task Server_AcceptsUnionIntegerCanonicalForm() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "union_test"); request.Headers.Add("Mcp-Param-Priority", "42"); @@ -141,7 +141,7 @@ public async Task Server_RejectsUnionIntegerOutsideSafeRange() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "union_test"); request.Headers.Add("Mcp-Param-Priority", "9007199254740993"); @@ -161,7 +161,7 @@ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -185,7 +185,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); request.Headers.TryAddWithoutValidation("Mcp-Name", " header_test "); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -208,7 +208,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", " tools/call "); request.Headers.TryAddWithoutValidation("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -232,7 +232,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -255,7 +255,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "us-west1"); @@ -281,7 +281,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", encodedValue!); @@ -306,7 +306,7 @@ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -334,7 +334,7 @@ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -363,7 +363,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -391,7 +391,7 @@ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(strin using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -414,7 +414,7 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); request.Headers.Add("Mcp-Param-Region", "test"); @@ -476,7 +476,7 @@ public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call"); // Raw UTF-8 non-ASCII value in Mcp-Name — server must reject this request.Headers.TryAddWithoutValidation("Mcp-Name", "café☕"); @@ -555,7 +555,7 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() #region Version gating tests [Theory] - [InlineData("DRAFT-2026-v1", true)] + [InlineData("2026-07-28", true)] [InlineData("2025-11-25", false)] [InlineData("2025-06-18", false)] [InlineData("2024-11-05", false)] @@ -576,15 +576,15 @@ private async Task InitializeWithDraftVersionAsync() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(InitializeRequestDraft); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + // Draft protocol revision (SEP-2567) is sessionless: the server does not return a + // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 + // to route through the sessionless path. } private async Task InitializeWithNonDraftVersionAsync() @@ -594,9 +594,8 @@ private async Task InitializeWithNonDraftVersionAsync() using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + // Server is stateless by default (SEP-2567), so initializing with the non-draft protocol does not return + // a mcp-session-id header. Subsequent requests are independent, just like the draft path. } private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); @@ -616,8 +615,9 @@ private string CallTool(string toolName, string arguments = "{}") """; private static string InitializeRequestDraft => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; #endregion } + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs index ef385ed70..6f36d1421 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpMcpServerBuilderExtensionsTests.cs @@ -211,7 +211,7 @@ public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() { Builder.Services .AddMcpServer() - .WithHttpTransport(); + .WithHttpTransport(options => options.Stateless = false); using var app = Builder.Build(); @@ -220,7 +220,7 @@ public async Task IdleTrackingBackgroundService_StartsTimer_WhenStateful() await idleTrackingService.StartAsync(TestContext.Current.CancellationToken); - // In the default (stateful) mode the timer loop must start, so ExecuteTask should be set. + // In stateful mode the timer loop must start, so ExecuteTask should be set. Assert.NotNull(idleTrackingService.ExecuteTask); await idleTrackingService.StopAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 5f961fe32..5fd4d49e4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -34,7 +34,9 @@ public async Task ConnectAndPing_Sse_TestServer() // Arrange // Act - await using var client = await GetClientAsync(); + // ping was removed in the draft revision (SEP-2575), so pin to the latest stable protocol + // version to keep exercising the legacy ping RPC. Draft liveness relies on the transport. + await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert @@ -47,7 +49,9 @@ public async Task Connect_TestServer_ShouldProvideServerFields() // Arrange // Act - await using var client = await GetClientAsync(); + // Stateful Streamable HTTP only provisions a session ID under the legacy handshake; the draft + // revision is sessionless. Pin to the latest stable version to keep covering session-ID provisioning. + await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" }); // Assert Assert.NotNull(client.ServerCapabilities); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs index b796d78c2..05f9bdff3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpSseTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -21,7 +21,7 @@ protected override void ConfigureStateless(HttpServerTransportOptions options) [InlineData("/mcp/secondary")] public async Task Allows_Customizing_Route(string pattern) { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(pattern); @@ -53,7 +53,7 @@ public async Task CanConnect_WithMcpClient_AfterCustomizingRoute(string routePat Name = "TestCustomRouteServer", Version = "1.0.0", }; - }).WithHttpTransport(options => options.EnableLegacySse = true); + }).WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(routePattern); @@ -83,7 +83,7 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_InSseMode() return "Complete"; }, options: new() { Name = "polling_tool" }); - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true).WithTools([pollingTool]); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }).WithTools([pollingTool]); await using var app = Builder.Build(); app.MapMcp(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 3d532802b..dcd1bcf50 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -410,7 +410,7 @@ public async Task CanResumeSessionWithMapMcpAndRunSessionHandler() OwnsSession = false, }, HttpClient, LoggerFactory); - await using (var initialClient = await McpClient.CreateAsync(initialTransport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + await using (var initialClient = await McpClient.CreateAsync(initialTransport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) { resumedSessionId = initialClient.SessionId ?? throw new InvalidOperationException("SessionId not negotiated."); serverCapabilities = initialClient.ServerCapabilities; @@ -486,7 +486,9 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenNoEvent await app.StartAsync(TestContext.Current.CancellationToken); - await using var mcpClient = await ConnectAsync(); + // Polling via an event-stream store is a stateful-session feature. Under draft, Streamable HTTP + // is sessionless, so pin to the latest stable version to keep exercising the stateful path. + await using var mcpClient = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); @@ -538,7 +540,9 @@ public async Task AdditionalHeaders_AreSent_InPostAndDeleteRequests() }, }; - await using var mcpClient = await ConnectAsync(transportOptions: transportOptions); + // DELETE requests are only sent when there's a session ID to delete - a legacy stateful + // behavior. Under draft, Streamable HTTP is sessionless. Pin to the latest stable version. + await using var mcpClient = await ConnectAsync(transportOptions: transportOptions, configureClient: options => options.ProtocolVersion = "2025-11-25"); // Do a tool call to ensure there's more than just the initialize request await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -589,7 +593,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse() OwnsSession = false, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Call a tool to ensure the session is fully established var result = await client.CallToolAsync( @@ -657,7 +661,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithUnsolicite OwnsSession = false, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var result = await client.CallToolAsync( "echo_claims_principal", @@ -717,7 +721,9 @@ public async Task Client_CanReconnect_AfterSessionExpiry() await app.StartAsync(TestContext.Current.CancellationToken); // Connect the first client and verify it works. - var client1 = await ConnectAsync(); + // Server-side session expiry and reconnect rely on session IDs, a legacy stateful behavior. + // Under draft, Streamable HTTP is sessionless. Pin both clients to the latest stable version. + var client1 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); var originalSessionId = client1.SessionId; Assert.NotNull(originalSessionId); @@ -739,7 +745,7 @@ await Assert.ThrowsAnyAsync(async () => await client1.DisposeAsync(); // Reconnect with a brand-new session. - await using var client2 = await ConnectAsync(); + await using var client2 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); Assert.NotNull(client2.SessionId); Assert.NotEqual(originalSessionId, client2.SessionId); @@ -753,6 +759,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() { var capturedSessionIds = new ConcurrentBag<(string? BeforeNext, string? AfterNext, string Method)>(); var capturedActivityTags = new ConcurrentBag<(string? TagValue, bool HadActivity, string Method)>(); + var requestObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -782,18 +789,31 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() capturedSessionIds.Add((beforeSessionId, afterSessionId, httpContext.Request.Method)); capturedActivityTags.Add((tagValue, activity is not null, httpContext.Request.Method)); + requestObserved.TrySetResult(); return result; }); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectAsync(); + // The stateful (else) branch below asserts session-ID behavior, which only exists under the + // legacy handshake; the draft revision is sessionless. Pin legacy only for the stateful variant. + await using var client = await ConnectAsync(configureClient: options => + { + if (!Stateless) + { + options.ProtocolVersion = "2025-11-25"; + } + }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // The filter must have observed at least one MCP request. Don't assert an exact - // minimum - the initialized notification or GET stream may not have completed yet. + // The filter records into the bag *after* await next(context) returns. For a streamed SSE + // response the client can observe completion (and ListToolsAsync can return) before that + // server-side continuation runs, so asserting the bag immediately races. Wait for the filter + // to record at least one request first. Don't assert an exact minimum - the initialized + // notification or GET stream may not have completed yet. + await requestObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); Assert.NotEmpty(capturedSessionIds); if (Stateless) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index ddae6c66b..e5c5a123f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -10,6 +10,13 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { + // Draft is sessionless (SEP-2567): the Streamable HTTP handler refuses a sessionless draft request + // when the server opted into sessions (Stateless = false), so a draft-pinned client downgrades to + // legacy instead of negotiating 2026-07-28. These draft 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 DraftStatefulStreamableHttpSkipReason = + "Draft is sessionless (SEP-2567); stateful Streamable HTTP refuses sessionless draft. Covered by the stateless and SSE fixtures."; + private ServerMessageTracker ConfigureServer(params Delegate[] tools) { var messageTracker = new ServerMessageTracker(); @@ -17,7 +24,7 @@ private ServerMessageTracker ConfigureServer(params Delegate[] tools) { options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; // Do not pin a protocol version - let it be negotiated based on what the client requests. - // DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get + // 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get // the latest non-draft. messageTracker.AddFilters(options.Filters.Message); }) @@ -30,11 +37,17 @@ private Task ConnectExperimentalAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); - private Task ConnectDefaultAsync() => - ConnectAsync(configureClient: ConfigureMrtrHandlers); + // The default client now negotiates draft (2026-07-28). The legacy JSON-RPC MRTR back-compat + // resolver only applies to legacy clients, so pin these to the latest non-draft version. + private Task ConnectLegacyAsync() => + ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + options.ProtocolVersion = "2025-11-25"; + }); /// Configures elicitation, sampling, and roots handlers on client options. private static void ConfigureMrtrHandlers(McpClientOptions options) @@ -79,7 +92,7 @@ private static void ConfigureMrtrHandlers(McpClientOptions options) // ===================================================================== // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. - // Each test creates its own server with DRAFT-2026-v1 enabled. + // Each test creates its own server with 2026-07-28 enabled. // ===================================================================== [McpServerTool(Name = "mrtr-mixed")] @@ -156,8 +169,15 @@ private static async Task MrtrMixed(McpServer server, RequestContext configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. // In stateless mode, those calls succeed only when the request is still open on the same @@ -180,6 +201,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) // and no persistent server instance for the backcompat retry loop). The server returns // a JSON-RPC error. await using var client = await ConnectAsync(configureClient: configureClient); + var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-mixed", cancellationToken: TestContext.Current.CancellationToken).AsTask()); @@ -202,7 +224,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient) // Stateful path - both client modes complete all 3 rounds. await using var statefulClient = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", + Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", statefulClient.NegotiatedProtocolVersion); var result = await statefulClient.CallToolAsync("mrtr-mixed", @@ -267,6 +289,10 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) // Parallel awaits work with regular JSON-RPC but fail with MRTR because // MrtrContext only supports one exchange at a time (TrySetResult gate). Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + // Under the draft protocol revision (SEP-2567), the server is implicitly stateless for draft + // clients, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client + // case for the same reason as Mrtr_MixedExceptionAndAwaitStyle. + Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) is sessionless."); ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); @@ -274,8 +300,9 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) await app.StartAsync(TestContext.Current.CancellationToken); Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); @@ -283,7 +310,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) { // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second // call throws InvalidOperationException, which the tool catches and returns as text. - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", cancellationToken: TestContext.Current.CancellationToken); @@ -330,6 +357,8 @@ private static string MrtrElicit(RequestContext context) [Fact] public async Task Mrtr_Roots_CompletesViaMrtr() { + Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => { @@ -351,7 +380,7 @@ public async Task Mrtr_Roots_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots", cancellationToken: TestContext.Current.CancellationToken); @@ -406,6 +435,8 @@ private static string MrtrMulti(RequestContext context) [InlineData(false)] public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) { + Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer(MrtrMulti); await using var app = Builder.Build(); app.MapMcp(); @@ -413,8 +444,9 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); if (!experimentalClient && Stateless) @@ -437,7 +469,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) if (experimentalClient) { - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); messageTracker.AssertMrtrUsed(); } else @@ -452,6 +484,8 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) [InlineData(false)] public async Task Mrtr_IsMrtrSupported(bool experimentalClient) { + Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); await using var app = Builder.Build(); app.MapMcp(); @@ -459,10 +493,11 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } + // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-check", cancellationToken: TestContext.Current.CancellationToken); @@ -515,6 +550,8 @@ private static string MrtrConcurrentThree(RequestContext [Fact] public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() { + Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer(MrtrConcurrentThree); await using var app = Builder.Build(); app.MapMcp(); @@ -526,7 +563,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() await using var client = await ConnectAsync(configureClient: options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; options.Handlers.ElicitationHandler = async (request, ct) => { elicitCalled.TrySetResult(); @@ -553,7 +590,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() }; }; }); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-concurrent-three", cancellationToken: TestContext.Current.CancellationToken); @@ -567,6 +604,8 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() [Fact] public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() { + Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => { @@ -582,7 +621,7 @@ public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-loadshed", cancellationToken: TestContext.Current.CancellationToken); @@ -617,7 +656,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectDefaultAsync(); + await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots-backcompat", @@ -668,7 +707,7 @@ public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectDefaultAsync(); + await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-multi-input", @@ -707,6 +746,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() await using var client = await ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); + options.ProtocolVersion = "2025-11-25"; var originalHandler = options.Handlers.ElicitationHandler!; options.Handlers.ElicitationHandler = (request, ct) => { @@ -739,7 +779,7 @@ public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - await using var client = await ConnectDefaultAsync(); + await using var client = await ConnectLegacyAsync(); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var ex = await Assert.ThrowsAsync(() => @@ -762,6 +802,7 @@ public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError() await using var client = await ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); + options.ProtocolVersion = "2025-11-25"; options.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index b9b8381ca..b269c9951 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -111,7 +111,10 @@ public async Task Messages_FromNewUser_AreRejected() await app.StartAsync(TestContext.Current.CancellationToken); - var httpRequestException = await Assert.ThrowsAsync(() => ConnectAsync()); + // Session-scoped user validation across requests is a legacy stateful-session behavior; the + // draft revision is sessionless. Pin to the latest stable version to keep covering it. + var httpRequestException = await Assert.ThrowsAsync( + () => ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25")); Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode); } @@ -159,6 +162,10 @@ public async Task Sampling_DoesNotCloseStreamPrematurely() var sampleCount = 0; await using var mcpClient = await ConnectAsync(configureClient: options => { + // Server->client sampling over the open response stream is a stateful-session behavior. + // Under draft, Streamable HTTP is forced sessionless, so the implicit-MRTR suspend path + // doesn't apply over HTTP (draft sampling is covered by the stdio MRTR tests). Pin legacy. + options.ProtocolVersion = "2025-11-25"; options.Handlers.SamplingHandler = async (parameters, _, _) => { Assert.NotNull(parameters?.Messages); @@ -319,7 +326,13 @@ await client.CallToolAsync("echo_with_user_name", new Dictionary { ["message"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); - Assert.Contains(RequestMethods.Initialize, observedMethods); + // The client now defaults to the draft revision, whose handshake is server/discover + // rather than the legacy initialize request. On the stateful Streamable HTTP fixture the + // sessionless draft request is refused, so the client downgrades to the legacy initialize. + var expectedHandshakeMethod = UseStreamableHttp && !Stateless + ? RequestMethods.Initialize + : RequestMethods.ServerDiscover; + Assert.Contains(expectedHandshakeMethod, observedMethods); Assert.Contains(RequestMethods.ToolsList, observedMethods); Assert.Contains(RequestMethods.ToolsCall, observedMethods); } @@ -373,6 +386,9 @@ public async Task OutgoingFilter_SeesResponsesAndRequests() await using var client = await ConnectAsync(configureClient: opts => { + // Server-originated sampling requests and the initialize response are legacy stateful + // behaviors; the draft revision routes sampling through MRTR and drops initialize. + opts.ProtocolVersion = "2025-11-25"; opts.Capabilities = clientOptions.Capabilities; opts.Handlers = clientOptions.Handlers; }); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index acdcfa456..eb036ad29 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0 @@ -7,6 +7,8 @@ false true ModelContextProtocol.AspNetCore.Tests + + $(NoWarn);MCP9006 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 6be82aec0..15c415683 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -14,12 +13,19 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Protocol-level tests for Multi Round-Trip Requests (MRTR). -/// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior -/// including InputRequiredResult structure, retry with inputResponses, and error handling. +/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the draft revision. +/// Under the draft protocol (SEP-2575 + SEP-2567) Streamable HTTP is sessionless, so these tests +/// drive the default server with raw, sessionless +/// draft JSON-RPC requests (no initialize, no Mcp-Session-Id) and verify the explicit +/// MRTR structure, retry with inputResponses, and error handling. +/// Stateful-session MRTR behaviors (implicit handler suspension, disposal cancellation) are covered +/// over stdio by MrtrHandlerLifecycleTests, and unknown-session rejection by +/// StreamableHttpServerConformanceTests.PostRequest_IsNotFound_WithUnrecognizedSessionId. /// public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private WebApplication? _app; private async Task StartAsync() @@ -31,30 +37,105 @@ private async Task StartAsync() Name = nameof(MrtrProtocolTests), Version = "1", }; - options.ProtocolVersion = "DRAFT-2026-v1"; }).WithTools([ McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => + static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams), + new McpServerToolCreateOptions + { + Name = "throwing-tool", + Description = "A tool that throws immediately" + }), + McpServerTool.Create( + static CallToolResult (RequestContext context) => { - var result = await server.ElicitAsync(new ElicitRequestParams + // Mirrors ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState: + // R1 (no requestState) issues a requestState; R2 with a tampered requestState + // surfaces a JSON-RPC error rather than a complete result or a re-prompt. + if (context.Params!.RequestState is { } state) { - Message = message, - RequestedSchema = new() - }, ct); + if (state != "valid-request-state-token") + { + throw new McpProtocolException( + "requestState failed integrity verification.", McpErrorCode.InvalidParams); + } - return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + return new CallToolResult { Content = [new TextContentBlock { Text = "state-ok" }] }; + } + + throw new InputRequiredException( + new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["ok"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["ok"], + }, + }), + }, + requestState: "valid-request-state-token"); }, new McpServerToolCreateOptions { - Name = "elicit-tool", - Description = "Elicits from client" + Name = "tampered-state-tool", + Description = "Rejects a tampered requestState with a JSON-RPC error" }), McpServerTool.Create( - static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams), + static CallToolResult (RequestContext context) => + { + // Mirrors ConformanceServer.Tools.IncompleteResultTools.ToolWithCapabilityCheck: + // emit inputRequests only for capabilities declared on the per-request _meta envelope. + var caps = context.JsonRpcRequest.Context?.ClientCapabilities; + var inputRequests = new Dictionary(); + + if (caps?.Sampling is not null) + { + inputRequests["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "What is the capital of France?" }], + }, + ], + MaxTokens = 100, + }); + } + + if (caps?.Elicitation is not null) + { + inputRequests["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }); + } + + if (inputRequests.Count == 0) + { + return new CallToolResult { Content = [new TextContentBlock { Text = "no-caps" }] }; + } + + throw new InputRequiredException(inputRequests); + }, new McpServerToolCreateOptions { - Name = "throwing-tool", - Description = "A tool that throws immediately" + Name = "capability-check-tool", + Description = "Gates inputRequests on the per-request _meta clientCapabilities envelope" }), ]).WithHttpTransport(); @@ -62,8 +143,12 @@ private async Task StartAsync() _app.MapMcp(); await _app.StartAsync(TestContext.Current.CancellationToken); + // Drive the server with sessionless draft requests: every request carries the draft + // MCP-Protocol-Version header and (via PostJsonRpcAsync) the SEP-2243 Mcp-Method/Mcp-Name + // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); } public async ValueTask DisposeAsync() @@ -79,7 +164,6 @@ public async ValueTask DisposeAsync() public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() { await StartAsync(); - await InitializeWithMrtrAsync(); var response = await PostJsonRpcAsync(CallTool("throwing-tool")); @@ -94,123 +178,71 @@ public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() } [Fact] - public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() + public async Task TamperedRequestState_ReturnsJsonRpcError() { await StartAsync(); - await InitializeWithMrtrAsync(); - - // Send a retry with a requestState that doesn't match any active continuation - var retryParams = new JsonObject - { - ["name"] = "elicit-tool", - ["arguments"] = new JsonObject { ["message"] = "test" }, - ["inputResponses"] = new JsonObject { ["key1"] = new JsonObject { ["action"] = "confirm" } }, - ["requestState"] = "nonexistent-state-id" - }; - - var response = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); - // Read as a generic JsonRpcMessage to check if it's an error - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); - var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); - - // Invalid requestState should result in a fresh tool invocation - // (the tool will return InputRequiredResult since it calls ElicitAsync) - // or an error, depending on the implementation. - // In our implementation, unrecognized requestState triggers a new invocation. - Assert.True( - message is JsonRpcResponse or JsonRpcError, - $"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}"); - } - - [Fact] - public async Task SessionDelete_CancelsPendingMrtrContinuation() - { - await StartAsync(); - await InitializeWithMrtrAsync(); + // Round 1: no requestState -> InputRequiredResult carrying the issued requestState. + using var r1 = await PostJsonRpcAsync(CallTool("tampered-state-tool")); + var r1Response = await AssertSingleSseResponseAsync(r1); + var r1Result = Assert.IsType(r1Response.Result); + Assert.Equal("input_required", r1Result["resultType"]?.GetValue()); - // 1. Call a tool that suspends at ElicitAsync (implicit MRTR path). - var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); - var rpcResponse = await AssertSingleSseResponseAsync(response); + var requestState = r1Result["requestState"]!.GetValue(); + var inputKey = r1Result["inputRequests"]!.AsObject().First().Key; - // Verify we got an InputRequiredResult (handler is now suspended, continuation stored). - var resultObj = Assert.IsType(rpcResponse.Result); - Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); - var requestState = resultObj["requestState"]!.GetValue(); - Assert.False(string.IsNullOrEmpty(requestState)); - - // 2. DELETE the session while the handler is suspended. - using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - - // Poll for the async cancellation to propagate through the handler task. - // Under thread pool starvation, this can take significantly longer than 100ms. - var deadline = DateTime.UtcNow.AddSeconds(30); - while (true) + // Round 2: tamper the requestState the way the conformance harness does and retry. + // The tool MUST reject it with a JSON-RPC error (not a complete result, not a re-prompt). + var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); + var retryParams = new JsonObject { - if (MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) - || DateTime.UtcNow >= deadline) + ["name"] = "tampered-state-tool", + ["arguments"] = new JsonObject(), + ["requestState"] = requestState + "-TAMPERED", + ["inputResponses"] = new JsonObject { - break; - } + [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) + }, + }; - await Task.Delay(100, TestContext.Current.CancellationToken); - } + using var r2 = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + Assert.Equal(HttpStatusCode.OK, r2.StatusCode); - // 3. Verify that the MRTR cancellation was logged at Debug level. - var mrtrCancelledLog = MockLoggerProvider.LogMessages - .Where(m => m.Message.Contains("pending MRTR continuation")) - .ToList(); - Assert.Single(mrtrCancelledLog); - Assert.Equal(LogLevel.Debug, mrtrCancelledLog[0].LogLevel); - Assert.Contains("1", mrtrCancelledLog[0].Message); - - // 4. Verify no error-level log was emitted for the cancellation. - // The handler's OperationCanceledException should be silently observed, not logged as an error. - var errorLogs = MockLoggerProvider.LogMessages - .Where(m => m.LogLevel >= LogLevel.Error && m.Message.Contains("elicit")) - .ToList(); - Assert.Empty(errorLogs); + var sseData = Assert.Single(await ReadSseAsync(r2.Content).ToListAsync(TestContext.Current.CancellationToken)); + var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); + var error = Assert.IsType(message); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); } [Fact] - public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() + public async Task CapabilityCheck_OnlyEmitsInputRequestsForDeclaredCapabilities() { await StartAsync(); - await InitializeWithMrtrAsync(); - - // 1. Call a tool that suspends at ElicitAsync. - var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); - var rpcResponse = await AssertSingleSseResponseAsync(response); - - var resultObj = Assert.IsType(rpcResponse.Result); - var requestState = resultObj["requestState"]!.GetValue(); - var inputRequests = resultObj["inputRequests"]!.AsObject(); - var inputKey = inputRequests.First().Key; - // 2. DELETE the session. - using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - - // 3. Attempt to retry with the old requestState - session is gone. - var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); - var retryParams = new JsonObject + // Per SEP-2575 the client declares capabilities per request in + // _meta['io.modelcontextprotocol/clientCapabilities']. Declare ONLY sampling: the tool + // must emit a sampling/createMessage inputRequest but no elicitation/create. + var callParams = new JsonObject { - ["name"] = "elicit-tool", - ["arguments"] = new JsonObject { ["message"] = "Please confirm" }, - ["requestState"] = requestState, - ["inputResponses"] = new JsonObject + ["name"] = "capability-check-tool", + ["arguments"] = new JsonObject(), + ["_meta"] = new JsonObject { - [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) + ["io.modelcontextprotocol/clientCapabilities"] = new JsonObject + { + ["sampling"] = new JsonObject(), + }, }, }; - using var retryResponse = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + using var response = await PostJsonRpcAsync(Request("tools/call", callParams.ToJsonString())); + var rpcResponse = await AssertSingleSseResponseAsync(response); + var resultObj = Assert.IsType(rpcResponse.Result); + Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); - // The session was deleted, so we should get a 404 with a JSON-RPC error. - Assert.Equal(HttpStatusCode.NotFound, retryResponse.StatusCode); - Assert.Equal("application/json", retryResponse.Content.Headers.ContentType?.MediaType); + var inputRequests = resultObj["inputRequests"]!.AsObject(); + Assert.Contains(inputRequests, kvp => kvp.Value!["method"]?.GetValue() == "sampling/createMessage"); + Assert.DoesNotContain(inputRequests, kvp => kvp.Value!["method"]?.GetValue() == "elicitation/create"); } /// @@ -229,9 +261,9 @@ public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() [Fact] public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { - // Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current + // Configure a server that does NOT pin 2026-07-28 so it can negotiate the current // protocol with a legacy client. The backcompat resolver path only runs when the - // negotiated version is not DRAFT-2026-v1. + // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation @@ -262,7 +294,7 @@ static string (RequestContext context) => Name = "backcompat-roots-tool", Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", }), - ]).WithHttpTransport(); + ]).WithHttpTransport(options => options.Stateless = false); _app = Builder.Build(); _app.MapMcp(); @@ -395,7 +427,7 @@ private Task PostJsonRpcAsync(string json) { var content = JsonContent(json); - // DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. + // 2026-07-28 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. // Parse the body to derive them and attach to this request only. var bodyNode = JsonNode.Parse(json); if (bodyNode is JsonObject obj) @@ -437,33 +469,4 @@ private string CallTool(string toolName, string arguments = "{}") => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} """); - - /// - /// Initialize a session requesting the experimental protocol version that enables MRTR. - /// - private async Task InitializeWithMrtrAsync() - { - var initJson = """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} - """; - - using var response = await PostJsonRpcAsync(initJson); - var rpcResponse = await AssertSingleSseResponseAsync(response); - Assert.NotNull(rpcResponse.Result); - - // Verify the server negotiated to the experimental version - var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); - Assert.Equal("DRAFT-2026-v1", protocolVersion); - - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); - HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); - - // Set the MCP-Protocol-Version header for subsequent requests - HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); - - // Reset request ID counter since initialize used ID 1 - _lastRequestId = 1; - } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs index 3c1919b0b..f9a4b64c0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs @@ -62,7 +62,7 @@ protected OAuthTestBase(ITestOutputHelper outputHelper, bool configureMcpMetadat }); Builder.Services.AddAuthorization(); - Builder.Services.AddMcpServer().WithHttpTransport(); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = false); } public async ValueTask DisposeAsync() diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs new file mode 100644 index 000000000..4ec8ba4d9 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -0,0 +1,219 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Wire-format conformance tests for the Streamable HTTP server driven directly via , +/// without going through . These hand-craft HTTP +/// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 + +/// SEP-2567 (sessionless, no-initialize) draft revision. +/// +public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private const string ProtocolVersionHeader = "MCP-Protocol-Version"; + + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + }) + .WithHttpTransport() + .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); + + _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")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + + /// + /// Reads either a direct JSON response or a single SSE message containing JSON-RPC and returns the + /// parsed JsonNode. The Streamable HTTP server can return either content type depending on negotiation; + /// raw HttpClient tests should accept either. + /// + private static async Task ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var contentType = response.Content.Headers.ContentType?.MediaType; + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (contentType == "text/event-stream") + { + // Pull the first non-empty data: line out of the SSE payload. + foreach (var line in body.Split('\n')) + { + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + var data = line.Substring("data:".Length).Trim(); + if (data.Length > 0) + { + return JsonNode.Parse(data)!; + } + } + } + throw new InvalidOperationException("SSE response did not contain a JSON data event. Body: " + body); + } + + return JsonNode.Parse(body)!; + } + + private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; + + [Fact] + public async Task DraftToolsCall_WithFullMeta_Succeeds_200() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + + DraftMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); + + // Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id. + Assert.False(response.Headers.Contains("mcp-session-id")); + } + + [Fact] + public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + + // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the + // safest defaults (immediately stale, not shareable) when the application hasn't customized. + Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind()); + Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue()); + Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); + } + + [Fact] + public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + DraftMetaFragment("9999-99-99") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, "9999-99-99"); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32004 and a data payload + // listing the supported versions. The dual-era client uses this to switch versions without fallback. + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + + var data = json["error"]!["data"]; + Assert.NotNull(data); + Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); + var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + } + + [Fact] + public async Task DraftPost_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32001() + { + await StartAsync(); + + // The MCP-Protocol-Version header declares the draft revision, but the per-request _meta declares a + // different (still individually supported) version. Per SEP-2575 the server MUST reject the + // disagreement. It uses -32001 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body + // checks) so a conformant draft client surfaces the error instead of mistaking the modern server for a + // legacy one and falling back to the initialize handshake. + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + + DraftMetaFragment("2025-11-25") + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, DraftVersion); + 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.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task LegacyInitialize_StillSucceeds_OnDefaultServer() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"; + + 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.Equal("2025-11-25", json["result"]!["protocolVersion"]!.GetValue()); + } + + [Fact] + public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Returns405() + { + await StartAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, ""); + 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 + // 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 new file mode 100644 index 000000000..501d698a6 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -0,0 +1,162 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Net.Http.Headers; +using System.Text; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Verifies that aborting an HTTP request flows cancellation into the running request handler's +/// . +/// +/// Under the draft protocol revision (SEP-2575 + SEP-2567) the HTTP request lifetime is the +/// request lifetime: there are no sessions, so a dropped connection is equivalent to cancelling the +/// in-flight request. The same holds for legacy stateless mode, where each request is independent and +/// outlived by nothing. These tests pin that behavior so a tool's fires +/// promptly when the client goes away. +/// +/// +public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private WebApplication? _app; + + private readonly TaskCompletionSource _toolStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _toolCanceled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _requestAborted = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private async Task StartAsync(bool stateless) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = nameof(RequestAbortCancellationTests), Version = "1" }; + }) + .WithHttpTransport(options => + { + options.Stateless = stateless; + }) + .WithTools([McpServerTool.Create( + async (CancellationToken cancellationToken) => + { + _toolStarted.TrySetResult(); + try + { + // Block until the request handler's CancellationToken fires. If cancellation never + // flows from the aborted HTTP request, this hangs and the test times out. + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException) + { + _toolCanceled.TrySetResult(); + throw; + } + + return "unreachable"; + }, + new() { Name = "blockingTool" })]); + + _app = Builder.Build(); + + // Record when the server observes the client abort so we can assert the abort (not some unrelated + // cancellation path) is what tears down the in-flight tool. + _app.Use(async (context, next) => + { + context.RequestAborted.Register(() => _requestAborted.TrySetResult()); + await next(); + }); + + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task DraftSessionlessRequest_AbortFlowsCancellationToToolHandler() + { + // Draft is sessionless (SEP-2567) and is served natively only on a stateless server; a + // Stateless=false server refuses sessionless draft so dual-era clients fall back to initialize. + await StartAsync(stateless: true); + + using var request = CreateBlockingToolRequest(draft: true); + + await AssertAbortCancelsToolAsync(request); + } + + [Fact] + public async Task StatelessRequest_AbortFlowsCancellationToToolHandler() + { + await StartAsync(stateless: true); + + using var request = CreateBlockingToolRequest(draft: false); + + await AssertAbortCancelsToolAsync(request); + } + + private static HttpRequestMessage CreateBlockingToolRequest(bool draft) + { + // Draft tools/call requires the SEP-2243 Mcp-Method/Mcp-Name headers and the per-request _meta + // (protocol version, client info, capabilities) that replaces the initialize handshake (SEP-2567). + var body = draft + ? """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"DRAFT_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """.Replace("DRAFT_VERSION", DraftVersion) + : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; + + var request = new HttpRequestMessage(HttpMethod.Post, "") + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + if (draft) + { + request.Headers.Add("MCP-Protocol-Version", DraftVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "blockingTool"); + } + + return request; + } + + private async Task AssertAbortCancelsToolAsync(HttpRequestMessage request) + { + using var requestCts = new CancellationTokenSource(); + + // Send the request without awaiting completion. The blockingTool will not return until its + // CancellationToken fires, so this Task only completes once we abort the request below. + // ResponseContentRead (the default) keeps SendAsync pending on the response body, so cancelling + // requestCts actually aborts the in-flight connection. (With ResponseHeadersRead, SendAsync would + // return as soon as the server flushed the SSE response headers and the cancel would be a no-op.) + var sendTask = HttpClient.SendAsync(request, requestCts.Token); + + // Wait for the server to actually start running the tool before aborting. + await _toolStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // Abort the in-flight HTTP request, simulating the client disconnecting. + requestCts.Cancel(); + + // The server must observe the abort... + await _requestAborted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // ...and that abort must cancel the running tool's CancellationToken. + await _toolCanceled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // The HttpClient call itself should observe the cancellation we requested. + await Assert.ThrowsAnyAsync(() => sendTask); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index 9738ffda3..c79207c2f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -490,6 +490,8 @@ protected async Task CreateServerAsync( var serverBuilder = Builder.Services.AddMcpServer() .WithHttpTransport(options => { + // Resumability is a stateful concern; pin Stateless = false now that the new default is true. + options.Stateless = false; options.EventStreamStore = eventStreamStore; configureTransport?.Invoke(options); }) @@ -515,7 +517,11 @@ protected async Task ConnectClientAsync() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - return await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + // Resumability (Last-Event-ID) and Mcp-Session-Id are removed in the draft revision + // (SEP-2567). Pin the client to the latest stable version so it negotiates the stateful, + // resumable legacy handshake instead of the sessionless draft default. + return await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, + loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index e996ffd1d..c0990737e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -143,7 +143,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() !NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - // SEP-2243 is a draft (DRAFT-2026-v1) scenario that uses the stateless lifecycle, so it + // SEP-2243 is a draft (2026-07-28) scenario that uses the stateless lifecycle, so it // requires a stateless server (a stateful server rejects the un-initialized list/call // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with // the stateful class fixture (300x) or the caching stateless server (301x). @@ -151,7 +151,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() TestContext.Current.CancellationToken, basePort: 3021); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version DRAFT-2026-v1"); + $"server --url {server.ServerUrl} --scenario http-header-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -169,32 +169,48 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() TestContext.Current.CancellationToken, basePort: 3024); var result = await RunStatelessConformanceTestAsync( - $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version DRAFT-2026-v1"); + $"server --url {server.ServerUrl} --scenario http-custom-header-server-validation --spec-version 2026-07-28"); Assert.True(result.Success, $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } - // SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios. + // SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) conformance scenarios. // The csharp-sdk ConformanceServer surfaces the matching tools/prompts via - // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts. - // Each scenario uses the conformance harness's RawMcpSession, which negotiates DRAFT-2026-v1 + // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts + // (the class names predate the conformance-suite rename from "incomplete-result-*" to + // "input-required-result-*"; the wire-level tool names now match the new convention). + // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28 // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the - // upstream conformance package ships with SEP-2322 scenarios - // (https://github.com/modelcontextprotocol/conformance/pull/188). + // installed conformance package ships SEP-2322 scenarios and emits this SDK's + // draft wire string (see ). + // + // input-required-result-tampered-state and input-required-result-capability-check are + // implemented by ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState + // (HMAC-protected requestState; a tampered requestState surfaces a -32602 JSON-RPC error) + // and ToolWithCapabilityCheck (gates inputRequests on the per-request + // _meta clientCapabilities envelope). Both behaviors also have in-process wire-level + // regression coverage in MrtrProtocolTests so they stay verified even while the published + // conformance package's draft wire string lags this SDK. [Theory] - [InlineData("incomplete-result-basic-elicitation")] - [InlineData("incomplete-result-basic-sampling")] - [InlineData("incomplete-result-basic-list-roots")] - [InlineData("incomplete-result-request-state")] - [InlineData("incomplete-result-multiple-input-requests")] - [InlineData("incomplete-result-multi-round")] - [InlineData("incomplete-result-missing-input-response")] - [InlineData("incomplete-result-non-tool-request")] + [InlineData("input-required-result-basic-elicitation")] + [InlineData("input-required-result-basic-sampling")] + [InlineData("input-required-result-basic-list-roots")] + [InlineData("input-required-result-request-state")] + [InlineData("input-required-result-multiple-input-requests")] + [InlineData("input-required-result-multi-round")] + [InlineData("input-required-result-missing-input-response")] + [InlineData("input-required-result-non-tool-request")] + [InlineData("input-required-result-result-type")] + [InlineData("input-required-result-unsupported-methods")] + [InlineData("input-required-result-tampered-state")] + [InlineData("input-required-result-capability-check")] + [InlineData("input-required-result-ignore-extra-params")] + [InlineData("input-required-result-validate-input")] public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package (or installed version uses a stale draft wire string)."); var result = await RunConformanceTestsAsync( $"server --url {fixture.ServerUrl} --scenario {scenario}"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs index a06a5d129..7609e8215 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SessionMigrationTests.cs @@ -222,7 +222,7 @@ private async Task StartAsync(ISessionMigrationHandler? migrationHandler = null) Name = "SessionMigrationTestServer", Version = "1.0.0", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = false); if (migrationHandler is not null) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs index 800a6ce96..bd47bdb74 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SseIntegrationTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -31,7 +31,7 @@ private Task ConnectMcpClientAsync(HttpClient? httpClient = null, Htt [Fact] public async Task ConnectAndReceiveMessage_InMemoryServer() { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -84,6 +84,7 @@ public async Task ConnectAndReceiveNotification_InMemoryServer() .WithHttpTransport(httpTransportOptions => { httpTransportOptions.EnableLegacySse = true; + httpTransportOptions.Stateless = false; #pragma warning disable MCPEXP002 // RunSessionHandler is experimental httpTransportOptions.RunSessionHandler = (httpContext, mcpServer, cancellationToken) => { @@ -128,7 +129,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() { firstOptionsCallbackCallCount++; }) - .WithHttpTransport(options => options.EnableLegacySse = true) + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }) .WithTools(); Builder.Services.AddMcpServer(options => @@ -172,7 +173,7 @@ public async Task AddMcpServer_CanBeCalled_MultipleTimes() public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); @@ -219,7 +220,7 @@ public async Task AdditionalHeaders_AreSent_InGetAndPostRequests() public async Task EmptyAdditionalHeadersKey_Throws_InvalidOperationException() { Builder.Services.AddMcpServer() - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); @@ -311,7 +312,7 @@ private static void MapAbsoluteEndpointUriMcp(IEndpointRouteBuilder endpoints, b [Fact] public async Task Completion_ServerShutdown_ReturnsHttpCompletionDetails() { - Builder.Services.AddMcpServer().WithHttpTransport(options => options.EnableLegacySse = true); + Builder.Services.AddMcpServer().WithHttpTransport(options => { options.EnableLegacySse = true; options.Stateless = false; }); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs index 80c37ea61..52b0d3685 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs @@ -4,8 +4,12 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; using System.Diagnostics; using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; namespace ModelContextProtocol.AspNetCore.Tests; @@ -318,6 +322,64 @@ public async Task StatelessMode_DoesNotAdvertise_ListChangedCapabilities() Assert.Null(client.ServerCapabilities.Resources?.ListChanged); } + [Fact] + public async Task SubscriptionsListen_InStatelessMode_GrantsNothing_AndDoesNotHoldRequestOpen() + { + Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })]) + .WithPrompts([McpServerPrompt.Create(() => new GetPromptResult(), new() { Name = "myPrompt" })]) + .WithResources([McpServerResource.Create(() => new ReadResourceResult(), new() { UriTemplate = "resource://test" })]); + + _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(); + await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => { ackChannel.Writer.TryWrite(notification); return default; }); + + // Request every kind of subscription the protocol exposes, even though the server registers + // subscribable primitives. A stateless session cannot push out-of-band notifications, so the + // request must acknowledge with no grants and complete promptly instead of holding the POST + // (and its request scope) open forever - a regression would hang here until the timeout. + var listenRequest = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + PromptsListChanged = true, + ResourcesListChanged = true, + ResourceSubscriptions = ["resource://test"], + }, + }, + McpJsonUtilities.DefaultOptions), + }; + + await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // The acknowledgement is sent before the response completes, so it is already buffered here. + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var grantedNotifications = Assert.IsType(Assert.IsType(ack.Params)["notifications"]); + Assert.Null(grantedNotifications["toolsListChanged"]); + Assert.Null(grantedNotifications["promptsListChanged"]); + Assert.Null(grantedNotifications["resourcesListChanged"]); + Assert.Null(grantedNotifications["resourceSubscriptions"]); + } + [McpServerTool(Name = "testSamplingErrors")] public static async Task TestSamplingErrors(McpServer server) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 517d41e02..849941d8e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -55,7 +55,7 @@ private async Task StartAsync(bool enableDelete = false) Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new(), @@ -138,7 +138,7 @@ public async Task CanCallToolOnSessionlessStreamableHttpServer() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var echoTool = Assert.Single(tools); @@ -158,7 +158,7 @@ public async Task CanCallToolConcurrently() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var echoTool = Assert.Single(tools); @@ -184,7 +184,7 @@ public async Task SendsDeleteRequestOnDispose() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); // Dispose should trigger DELETE request await client.DisposeAsync(); @@ -206,7 +206,7 @@ public async Task DoesNotSendDeleteWhenTransportDoesNotOwnSession() OwnsSession = false, }, HttpClient, LoggerFactory); - await using (await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + await using (await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) { // No-op. Disposing the client should not trigger a DELETE request. } @@ -277,7 +277,7 @@ public async Task CreateAsyncWithKnownSessionIdThrows() }, HttpClient, LoggerFactory); var exception = await Assert.ThrowsAsync(() => - McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains(nameof(McpClient.ResumeSessionAsync), exception.Message); } @@ -311,7 +311,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithActiveGetS Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "hang-test", Version = "0.0.1" }, }, McpJsonUtilities.DefaultOptions) @@ -358,7 +358,7 @@ public async Task DisposeAsync_DoesNotHang_WhenOwnsSessionIsFalse_WithActiveGetS OwnsSession = false, }, HttpClient, LoggerFactory); - await using (var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + await using (var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) { var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Single(tools); @@ -403,7 +403,7 @@ public async Task Completion_SessionExpiredOnPost_ReturnsHttpCompletionDetails() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "expiry-test", Version = "0.0.1" }, }, McpJsonUtilities.DefaultOptions) @@ -421,7 +421,7 @@ public async Task Completion_SessionExpiredOnPost_ReturnsHttpCompletionDetails() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("expiry-test-session", client.SessionId); Assert.False(client.Completion.IsCompleted); @@ -464,7 +464,7 @@ public async Task Completion_SessionExpiredOnGet_ReturnsHttpCompletionDetails() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2024-11-05", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "get-expiry-test", Version = "0.0.1" }, }, McpJsonUtilities.DefaultOptions) @@ -489,7 +489,7 @@ public async Task Completion_SessionExpiredOnGet_ReturnsHttpCompletionDetails() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("get-expiry-test", client.SessionId); // Trigger session expiry on the GET SSE stream @@ -512,7 +512,7 @@ public async Task Completion_GracefulDisposal_ReturnsCompletionDetails() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); Assert.False(client.Completion.IsCompleted); await client.DisposeAsync(); @@ -563,7 +563,7 @@ public async Task ListTools_FiltersToolsWithInvalidHeaderAnnotations() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); // The server returns 3 tools: valid_tool, invalid_space_tool, invalid_duplicate_tool @@ -587,7 +587,7 @@ public async Task Client_SendsCorrectHeaders_EndToEnd() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var tool = Assert.Single(tools); @@ -628,7 +628,7 @@ private async Task StartHeaderToolServer() Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-test-server", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) @@ -705,7 +705,7 @@ private async Task StartHeaderCapturingServer(Dictionary capture Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2025-11-25", Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 7b282f26d..d3a3681dd 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; @@ -27,7 +27,7 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper private WebApplication? _app; - private async Task StartAsync() + private async Task StartAsync(bool stateless = false) { Builder.Services.AddMcpServer(options => { @@ -36,7 +36,7 @@ private async Task StartAsync() Name = nameof(StreamableHttpServerConformanceTests), Version = "73", }; - }).WithTools(Tools).WithHttpTransport(); + }).WithTools(Tools).WithHttpTransport(options => options.Stateless = stateless); _app = Builder.Build(); @@ -65,7 +65,7 @@ public async Task NegativeNonInfiniteIdleTimeout_Throws_ArgumentOutOfRangeExcept options.IdleTimeout = TimeSpan.MinValue; }); - var ex = await Assert.ThrowsAnyAsync(StartAsync); + var ex = await Assert.ThrowsAnyAsync(() => StartAsync()); Assert.Contains("IdleTimeout", ex.Message); } @@ -77,7 +77,7 @@ public async Task NegativeMaxIdleSessionCount_Throws_ArgumentOutOfRangeException options.MaxIdleSessionCount = -1; }); - var ex = await Assert.ThrowsAnyAsync(StartAsync); + var ex = await Assert.ThrowsAnyAsync(() => StartAsync()); Assert.Contains("MaxIdleSessionCount", ex.Message); } @@ -247,6 +247,38 @@ public async Task PostWithoutSessionId_NonInitializeRequest_Returns400() Assert.Contains("Stateless", body); } + [Fact] + public async Task PostMalformedJson_Returns400_InvalidRequest_WithNullId() + { + await StartAsync(); + + using var response = await HttpClient.PostAsync("", JsonContent("{ this is not valid json"), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + // The server must emit a conformant JSON-RPC error envelope (not a raw 500). Because the request + // id could not be read, the error carries id=null per JSON-RPC 2.0 §5.1 — and crucially it must + // serialize as JSON null, not "" (regression guard for the RequestId write path). + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("id").ValueKind); + Assert.Equal((int)McpErrorCode.InvalidRequest, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + + [Fact] + public async Task PostRequestWithExplicitNullId_Returns400_InvalidRequest_WithNullId() + { + await StartAsync(); + + // A request carrying an explicit `id:null` is malformed per the MCP base protocol ("the ID MUST + // NOT be null") and must NOT be silently treated as a notification. The server rejects it with a + // conformant 400 InvalidRequest error whose own id is null. + using var response = await HttpClient.PostAsync("", JsonContent("""{"jsonrpc":"2.0","id":null,"method":"tools/list"}"""), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("id").ValueKind); + Assert.Equal((int)McpErrorCode.InvalidRequest, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + [Fact] public async Task GetWithoutSessionId_Returns400_WithStatelessGuidance() { @@ -886,7 +918,8 @@ public async Task McpServer_UsedOutOfScope_CanSendNotifications() [Fact] public async Task DraftVersion_RejectsMissingMcpMethodHeader() { - await StartAsync(); + // Draft is sessionless and served only on a stateless server (SEP-2567). + await StartAsync(stateless: true); // Initialize with draft version to enable header validation await CallInitializeWithDraftVersionAndValidateAsync(); @@ -894,7 +927,7 @@ public async Task DraftVersion_RejectsMissingMcpMethodHeader() // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -904,13 +937,13 @@ public async Task DraftVersion_RejectsMissingMcpMethodHeader() [Fact] public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() { - await StartAsync(); + await StartAsync(stateless: true); await CallInitializeWithDraftVersionAndValidateAsync(); // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -920,13 +953,13 @@ public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() [Fact] public async Task DraftVersion_AcceptsCorrectMcpMethodHeader() { - await StartAsync(); + await StartAsync(stateless: true); await CallInitializeWithDraftVersionAndValidateAsync(); // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -956,19 +989,19 @@ private async Task CallInitializeWithDraftVersionAndValidateAsync() using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(InitializeRequestDraft); - request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); var rpcResponse = await AssertSingleSseResponseAsync(response); AssertServerInfo(rpcResponse); - var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); - SetSessionId(sessionId); + // Draft protocol revision (SEP-2567) is sessionless; the server does not return mcp-session-id. + // Subsequent requests carry MCP-Protocol-Version=2026-07-28 to opt back into the draft path. } private static string InitializeRequestDraft => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} """; #endregion diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 7ce848907..342cf9743 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -36,6 +36,16 @@ }, }; +// The default client now prefers the draft revision (probing with server/discover and falling back +// to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios specifically exercise +// the legacy initialize handshake and SSE resumability (removed in draft) and strictly expect +// initialize as the first message, so pin them to the latest stable version. Other scenarios run on +// the draft default and exercise the server/discover probe plus the transparent legacy fallback. +if (scenario is "initialize" or "sse-retry") +{ + options.ProtocolVersion = "2025-11-25"; +} + var consoleLoggerFactory = LoggerFactory.Create(builder => { builder.AddConsole(); diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index 15b2c87f2..c81d8d262 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -5,6 +5,7 @@ enable enable Exe + $(NoWarn);MCP9006 diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs index 4dfe6dfb0..0fcb05711 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -16,8 +16,8 @@ namespace ConformanceServer.Prompts; [McpServerPromptType] public sealed class IncompleteResultPrompts { - [McpServerPrompt(Name = "test_incomplete_result_prompt")] - [Description("SEP-2322 D1: prompts/get returns IncompleteResult until user_context is supplied.")] + [McpServerPrompt(Name = "test_input_required_result_prompt")] + [Description("SEP-2322 D1: prompts/get returns InputRequiredResult until user_context is supplied.")] public static GetPromptResult IncompleteResultPrompt(RequestContext context) { if (context.Params!.InputResponses is { } responses && diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs index caf91237a..99a770527 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -4,6 +4,8 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -20,8 +22,8 @@ namespace ConformanceServer.Tools; public sealed class IncompleteResultTools { // ──── A1: Basic Elicitation ───────────────────────────────────────────── - [McpServerTool(Name = "test_tool_with_elicitation")] - [Description("SEP-2322 A1: returns IncompleteResult with elicitation/create keyed 'user_name'.")] + [McpServerTool(Name = "test_input_required_result_elicitation")] + [Description("SEP-2322 A1: returns InputRequiredResult with elicitation/create keyed 'user_name'.")] public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -51,8 +53,8 @@ public static CallToolResult ToolWithElicitation(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -81,8 +83,8 @@ public static CallToolResult ToolWithSampling(RequestContext context) { if (context.Params!.InputResponses is { } responses && @@ -102,7 +104,7 @@ public static CallToolResult ToolWithListRoots(RequestContext context) { @@ -135,7 +137,7 @@ public static CallToolResult ToolWithRequestState(RequestContext context) { @@ -177,7 +179,7 @@ public static CallToolResult ToolWithMultipleInputs(RequestContext incomplete, R2 -> incomplete (new state), R3 -> complete) ───── - [McpServerTool(Name = "test_incomplete_result_multi_round")] + [McpServerTool(Name = "test_input_required_result_multi_round")] [Description("SEP-2322 B3: three-round flow whose requestState changes between rounds.")] public static CallToolResult ToolWithMultiRound(RequestContext context) { @@ -263,6 +265,137 @@ public static CallToolResult ToolForMissingResponse(RequestContext context) + { + if (context.Params!.RequestState is { } state) + { + if (!VerifyRequestState(state)) + { + throw new McpProtocolException( + "requestState failed integrity verification (tampered or invalid signature).", + McpErrorCode.InvalidParams); + } + + return TextResult("tampered-state-ok: requestState integrity verified"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["ok"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["ok"], + }, + }), + }, + requestState: SignRequestState()); + } + + // ──── A13: Respect client capabilities ────────────────────────────────── + // Per SEP-2575 the client declares its capabilities in the per-request + // _meta['io.modelcontextprotocol/clientCapabilities'] envelope (surfaced on + // JsonRpcMessageContext.ClientCapabilities). The server MUST only emit inputRequests + // for capabilities the client advertised on this request. + [McpServerTool(Name = "test_input_required_result_capabilities")] + [Description("SEP-2322 A13: returns inputRequests only for the capabilities the client declared in per-request _meta.")] + public static CallToolResult ToolWithCapabilityCheck(RequestContext context) + { + if (context.Params!.InputResponses is { Count: > 0 }) + { + return TextResult("capability-check-ok: received input responses"); + } + + var capabilities = context.JsonRpcRequest.Context?.ClientCapabilities; + var inputRequests = new Dictionary(); + + if (capabilities?.Sampling is not null) + { + inputRequests["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "What is the capital of France?" }], + }, + ], + MaxTokens = 100, + }); + } + + if (capabilities?.Elicitation is not null) + { + inputRequests["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }); + } + + if (capabilities?.Roots is not null) + { + inputRequests["client_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()); + } + + if (inputRequests.Count == 0) + { + return TextResult("capability-check-ok: client declared no MRTR-capable features"); + } + + throw new InputRequiredException(inputRequests); + } + + private static string SignRequestState() + { + var nonce = Guid.NewGuid().ToString("N"); + return $"{nonce}.{ComputeSignature(nonce)}"; + } + + private static bool VerifyRequestState(string state) + { + var separator = state.LastIndexOf('.'); + if (separator <= 0 || separator == state.Length - 1) + { + return false; + } + + var nonce = state[..separator]; + var signature = state[(separator + 1)..]; + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(signature), + Encoding.UTF8.GetBytes(ComputeSignature(nonce))); + } + + private static string ComputeSignature(string nonce) + { + using var hmac = new HMACSHA256(s_requestStateKey); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(nonce))); + } + private static CallToolResult TextResult(string text) => new() { Content = [new TextContentBlock { Text = text }], diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 75211bb60..f434c6e01 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -425,7 +425,13 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide } builder.Services.AddMcpServer(ConfigureOptions) - .WithHttpTransport(options => options.EnableLegacySse = true); + .WithHttpTransport(options => + { + // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). + // Set Stateless = false explicitly now that draft (SEP-2567) defaults to true. + options.Stateless = false; + options.EnableLegacySse = true; + }); var app = builder.Build(); diff --git a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs new file mode 100644 index 000000000..659bfa4b0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on +/// — the client should call server/discover instead of +/// initialize when is set to +/// . +/// +public class DraftConnectionTests : ClientServerTestBase +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private const string LatestStableVersion = "2025-11-25"; + + public DraftConnectionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" }; + }); + } + + [Fact] + public async Task DraftClient_ConnectingToDraftServer_NegotiatesDraftVersion() + { + StartServer(); + + var options = new McpClientOptions { ProtocolVersion = DraftVersion }; + await using var client = await CreateMcpClientForServer(options); + + Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion); + Assert.NotNull(client.ServerCapabilities); + Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name); + } + + [Fact] + public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion() + { + StartServer(); + + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + + Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task LegacyClient_CanCallServerDiscover() + { + // server/discover is registered unconditionally, so a legacy client can probe it + // (e.g., to learn capabilities without doing a second initialize). + StartServer(); + + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverResult); + Assert.NotEmpty(discoverResult.SupportedVersions); + Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); + Assert.Equal(nameof(DraftConnectionTests), discoverResult.ServerInfo.Name); + } + + [Fact] + public async Task DraftServer_DiscoverIncludesDraftVersion() + { + StartServer(); + + await using var client = await CreateMcpClientForServer(); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverResult); + Assert.Contains(DraftVersion, discoverResult.SupportedVersions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs new file mode 100644 index 000000000..71215cdad --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Verifies that the C# client emits the SEP-2575 _meta envelope on every list-style +/// request (and on server/discover) under the draft protocol revision, even when the +/// caller supplies no RequestOptions / no params. +/// +/// +/// Spec PR #2759 promotes params._meta to required on tools/list, +/// resources/list, resources/templates/list, prompts/list, and +/// server/discover under draft. This test class drives the C# client through +/// with the draft revision negotiated, attaches a request +/// filter on each list endpoint that captures the incoming _meta envelope, and asserts +/// the three required SEP-2575 keys are present: +/// io.modelcontextprotocol/protocolVersion, +/// io.modelcontextprotocol/clientInfo, and +/// io.modelcontextprotocol/clientCapabilities. +/// +public class DraftListMetaEmissionTests : ClientServerTestBase +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + private const string LatestStableVersion = "2025-11-25"; + + // Captured _meta envelopes for each request method we exercise. Populated by the per-method + // server-side filters and asserted from each test method. + private readonly Dictionary _capturedMeta = new(StringComparer.Ordinal); + + public DraftListMetaEmissionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithRequestFilters(filters => + { + filters.AddListToolsFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ToolsList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListPromptsFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.PromptsList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListResourcesFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ResourcesList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + filters.AddListResourceTemplatesFilter(next => async (request, cancellationToken) => + { + _capturedMeta[RequestMethods.ResourcesTemplatesList] = request.Params?.Meta; + return await next(request, cancellationToken); + }); + }); + + // No-op list handlers (so the requests complete) — content is irrelevant; we only assert the + // incoming envelope. + mcpServerBuilder + .WithListToolsHandler((_, _) => new ValueTask(new ListToolsResult { Tools = [] })) + .WithListPromptsHandler((_, _) => new ValueTask(new ListPromptsResult { Prompts = [] })) + .WithListResourcesHandler((_, _) => new ValueTask(new ListResourcesResult { Resources = [] })) + .WithListResourceTemplatesHandler((_, _) => new ValueTask( + new ListResourceTemplatesResult { ResourceTemplates = [] })); + } + + [Fact] + public async Task DraftClient_ListTools_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ToolsList); + } + + [Fact] + public async Task DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.PromptsList); + } + + [Fact] + public async Task DraftClient_ListResources_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ResourcesList); + } + + [Fact] + public async Task DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); + + AssertDraftMetaPresent(RequestMethods.ResourcesTemplatesList); + } + + [Fact] + public async Task DraftClient_ServerDiscover_EmitsRequiredMeta() + { + // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, + // which still flows through the client's draft-meta injector. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + + // Hook the server-side handler invocation via a notification handler is awkward here; assert + // instead by sending the request and parsing the wire-shape echo from the response context. + // Easier path: rely on the existing JsonRpcRequest capture in the message context — see the + // raw conformance tests for the wire-level proof. For this in-process test, we instead drive + // the request and rely on the response being a valid DiscoverResult; the draft meta injector + // would otherwise have failed the server's per-request envelope validation. + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken); + + Assert.NotNull(response.Result); + var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; + Assert.Contains(DraftVersion, discover.SupportedVersions); + + // The server enforces draft envelope shape per request; if the client had omitted _meta, the + // request would have failed with -32602 / -32003 rather than returning a DiscoverResult. The + // successful round-trip is the assertion. + } + + [Fact] + public async Task LegacyClient_ListTools_DoesNotEmitDraftMeta() + { + // Sanity guard: the legacy (non-draft) client must NOT emit the SEP-2575 envelope — the meta + // injector is gated on the negotiated protocol version. If this ever started writing draft keys + // under legacy protocols, every legacy server would reject the request. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var meta = _capturedMeta[RequestMethods.ToolsList]; + if (meta is not null) + { + Assert.False(meta.ContainsKey(MetaKeys.ProtocolVersion)); + Assert.False(meta.ContainsKey(MetaKeys.ClientInfo)); + Assert.False(meta.ContainsKey(MetaKeys.ClientCapabilities)); + } + } + + private void AssertDraftMetaPresent(string method) + { + Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}"); + Assert.NotNull(meta); + Assert.True(meta!.ContainsKey(MetaKeys.ProtocolVersion), + $"Missing protocolVersion key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(MetaKeys.ClientInfo), + $"Missing clientInfo key on {method} _meta envelope"); + Assert.True(meta.ContainsKey(MetaKeys.ClientCapabilities), + $"Missing clientCapabilities key on {method} _meta envelope"); + + // The protocolVersion value must match the negotiated draft version. + Assert.Equal(DraftVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs new file mode 100644 index 000000000..ee624ede0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs @@ -0,0 +1,272 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Regression tests for the draft-protocol-to-legacy fallback path in +/// . These verify that a client configured with +/// McpClientOptions.ProtocolVersion = McpSession.DraftProtocolVersion +/// correctly probes for a draft-aware server with server/discover, falls +/// back to the legacy initialize handshake when the server is legacy, +/// and accepts whatever supported protocol version the legacy server +/// negotiates - including a version different from the one the client +/// originally requested. +/// +/// +/// The originally shipped logic in PerformLegacyInitializeAsync compared +/// the server's response against _options.ProtocolVersion, which under +/// draft is "2026-07-28". When the legacy server downgraded to (say) +/// "2025-06-18", the comparison threw, even though the legacy +/// negotiation succeeded. These tests guard against that regression. +/// +public class DraftProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + [Fact] + public async Task DraftClient_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(transport.ServerDiscoverProbed); + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task DraftClient_OnInvalidParams_FallsBackTo_Initialize() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + probeErrorCode: (int)McpErrorCode.InvalidParams); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + [Fact] + public async Task DraftClient_WithMinProtocolVersion_RefusesFallback_BelowMinimum() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + MinProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.Contains("minimum", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() + { + var ct = TestContext.Current.CancellationToken; + // Server responds with a DIFFERENT version than the one the user pinned. + await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = "2025-11-25", + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback() + { + // The peer is modern (returns the spec-defined -32001 HeaderMismatch on the probe). + // Falling back to legacy initialize would just produce another malformed envelope. + // Verify the connect-time logic surfaces the error to the caller instead of falling back. + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + probeErrorCode: (int)McpErrorCode.HeaderMismatch); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.True(transport.ServerDiscoverProbed); + Assert.False(transport.LegacyInitializeReceived); + Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); + } + + [Fact] + public async Task DraftClient_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() + { + // Simulate a legacy server that silently drops the unknown server/discover method (it never + // responds to the probe). The client must fall back to legacy initialize once the configured + // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. + var ct = TestContext.Current.CancellationToken; + await using var transport = new LegacyServerTestTransport( + serverNegotiatedVersion: "2025-11-25", + silentDiscoverProbe: true); + + var stopwatch = Stopwatch.StartNew(); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250), + InitializationTimeout = TestConstants.DefaultTimeout, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + stopwatch.Stop(); + + Assert.True(transport.ServerDiscoverProbed); + Assert.True(transport.LegacyInitializeReceived); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(30), + $"Fallback should have happened shortly after the {nameof(McpClientOptions.DiscoverProbeTimeout)}, but took {stopwatch.Elapsed}."); + } + + [Theory] + [InlineData(0)] + [InlineData(-1000)] + public void DiscoverProbeTimeout_Setter_Rejects_NonPositiveValues(int milliseconds) + { + var options = new McpClientOptions(); + Assert.Throws(() => options.DiscoverProbeTimeout = TimeSpan.FromMilliseconds(milliseconds)); + } + + [Fact] + public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues() + { + var options = new McpClientOptions(); + + // Default is the documented 5 seconds. + Assert.Equal(TimeSpan.FromSeconds(5), options.DiscoverProbeTimeout); + + options.DiscoverProbeTimeout = TimeSpan.FromSeconds(30); + Assert.Equal(TimeSpan.FromSeconds(30), options.DiscoverProbeTimeout); + + // Timeout.InfiniteTimeSpan disables the separate probe timeout (bounded by InitializationTimeout only). + options.DiscoverProbeTimeout = Timeout.InfiniteTimeSpan; + Assert.Equal(Timeout.InfiniteTimeSpan, options.DiscoverProbeTimeout); + } + + /// + /// Minimal in-memory transport that simulates a legacy server: rejects + /// server/discover (with a configurable JSON-RPC error code, or by + /// silently dropping the request) and responds to initialize with a + /// configurable protocol version. + /// + private sealed class LegacyServerTestTransport( + string serverNegotiatedVersion, + int probeErrorCode = (int)McpErrorCode.MethodNotFound, + bool silentDiscoverProbe = false) : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "legacy-server-test-transport"; + + public bool ServerDiscoverProbed { get; private set; } + + public bool LegacyInitializeReceived { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + ITransport transport = new TransportChannel(_incomingToClient, this); + return Task.FromResult(transport); + } + + public ValueTask DisposeAsync() => default; + + private void HandleOutgoingMessage(JsonRpcMessage message) + { + switch (message) + { + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq: + ServerDiscoverProbed = true; + if (silentDiscoverProbe) + { + // Model a legacy server that drops the unknown method without replying. + break; + } + + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = probeErrorCode, + Message = probeErrorCode == (int)McpErrorCode.MethodNotFound + ? "Method not found" + : "Invalid params", + }, + }); + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: + LegacyInitializeReceived = true; + _ = WriteAsync(new JsonRpcResponse + { + Id = initReq.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = serverNegotiatedVersion, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private sealed class TransportChannel( + Channel incoming, + LegacyServerTestTransport parent) : ITransport + { + public ChannelReader MessageReader => incoming.Reader; + public bool IsConnected { get; private set; } = true; + public string? SessionId => null; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + parent.HandleOutgoingMessage(message); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + incoming.Writer.TryComplete(); + IsConnected = false; + return default; + } + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index b2935d247..1a0785630 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -171,10 +171,27 @@ public virtual Task SendMessageAsync(JsonRpcMessage message, CancellationToken c { switch (message) { - case JsonRpcRequest: + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverRequest: _channel.Writer.TryWrite(new JsonRpcResponse { - Id = ((JsonRpcRequest)message).Id, + Id = discoverRequest.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + Capabilities = new ServerCapabilities(), + SupportedVersions = [McpSession.DraftProtocolVersion], + ServerInfo = new Implementation + { + Name = "NopTransport", + Version = "1.0.0" + }, + }, McpJsonUtilities.DefaultOptions), + }); + break; + + case JsonRpcRequest request: + _channel.Writer.TryWrite(new JsonRpcResponse + { + Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { Capabilities = new ServerCapabilities(), diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 7e67eb44c..18cd5e232 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -2,12 +2,17 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; public class McpClientMetaTests : ClientServerTestBase { + // InitializeMeta is carried on the legacy initialize request, which the draft revision removes. + // The two InitializeMeta_* tests pin to the latest stable version so the handshake actually runs. + private const string LatestStableVersion = "2025-11-25"; + private readonly TaskCompletionSource _initializeMeta = new(); public McpClientMetaTests(ITestOutputHelper outputHelper) @@ -50,6 +55,7 @@ public async Task InitializeMeta_IsSentToServer_WhenSet() { var clientOptions = new McpClientOptions { + ProtocolVersion = LatestStableVersion, InitializeMeta = new JsonObject { { "foo", "bar baz" } @@ -58,7 +64,7 @@ public async Task InitializeMeta_IsSentToServer_WhenSet() await using McpClient client = await CreateMcpClientForServer(clientOptions); - var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken); + var meta = await _initializeMeta.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); Assert.NotNull(meta); Assert.Equal("bar baz", meta["foo"]?.ToString()); @@ -67,9 +73,9 @@ public async Task InitializeMeta_IsSentToServer_WhenSet() [Fact] public async Task InitializeMeta_IsOmitted_WhenNotSet() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - var meta = await _initializeMeta.Task.WaitAsync(TestContext.Current.CancellationToken); + var meta = await _initializeMeta.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); Assert.Null(meta); } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 749ef51eb..e3d90bced 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -584,15 +584,16 @@ public async Task AsClientLoggerProvider_MessagesSentToClient() public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) { await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = protocolVersion }); - Assert.Equal(protocolVersion ?? "2025-11-25", client.NegotiatedProtocolVersion); + // A null ProtocolVersion now prefers the draft revision, which the reactive test server advertises. + Assert.Equal(protocolVersion ?? "2026-07-28", client.NegotiatedProtocolVersion); } [Fact] public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() { - Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1"; - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" }); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Server.ServerOptions.ProtocolVersion = "2026-07-28"; + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2026-07-28" }); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); } [Fact] @@ -794,7 +795,9 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task ServerCanPingClient() { - await using McpClient client = await CreateMcpClientForServer(); + // ping is a legacy-only RPC (removed in the draft revision per SEP-2575), so pin the client + // to a legacy protocol version to exercise the server-initiated ping round-trip. + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping }; var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 83f9e610f..3dd944ce5 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -22,7 +22,7 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() } [Theory] - [InlineData("DRAFT-2026-v1", true)] + [InlineData("2026-07-28", true)] [InlineData("2025-11-25", false)] [InlineData("2025-06-18", false)] [InlineData("2024-11-05", false)] diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 90864d393..b22bcffc3 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -95,14 +95,14 @@ public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCal // input resolution failures back to the server. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); // The client handler throws during input resolution, so the exception // escapes ResolveInputRequestAsync and surfaces directly to the caller. @@ -130,7 +130,7 @@ public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() // SendMessageAsync should throw InvalidOperationException if the message is a // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -155,7 +155,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); clientOptions.Handlers.SamplingHandler = (request, progress, ct) => @@ -165,7 +165,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() Model = "test-model" }); - // Start the client task - it will send initialize and block waiting for response + // Start the client task — it will send server/discover (draft) and block waiting for response var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -175,37 +175,34 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); - // Simulate server: read initialize request, respond with experimental version + // Simulate server: read server/discover request, respond with a DiscoverResult + // that advertises support for the experimental version. var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Read the initialize request from client - var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initLine); - var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(initRequest); - Assert.Equal("initialize", initRequest.Method); + // Read the server/discover request from client (draft revision skips initialize per SEP-2575). + var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(discoverLine); + var discoverRequest = JsonSerializer.Deserialize(discoverLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverRequest); + Assert.Equal(RequestMethods.ServerDiscover, discoverRequest.Method); - // Respond with experimental protocol version (MRTR negotiated) - var initResponse = new JsonRpcResponse + // Respond with a DiscoverResult that includes the experimental version in supportedVersions. + var discoverResponse = new JsonRpcResponse { - Id = initRequest.Id, - Result = JsonSerializer.SerializeToNode(new InitializeResult + Id = discoverRequest.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult { - ProtocolVersion = "DRAFT-2026-v1", + SupportedVersions = new List { "2026-07-28" }, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } + ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; - await WriteJsonRpcAsync(serverWriter, initResponse); + await WriteJsonRpcAsync(serverWriter, discoverResponse); - // Read the initialized notification from client - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); - - // Client is now connected with MRTR negotiated + // Client is now connected with MRTR negotiated (no initialized notification under draft). await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); // Now simulate the non-compliant server sending a legacy elicitation/create request var legacyRequest = new JsonRpcRequest @@ -253,8 +250,9 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Client does NOT set DRAFT-2026-v1 - standard protocol only - var clientOptions = new McpClientOptions(); + // Client is pinned to a legacy protocol version, so it performs the initialize + // handshake and the session is treated as non-MRTR. + var clientOptions = new McpClientOptions { ProtocolVersion = "2025-03-26" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { @@ -408,7 +406,9 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var clientOptions = new McpClientOptions(); + // Pin to the draft revision so the client performs the server/discover handshake and + // treats InputRequiredResult as an MRTR round-trip. + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { @@ -431,30 +431,27 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Initialize handshake - negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR. - var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initLine); - var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(initRequest); - Assert.Equal("initialize", initRequest.Method); + // server/discover handshake - negotiate 2026-07-28 so the client treats InputRequiredResult as MRTR. + var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(discoverLine); + var discoverRequest = JsonSerializer.Deserialize(discoverLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(discoverRequest); + Assert.Equal("server/discover", discoverRequest.Method); - var initResponse = new JsonRpcResponse + var discoverResponse = new JsonRpcResponse { - Id = initRequest.Id, - Result = JsonSerializer.SerializeToNode(new InitializeResult + Id = discoverRequest.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult { - ProtocolVersion = "DRAFT-2026-v1", + SupportedVersions = ["2026-07-28"], Capabilities = new ServerCapabilities { Tools = new() }, ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" } }, McpJsonUtilities.DefaultOptions), }; - await WriteJsonRpcAsync(serverWriter, initResponse); - - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); + await WriteJsonRpcAsync(serverWriter, discoverResponse); await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var cancellationToken = TestContext.Current.CancellationToken; diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 70553ee45..3d62f8636 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,7 +33,10 @@ public async Task ConnectAndPing_Stdio(string clientId) // Arrange // Act - await using var client = await _fixture.CreateClientAsync(clientId); + // ping was removed in the draft revision (SEP-2575), so pin to the latest stable + // protocol version to keep exercising the legacy ping RPC. Draft liveness relies on + // the transport/request lifecycle instead of an explicit ping. + await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs index f339e0f20..3b20f39c2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs @@ -11,6 +11,8 @@ namespace ModelContextProtocol.Tests.Configuration; public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper, startServer: false) { + private const string LatestStableVersion = "2025-11-25"; + private static ILogger GetLogger(IServiceProvider? services, string categoryName) { var loggerFactory = services?.GetRequiredService() ?? throw new InvalidOperationException("LoggerFactory not available"); @@ -72,22 +74,25 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() { List messageTypes = []; - // The client sends notifications/initialized fire-and-forget, so unlike the initialize and - // tools/list request/response exchanges it has no synchronization point the test can await. - // Signal once the filter finishes processing it so the strict counts below observe a - // complete, stable log instead of racing the still-in-flight notification. - var initializedNotificationProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Under the draft protocol the client performs a server/discover + tools/list exchange (no + // fire-and-forget initialized notification), so the tools/list request is a deterministic + // synchronization point. Gate recording to it and signal once the filter finishes so a + // regression that invokes the filter pipeline more than once per message surfaces as an extra entry. + var toolsListProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); McpServerBuilder .WithMessageFilters(filters => filters.AddIncomingFilter((next) => async (context, cancellationToken) => { - var messageTypeName = context.JsonRpcMessage.GetType().Name; - messageTypes.Add(messageTypeName); + if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }) + { + messageTypes.Add(context.JsonRpcMessage.GetType().Name); + } + await next(context, cancellationToken); - if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification }) + if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }) { - initializedNotificationProcessed.TrySetResult(true); + toolsListProcessed.TrySetResult(true); } })) .WithTools(); @@ -98,51 +103,57 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // Wait for the fire-and-forget initialized notification to flow through the filter pipeline - // before snapshotting the counts; otherwise the strict counts below can race the notification. - await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // The message filter should intercept JsonRpcRequest messages. - // Use strict counts so a regression that invokes the filter pipeline more than once per - // incoming message (analogous to the SendRequestAsync double-wrap regression on the outgoing - // side) would fail this test instead of slipping through Assert.Contains. - // A single ListToolsAsync drives three server-bound messages: initialize (request), - // notifications/initialized (notification), and tools/list (request). - Assert.Equal(2, messageTypes.Count(m => m == nameof(JsonRpcRequest))); - Assert.Equal(1, messageTypes.Count(m => m == nameof(JsonRpcNotification))); + await toolsListProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // The message filter should intercept the tools/list JsonRpcRequest exactly once. + Assert.Collection(messageTypes, m => Assert.Equal(nameof(JsonRpcRequest), m)); } [Fact] public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() { - // The client sends notifications/initialized fire-and-forget, so unlike the initialize and - // tools/list request/response exchanges it has no synchronization point the test can await. - // Signal once the outermost filter finishes processing it so the strict counts below observe a - // complete, stable log instead of racing the still-in-flight notification. - var initializedNotificationProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Under the draft protocol the client performs a server/discover + tools/list exchange (no + // fire-and-forget initialized notification), so the tools/list request is a deterministic + // synchronization point. Gate the filter logging to it and signal once the outermost filter + // finishes so the assertions observe a complete, stable log. + var toolsListProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); McpServerBuilder .WithMessageFilters(filters => { filters.AddIncomingFilter((next) => async (context, cancellationToken) => { + var isToolsList = context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }; var logger = GetLogger(context.Services, "MessageFilter1"); - logger.LogInformation("MessageFilter1 before"); + if (isToolsList) + { + logger.LogInformation("MessageFilter1 before"); + } + await next(context, cancellationToken); - logger.LogInformation("MessageFilter1 after"); - if (context.JsonRpcMessage is JsonRpcNotification { Method: NotificationMethods.InitializedNotification }) + if (isToolsList) { - initializedNotificationProcessed.TrySetResult(true); + logger.LogInformation("MessageFilter1 after"); + toolsListProcessed.TrySetResult(true); } }); filters.AddIncomingFilter((next) => async (context, cancellationToken) => { + var isToolsList = context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.ToolsList }; var logger = GetLogger(context.Services, "MessageFilter2"); - logger.LogInformation("MessageFilter2 before"); + if (isToolsList) + { + logger.LogInformation("MessageFilter2 before"); + } + await next(context, cancellationToken); - logger.LogInformation("MessageFilter2 after"); + + if (isToolsList) + { + logger.LogInformation("MessageFilter2 after"); + } }); }) .WithTools(); @@ -153,38 +164,23 @@ public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - // Wait for the fire-and-forget initialized notification to flow through the filter pipeline - // before snapshotting the log; otherwise the strict counts below can race the notification. - await initializedNotificationProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + // Wait for the outermost filter to finish processing the tools/list request before + // snapshotting the log; otherwise the assertions can race the still-in-flight "after" logs. + await toolsListProcessed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); var logMessages = MockLoggerProvider.LogMessages .Where(m => m.Category.StartsWith("MessageFilter")) .Select(m => m.Message) .ToList(); - // First filter registered is outermost - // We should see this pattern for each message: MessageFilter1 before -> MessageFilter2 before -> MessageFilter2 after -> MessageFilter1 after - int idx1Before = logMessages.IndexOf("MessageFilter1 before"); - int idx2Before = logMessages.IndexOf("MessageFilter2 before"); - int idx2After = logMessages.IndexOf("MessageFilter2 after"); - int idx1After = logMessages.IndexOf("MessageFilter1 after"); - - Assert.True(idx1Before >= 0); - Assert.True(idx2Before >= 0); - Assert.True(idx2After >= 0); - Assert.True(idx1After >= 0); - - // Verify ordering within a single request - Assert.True(idx1Before < idx2Before); - Assert.True(idx2Before < idx2After); - Assert.True(idx2After < idx1After); - - // Verify each filter ran exactly once per incoming message (initialize + notifications/initialized + tools/list). - // Strict counts catch regressions where the incoming filter pipeline gets invoked more than once per message. - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 before")); - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 before")); - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 after")); - Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 after")); + // First filter registered is outermost. For the single gated tools/list request we expect the + // strict nested order. Assert.Collection also catches any regression that invokes the incoming + // filter pipeline more than once per message (which would add extra entries). + Assert.Collection(logMessages, + m => Assert.Equal("MessageFilter1 before", m), + m => Assert.Equal("MessageFilter2 before", m), + m => Assert.Equal("MessageFilter2 after", m), + m => Assert.Equal("MessageFilter1 after", m)); } [Fact] @@ -396,6 +392,11 @@ public async Task AddOutgoingMessageFilter_Sees_Responses_Notifications_And_Requ var clientOptions = new McpClientOptions { + // This test observes the legacy outgoing flow on the server side: the initialize response and + // the server->client sampling/createMessage request. Under the draft protocol those are replaced + // by server/discover and implicit MRTR (InputRequiredResult), which is covered by MrtrIntegrationTests. + // Pin to the latest stable version to keep exercising the legacy server->client request path here. + ProtocolVersion = LatestStableVersion, Capabilities = new() { Sampling = new() }, Handlers = new() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 209469e7b..795b380a4 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -128,7 +128,14 @@ public async Task Can_List_And_Call_Registered_Prompts() [Fact] public async Task Can_Be_Notified_Of_Prompt_Changes() { - await using McpClient client = await CreateMcpClientForServer(); + // Under the draft revision, list-changed notifications are delivered only over a + // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the + // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(8, prompts.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d6bb239d7..5630d2b77 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -162,7 +162,14 @@ public async Task Can_List_And_Call_Registered_ResourceTemplates() [Fact] public async Task Can_Be_Notified_Of_Resource_Changes() { - await using McpClient client = await CreateMcpClientForServer(); + // Under the draft revision, list-changed notifications are delivered only over a + // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the + // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(7, resources.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index d2db4c62c..79eace963 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -188,7 +188,13 @@ public async Task Can_Create_Multiple_Servers_From_Options_And_List_Registered_T [Fact] public async Task Can_Be_Notified_Of_Tool_Changes() { - await using McpClient client = await CreateMcpClientForServer(); + // Under the draft revision, list-changed notifications are delivered only over a + // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the + // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(19, tools.Count); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 1956887ac..9a78045b9 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -58,7 +58,11 @@ private async Task AssertMatchAsync( } /// - /// Asserts that the given URI does NOT match the template. + /// Asserts that the given URI does NOT match the template. Uses the default client, which + /// negotiates the draft protocol revision, so the unknown-resource response carries the + /// standard JSON-RPC (-32602). The version-gated + /// legacy mapping to (-32002) is covered by + /// . /// private async Task AssertNoMatchAsync( string uriTemplate, @@ -71,7 +75,7 @@ private async Task AssertNoMatchAsync( var ex = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(uri, null, TestContext.Current.CancellationToken)); - Assert.Equal(McpErrorCode.ResourceNotFound, ex.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, ex.ErrorCode); } // Unknown-resource-URI responses are version-gated: older clients keep the legacy @@ -79,7 +83,7 @@ private async Task AssertNoMatchAsync( // moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams). [Theory] [InlineData("2025-11-25", McpErrorCode.ResourceNotFound)] - [InlineData("DRAFT-2026-v1", McpErrorCode.InvalidParams)] + [InlineData("2026-07-28", McpErrorCode.InvalidParams)] public async Task ResourceNotFound_ErrorCode_IsVersionGated(string serverProtocolVersion, McpErrorCode expectedCode) { var resource = McpServerResource.Create( @@ -130,7 +134,9 @@ public async Task MultipleTemplatedResources_MatchesCorrectResource() // Literal template braces in URI should not match (template literal is not a valid URI) var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken)); - Assert.Equal(McpErrorCode.ResourceNotFound, mcpEx.ErrorCode); + // Draft maps an unmatched resource URI to InvalidParams (-32602); the legacy -32002 ResourceNotFound + // mapping is covered by the version-gated ResourceNotFound_ErrorCode_IsVersionGated theory. + Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode); Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message); } diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index 03e00af8d..acaa1b9ae 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -91,10 +91,13 @@ await WaitForAsync( Assert.Equal(clientListToolsCall.SpanId, serverListToolsCall.ParentSpanId); Assert.Equal(clientListToolsCall.TraceId, serverListToolsCall.TraceId); - // Validate that the client trace context encoded to request.params._meta[traceparent] + // Validate that the client trace context encoded to request.params._meta[traceparent]. + // Under the draft revision _meta also carries the per-request envelope (protocolVersion, + // clientInfo, clientCapabilities), so assert on the traceparent property specifically + // rather than the entire _meta object. using var listToolsJson = JsonDocument.Parse(clientToServerLog.First(s => s.Contains("\"method\":\"tools/list\""))); - var metaJson = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetRawText(); - Assert.Equal($$"""{"traceparent":"00-{{clientListToolsCall.TraceId}}-{{clientListToolsCall.SpanId}}-01"}""", metaJson); + var traceparent = listToolsJson.RootElement.GetProperty("params").GetProperty("_meta").GetProperty("traceparent").GetString(); + Assert.Equal($"00-{clientListToolsCall.TraceId}-{clientListToolsCall.SpanId}-01", traceparent); // Validate that mcp.session.id is set on both client and server activities and that // all client activities share one session ID while all server activities share another. diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 6025bc316..4b782cd64 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ true ModelContextProtocol.Tests - $(NoWarn);NU1903;NU1902 + $(NoWarn);NU1903;NU1902;MCP9006 $(DefineConstants);MCP_TEST_TIME_PROVIDER diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs index ac40bd767..614af34c7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs @@ -84,6 +84,9 @@ public async Task InitializeTimeout_DoesNotSendCancellationNotification() var clientOptions = new McpClientOptions { + // Pin to a legacy protocol version so the client performs the initialize handshake + // (the spec rule under test is "the initialize request MUST NOT be cancelled by clients"). + ProtocolVersion = "2025-11-25", InitializationTimeout = TimeSpan.FromMilliseconds(500), }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs new file mode 100644 index 000000000..1e32062da --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -0,0 +1,80 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the request/result types introduced by the draft protocol revision (SEP-2575). +/// +public static class DiscoverProtocolTests +{ + [Fact] + public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta() + { + var original = new DiscoverRequestParams + { + Meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = "2026-07-28", + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0", + }, + [MetaKeys.ClientCapabilities] = new JsonObject(), + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Meta); + Assert.Equal("2026-07-28", (string)deserialized.Meta[MetaKeys.ProtocolVersion]!); + } + + [Fact] + public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties() + { + var original = new DiscoverResult + { + SupportedVersions = new List { "2025-11-25", "2026-07-28" }, + Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability { ListChanged = true }, + }, + ServerInfo = new Implementation { Name = "test-server", Version = "2.0" }, + Instructions = "Use this server for testing.", + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + 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); + } + + [Fact] + public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties() + { + var original = new DiscoverResult + { + 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); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Single(deserialized.SupportedVersions); + Assert.Equal("2026-07-28", deserialized.SupportedVersions[0]); + Assert.Null(deserialized.Instructions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs new file mode 100644 index 000000000..001c05f95 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -0,0 +1,125 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +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 +/// helper (it instantiates via reflection). This file covers the +/// same property-shape assertions for . +/// +public static class DiscoverResultCacheableTests +{ + private static DiscoverResult NewDiscoverResult() => new() + { + SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, + }; + + [Fact] + public static void DiscoverResult_SerializesTtlMsAsIntegerMilliseconds() + { + var result = NewDiscoverResult(); + result.TimeToLive = TimeSpan.FromMilliseconds(300_000); + result.CacheScope = CacheScope.Public; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind()); + Assert.Equal(300_000, node["ttlMs"]!.GetValue()); + Assert.Equal("public", node["cacheScope"]!.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_PrivateScope_RoundTrips() + { + var result = NewDiscoverResult(); + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + Assert.True(node.ContainsKey("ttlMs")); + Assert.Equal(0, node["ttlMs"]!.GetValue()); + Assert.Equal("private", node["cacheScope"]!.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + Assert.Equal(CacheScope.Private, deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_OmitsCachingHints_WhenUnset() + { + var result = NewDiscoverResult(); + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json)!.AsObject(); + + // Backward compatibility: servers that do not set the hints must not emit them. + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_DeserializesMissingHints_AsNull() + { + // A response from a pre-PR-#2855 server may omit both fields. Deserialization must succeed + // and surface them as null so callers can apply their own defaults. + string json = + """ + { + "supportedVersions": ["2025-11-25"], + "capabilities": {}, + "serverInfo": {"name": "x", "version": "1"} + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.TimeToLive); + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_DeserializesUnknownCacheScope_AsNull() + { + // A future or unknown cacheScope string must not break deserialization of the entire result. + string json = + """ + { + "supportedVersions": ["2025-11-25"], + "capabilities": {}, + "serverInfo": {"name": "x", "version": "1"}, + "cacheScope": "shared" + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.Null(deserialized.CacheScope); + } + + [Fact] + public static void DiscoverResult_ImplementsICacheableResult() + { + // Compile-time assertion that DiscoverResult participates in the shared cacheability surface + // alongside the list/read result types. + Assert.IsAssignableFrom(NewDiscoverResult()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs new file mode 100644 index 000000000..fd82f9e0b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the error data payloads introduced by the draft protocol revision (SEP-2575). +/// +public static class DraftErrorDataTests +{ + [Fact] + public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_PreservesAllProperties() + { + var original = new UnsupportedProtocolVersionErrorData + { + Supported = new List { "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25" }, + Requested = "2026-07-28", + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(4, deserialized.Supported.Count); + Assert.Contains("2025-11-25", deserialized.Supported); + Assert.Equal("2026-07-28", deserialized.Requested); + } + + [Fact] + public static void MissingRequiredClientCapabilityErrorData_SerializationRoundTrip_PreservesAllProperties() + { + var original = new MissingRequiredClientCapabilityErrorData + { + RequiredCapabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.RequiredCapabilities.Sampling); + } + + [Fact] + public static void UnsupportedProtocolVersionException_ExposesRequestedAndSupported() + { + var ex = new UnsupportedProtocolVersionException("2099-12-31", ["2025-11-25", "2025-06-18"]); + + Assert.Equal(McpErrorCode.UnsupportedProtocolVersion, ex.ErrorCode); + Assert.Equal("2099-12-31", ex.Requested); + Assert.Equal(2, ex.Supported.Count); + Assert.Contains("2025-11-25", ex.Supported); + } + + [Fact] + public static void MissingRequiredClientCapabilityException_ExposesRequiredCapabilities() + { + var caps = new ClientCapabilities { Roots = new RootsCapability() }; + var ex = new MissingRequiredClientCapabilityException(caps); + + Assert.Equal(McpErrorCode.MissingRequiredClientCapability, ex.ErrorCode); + Assert.Same(caps, ex.RequiredCapabilities); + } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs index 7b55b738a..60b8093a7 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTypedTests.cs @@ -287,6 +287,8 @@ public async Task Elicit_Typed_With_Nullable_Property_Type_Throws() var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsync("TestElicitationNullablePropertyForm", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("Nullable", ex.Message); } [Fact] @@ -340,7 +342,7 @@ public sealed class CamelForm public sealed class NullablePropertyForm { public string? FirstName { get; set; } - public int ZipCode { get; set; } + public int? ZipCode { get; set; } public bool IsAdmin { get; set; } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs index ddab6b142..ab172aa1e 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs @@ -760,4 +760,71 @@ public static void Deserialize_ErrorWithArrayData_IsValid() var error = (JsonRpcError)message; Assert.NotNull(error.Error.Data); } + + [Fact] + public static void Deserialize_ErrorWithNullId_IsValid() + { + // Per JSON-RPC 2.0 §5.1, when an error occurs before the request id can be determined + // (parse error or invalid request), the server MUST respond with id=null. This shape is + // produced by some peers (e.g. Python's simple-streamablehttp-stateless on a draft probe) + // and must be accepted so the HTTP-fallback path can recognize the structured signal. + string json = """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request"}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + var error = Assert.IsType(message); + Assert.Equal(default(RequestId), error.Id); + Assert.Equal(-32600, error.Error.Code); + Assert.Equal("Bad Request", error.Error.Message); + } + + [Fact] + public static void Deserialize_ErrorWithMissingId_IsValid() + { + // Some peers omit `id` entirely on pre-routing errors; treat as null per JSON-RPC 2.0 §5.1. + string json = """{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + var error = Assert.IsType(message); + Assert.Equal(default(RequestId), error.Id); + Assert.Equal(-32700, error.Error.Code); + } + + [Fact] + public static void Deserialize_RequestWithExplicitNullId_Throws() + { + // A message carrying a `method` and an explicit `id:null` is a malformed request. Per the MCP + // base protocol the request id "MUST NOT be null", and a null id does NOT denote a notification + // (JSON-RPC 2.0 notifications omit the id member entirely). The converter must reject it rather + // than silently downgrading to a notification (which would swallow the id and skip the response). + string json = """{"jsonrpc":"2.0","id":null,"method":"tools/list"}"""; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void Deserialize_RequestWithExplicitNullIdAndParams_Throws() + { + string json = """{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"echo"}}"""; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public static void Deserialize_NotificationWithoutIdMember_IsNotConfusedWithNullIdRequest() + { + // Contrast with the explicit-null-id case above: omitting the id member entirely is a valid + // notification and must continue to deserialize as one. + string json = """{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"""; + + var message = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + var notification = Assert.IsType(message); + Assert.Equal("notifications/cancelled", notification.Method); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs index e426c7469..ba9120cb3 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/RequestIdTests.cs @@ -35,4 +35,32 @@ public void Int64Ctor_Roundtrips() Assert.Equal(id, JsonSerializer.Deserialize(JsonSerializer.Serialize(id, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions)); } + + [Fact] + public void Null_DeserializesAsDefault() + { + // Per JSON-RPC 2.0 §5.1, error responses produced before the request id can be determined + // MUST carry id=null. Deserialization needs to tolerate that shape so callers can handle + // such error envelopes (instead of throwing on the bare RequestId conversion). + var id = JsonSerializer.Deserialize("null", McpJsonUtilities.DefaultOptions); + Assert.Equal(default(RequestId), id); + Assert.Null(id.Id); + } + + [Fact] + public void Null_SerializesAsJsonNull() + { + // The default RequestId (Id == null) is the id-less-error-response shape. It MUST serialize as + // JSON null — not "" — so the wire form is spec-conformant and round-trips losslessly. + Assert.Equal("null", JsonSerializer.Serialize(default(RequestId), McpJsonUtilities.DefaultOptions)); + } + + [Fact] + public void Null_Roundtrips() + { + var json = JsonSerializer.Serialize(default(RequestId), McpJsonUtilities.DefaultOptions); + var id = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.Equal(default(RequestId), id); + Assert.Null(id.Id); + } } diff --git a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs new file mode 100644 index 000000000..c2d48b888 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs @@ -0,0 +1,63 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization tests for the subscriptions/listen types introduced by the draft protocol revision (SEP-2575). +/// +public static class SubscriptionsListenProtocolTests +{ + [Fact] + public static void SubscriptionsListenRequestParams_SerializationRoundTrip_PreservesAllProperties() + { + var original = new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + PromptsListChanged = true, + ResourcesListChanged = true, + ResourceSubscriptions = new List { "file:///foo.txt", "file:///bar.txt" }, + }, + Meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = "2026-07-28", + [MetaKeys.LogLevel] = "info", + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Notifications.ToolsListChanged); + Assert.True(deserialized.Notifications.PromptsListChanged); + Assert.True(deserialized.Notifications.ResourcesListChanged); + Assert.NotNull(deserialized.Notifications.ResourceSubscriptions); + Assert.Equal(["file:///foo.txt", "file:///bar.txt"], deserialized.Notifications.ResourceSubscriptions); + Assert.Equal("2026-07-28", (string)deserialized.Meta![MetaKeys.ProtocolVersion]!); + } + + [Fact] + public static void SubscriptionsAcknowledgedNotificationParams_SerializationRoundTrip_PreservesNotifications() + { + var original = new SubscriptionsAcknowledgedNotificationParams + { + Notifications = new SubscriptionsListenNotifications + { + ToolsListChanged = true, + }, + }; + + var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Notifications.ToolsListChanged); + Assert.Null(deserialized.Notifications.PromptsListChanged); + Assert.Null(deserialized.Notifications.ResourcesListChanged); + Assert.Null(deserialized.Notifications.ResourceSubscriptions); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs index 662ffdb27..ca6134a24 100644 --- a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs @@ -9,15 +9,15 @@ namespace ModelContextProtocol.Tests.Server; /// Verifies that the server-to-client request methods (, /// , /// ) keep working when the negotiated protocol revision is -/// DRAFT-2026-v1 on a stateful session - for example, stdio. +/// 2026-07-28 on a stateful session - for example, stdio. /// /// -/// Under DRAFT-2026-v1 the spec removes the corresponding server-to-client request methods, but +/// Under 2026-07-28 the spec removes the corresponding server-to-client request methods, but /// the SDK only fails fast in stateless mode (where the existing ThrowIf*Unsupported guards already /// throw "X is not supported in stateless mode" because is /// ). Stdio is implicitly stateful - one per process - so the /// legacy elicitation/create / sampling/createMessage / roots/list flow still works. -/// A future PR is expected to force DRAFT-2026-v1 Streamable HTTP servers to stateless mode, at which +/// A future PR is expected to force 2026-07-28 Streamable HTTP servers to stateless mode, at which /// point those configurations will start throwing through the existing stateless guard. /// public sealed class DraftProtocolBackcompatTests : ClientServerTestBase @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; }); mcpServerBuilder.WithTools([ @@ -47,7 +47,7 @@ public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability(), @@ -69,7 +69,7 @@ public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Sampling = new SamplingCapability(), @@ -96,7 +96,7 @@ public async Task RequestRootsAsync_OnStatefulDraftSession_ResolvesViaLegacyRequ StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "DRAFT-2026-v1", + ProtocolVersion = "2026-07-28", Capabilities = new ClientCapabilities { Roots = new RootsCapability(), diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs index 9a408ce78..f2cbfef6e 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -191,7 +191,7 @@ public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCance StartServer(); var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // Cancel the token during the callback. The retry loop will throw @@ -219,7 +219,7 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() StartServer(); var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = async (request, ct) => { // Signal that the MRTR round trip reached the client, then block indefinitely. @@ -250,6 +250,29 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() // The client call should fail (server disposed mid-MRTR). await Assert.ThrowsAnyAsync(async () => await callTask); + + // Disposing the server while a continuation is suspended should log the cancellation of the + // pending MRTR continuation once at Debug level (this is the only path that reaches the + // continuation-cancellation log now that HTTP draft is always sessionless). Poll for the + // async cancellation to propagate through the handler task. + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) + && DateTime.UtcNow < deadline) + { + await Task.Delay(50, TestContext.Current.CancellationToken); + } + + var mrtrCancelledLog = MockLoggerProvider.LogMessages + .Where(m => m.Message.Contains("pending MRTR continuation")) + .ToList(); + var log = Assert.Single(mrtrCancelledLog); + Assert.Equal(LogLevel.Debug, log.LogLevel); + Assert.Contains("1", log.Message); + + // The handler's OperationCanceledException must be silently observed during disposal, not + // logged as an error. + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel >= LogLevel.Error && m.Message.Contains("cancellation-test-tool")); } [Fact] @@ -263,7 +286,7 @@ public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandle // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -301,7 +324,7 @@ public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHand StartServer(); int elicitationCount = 0; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { Interlocked.Increment(ref elicitationCount); @@ -345,7 +368,7 @@ public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() StartServer(); bool handlerCompleted = false; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -387,7 +410,7 @@ public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() // (after resuming from ElicitAsync), the error is logged at Error level. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -417,7 +440,7 @@ public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() // not an error. It should not be logged via ToolCallError at Error level. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index 664429b13..a263e289b 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -23,7 +23,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs index fd9098734..9c83a3306 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -26,7 +26,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -73,14 +73,14 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() // When both sides are on the experimental protocol, the server should use MRTR // (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { return new ValueTask(new ElicitResult { Action = "accept" }); }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("elicit-tool", new Dictionary { ["message"] = "test" }, @@ -95,7 +95,7 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() { StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.SamplingHandler = (request, progress, ct) => { var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; @@ -107,7 +107,7 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("sample-tool", new Dictionary { ["prompt"] = "test" }, @@ -126,7 +126,7 @@ public async Task OutgoingFilter_SeesIncompleteResultResponse() var sawIncompleteResult = false; StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // If we reach this handler, it means the client received an InputRequiredResult diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index d8fa6f32b..14b5dd3a9 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the legacy MRTR backcompat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync. -/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful - +/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful - /// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler /// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks /// a non-draft version during initialize negotiation. diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs index 1836d4d13..fc9c26fc2 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs @@ -49,7 +49,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ProtocolVersion = "DRAFT-2026-v1"; + options.ProtocolVersion = "2026-07-28"; _messageTracker.AddFilters(options.Filters.Message); // Outgoing filter: detect InputRequiredResult responses and track per session. @@ -131,7 +131,7 @@ public async Task OutgoingFilter_TracksIncompleteResultsPerSession() // Verify that an outgoing message filter can observe InputRequiredResult responses // and track the pending MRTR flow count per session using context.Server.SessionId. StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -165,7 +165,7 @@ public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() _maxFlowsPerSession = 0; StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs new file mode 100644 index 000000000..ff48b71c8 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.ComponentModel; +using System.IO.Pipelines; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies the server establishes its negotiated protocol version exactly once per stateful session: the +/// initial -to-value transition is allowed and re-sending the same version is an +/// idempotent no-op, but a request that switches to a different (even if otherwise supported) version is +/// rejected with . The session is driven over a raw stream +/// transport (the stdio-shaped, stateful path) so the per-request _meta protocol version is fully +/// controlled - the SDK's own client normalizes it on every outgoing request, so only a misbehaving peer +/// can trigger a mid-session change. +/// +public sealed class NegotiatedProtocolVersionTests : LoggedTest, IAsyncDisposable +{ + private readonly Pipe _clientToServer = new(); + private readonly Pipe _serverToClient = new(); + private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly ServiceProvider _services; + private readonly Task _serverTask; + private readonly StreamWriter _writer; + private readonly StreamReader _reader; + + public NegotiatedProtocolVersionTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddSingleton(XunitLoggerProvider); + serviceCollection + .AddMcpServer() + .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) + .WithTools(); + + _services = serviceCollection.BuildServiceProvider(validateScopes: true); + var server = _services.GetRequiredService(); + _serverTask = server.RunAsync(_cts.Token); + + _writer = new StreamWriter(_clientToServer.Writer.AsStream()) { AutoFlush = true }; + _reader = new StreamReader(_serverToClient.Reader.AsStream()); + } + + [Fact] + public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterChange() + { + var ct = TestContext.Current.CancellationToken; + + // The first request establishes the draft version for the stateful session (null -> draft). + Assert.IsType(await RoundTripAsync(id: 1, McpSession.DraftProtocolVersion, ct)); + + // Re-sending the same version is an idempotent no-op, not an error. + Assert.IsType(await RoundTripAsync(id: 2, McpSession.DraftProtocolVersion, ct)); + + // Switching to a different (still-supported) version mid-session is rejected. + var error = Assert.IsType(await RoundTripAsync(id: 3, McpSession.LatestProtocolVersion, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); + + // The rejected request must not have mutated the negotiated version: the original draft version still works. + Assert.IsType(await RoundTripAsync(id: 4, McpSession.DraftProtocolVersion, ct)); + } + + private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) + { + // tools/list is available under both the legacy and draft revisions (unlike ping/initialize, + // which the draft revision removed), so it exercises the version guard rather than the + // per-method availability gate. + var request = new JsonRpcRequest + { + Id = new RequestId(id), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = protocolVersion, + }, + }, + }; + + string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); +#if NET + await _writer.WriteLineAsync(json.AsMemory(), cancellationToken); +#else + cancellationToken.ThrowIfCancellationRequested(); + await _writer.WriteLineAsync(json); +#endif + + while (true) + { +#if NET + string? line = await _reader.ReadLineAsync(cancellationToken) + .AsTask() + .WaitAsync(TestConstants.DefaultTimeout, cancellationToken); +#else + string? line = await _reader.ReadLineAsync() + .WaitAsync(TestConstants.DefaultTimeout, cancellationToken); +#endif + + if (line is null) + { + throw new InvalidOperationException("Server stream closed before responding."); + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var message = (JsonRpcMessage)JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)))!; + + // Ignore anything that isn't the response to the request we just sent (e.g. notifications). + if (message is JsonRpcMessageWithId withId && withId.Id.Equals(request.Id)) + { + return message; + } + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _clientToServer.Writer.Complete(); + _serverToClient.Writer.Complete(); + + try + { + await _serverTask; + } + catch (OperationCanceledException) + { + } + + await _services.DisposeAsync(); + _cts.Dispose(); + Dispose(); + } + + [McpServerToolType] + private sealed class EchoTools + { + [McpServerTool, Description("Echoes the input back to the caller.")] + public static string Echo([Description("The message to echo.")] string message) => message; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs new file mode 100644 index 000000000..45c978be2 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the built-in ping handler is gated by protocol version. +/// SEP-2575 (the draft 2026-07-28 revision) removes ping; servers must +/// respond with -32601 MethodNotFound. Legacy protocol versions still +/// support ping per the spec. +/// +public sealed class PingProtocolGatingTests : ClientServerTestBase +{ + public PingProtocolGatingTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + } + + [Fact] + public async Task Ping_OnDraftSession_ReturnsMethodNotFound() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.DraftProtocolVersion, + }); + + var ex = await Assert.ThrowsAsync(async () => + await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task Ping_OnLegacySession_StillSucceeds() + { + // Default server config; client pinned to 2025-11-25. + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "2025-11-25", + }); + + var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs new file mode 100644 index 000000000..003077673 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -0,0 +1,188 @@ +#if !NET472 +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Wire-format conformance tests for driven directly against the underlying +/// stream — without going through . This exercises the +/// SEP-2575 (sessionless / no-initialize) and SEP-2567 (server/discover) flows by hand-crafting JSON-RPC +/// messages and asserting on the exact responses the server emits. +/// +/// +/// The tests use a paired the way does, but instead +/// of constructing an McpClient we read and write JSON-RPC envelopes directly. This is the closest +/// approximation we have to a third-party / non-SDK client and is what conformance tooling will exercise. +/// +public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable +{ + private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; + + private readonly Pipe _clientToServer = new(); + private readonly Pipe _serverToClient = new(); + private readonly CancellationTokenSource _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly Task _serverTask; + private readonly ServiceProvider _services; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + + public RawStreamConformanceTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(XunitLoggerProvider)); + services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "raw-conformance-server", Version = "1.0.0" }; + }) + .WithStreamServerTransport(_clientToServer.Reader.AsStream(), _serverToClient.Writer.AsStream()) + .WithTools([ + McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" }), + ]); + + _services = services.BuildServiceProvider(validateScopes: true); + var server = _services.GetRequiredService(); + _serverTask = server.RunAsync(_cts.Token); + + _writer = new StreamWriter(_clientToServer.Writer.AsStream(), new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" }; + _reader = new StreamReader(_serverToClient.Reader.AsStream(), Encoding.UTF8); + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _clientToServer.Writer.Complete(); + _serverToClient.Writer.Complete(); + try { await _serverTask; } catch { /* expected on cancellation */ } + await _services.DisposeAsync(); + _cts.Dispose(); + Dispose(); + } + + private async Task SendAsync(string json) => await _writer.WriteLineAsync(json); + + private async Task ReadAsync() + { + var line = await _reader.ReadLineAsync(_cts.Token).ConfigureAwait(false); + Assert.NotNull(line); + return JsonNode.Parse(line!)!; + } + + private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; + + [Fact] + public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() + { + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + + var response = await ReadAsync(); + Assert.Equal("2.0", response["jsonrpc"]!.GetValue()); + Assert.Equal(1, response["id"]!.GetValue()); + + var result = response["result"]; + Assert.NotNull(result); + + var supportedVersions = result!["supportedVersions"]!.AsArray() + .Select(n => n!.GetValue()) + .ToList(); + Assert.Contains(DraftVersion, supportedVersions); + + // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. + Assert.NotNull(result["capabilities"]); + Assert.NotNull(result["serverInfo"]); + Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue()); + + // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the + // safest defaults (immediately stale, not shareable) when the application hasn't customized. + Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind()); + Assert.Equal(0, result["ttlMs"]!.GetValue()); + Assert.Equal("private", result["cacheScope"]!.GetValue()); + } + + [Fact] + public async Task DraftToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided() + { + // Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long + // as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," + + DraftMetaFragment() + "}}"); + + var response = await ReadAsync(); + Assert.Equal(42, response["id"]!.GetValue()); + var result = response["result"]; + Assert.NotNull(result); + var content = result!["content"]!.AsArray(); + Assert.Single(content); + Assert.Equal("echo:hello", content[0]!["text"]!.GetValue()); + } + + [Fact] + public async Task DraftRequest_WithUnsupportedProtocolVersion_ReturnsMinus32004WithSupported() + { + // Server should respond with UnsupportedProtocolVersionError (-32004) and a data.supported[] list. + await SendAsync( + @"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + DraftMetaFragment("9999-99-99") + "}}"); + + var response = await ReadAsync(); + Assert.Equal(7, response["id"]!.GetValue()); + var error = response["error"]; + Assert.NotNull(error); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error!["code"]!.GetValue()); + + var data = error["data"]; + Assert.NotNull(data); + Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); + var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Contains(DraftVersion, supported); + } + + [Fact] + public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer() + { + // Dual-era: a draft-default server (ProtocolVersion = DraftVersion in McpServerOptions) must still + // accept the legacy initialize handshake from clients that don't speak the new protocol. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + + var response = await ReadAsync(); + Assert.Equal(1, response["id"]!.GetValue()); + var result = response["result"]; + Assert.NotNull(result); + Assert.Equal("2025-11-25", result!["protocolVersion"]!.GetValue()); + } + + [Fact] + public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() + { + // Dual-era servers must accept draft and legacy traffic on the same connection. The exact mix below + // is what a permissive client running against an unknown server would emit while probing. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + var discover = await ReadAsync(); + Assert.NotNull(discover["result"]); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + var init = await ReadAsync(); + Assert.NotNull(init["result"]); + Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); + + await SendAsync(@"{""jsonrpc"":""2.0"",""method"":""notifications/initialized"",""params"":{}}"); + + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":3,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""after-init""}}}"); + var call = await ReadAsync(); + Assert.Equal("echo:after-init", call["result"]!["content"]![0]!["text"]!.GetValue()); + } +} +#endif diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs new file mode 100644 index 000000000..a57c07d55 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -0,0 +1,161 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an +/// in-memory stream transport (the stdio-shaped path exercised by ). +/// Validates that a draft client receives only the change notifications it subscribed to, each tagged +/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. +/// +public class SubscriptionsListenTests : ClientServerTestBase +{ + public SubscriptionsListenTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools(); + mcpServerBuilder.WithPrompts(); + } + + [Fact] + public async Task Draft_ToolsListChangedSubscription_DeliversTaggedNotification_AndWithholdsUnsubscribed() + { + await using McpClient client = await CreateMcpClientForServer(); + + var ackChannel = Channel.CreateUnbounded(); + var toolsChannel = Channel.CreateUnbounded(); + var promptsChannel = 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; }); + await using var promptsReg = client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, + (notification, _) => { promptsChannel.Writer.TryWrite(notification); return default; }); + + using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var listenTask = SendSubscriptionsListenAsync(client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token); + + // SEP-2575: the acknowledgement is always sent first, tagged with the subscription id. + var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + var subscriptionId = GetSubscriptionId(ack); + Assert.NotNull(subscriptionId); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverTools = serverOptions.ToolCollection!; + var serverPrompts = serverOptions.PromptCollection!; + + // A prompt change must never reach this client: it only subscribed to tool list changes. Because the + // fan-out skips it without sending anything, the prompts channel stays empty for the rest of the test. + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "AddedPrompt")] () => "added")); + + // A tool change must arrive on the subscription stream, tagged with the same subscription id as the ack. + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + var toolsNotification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Equal(subscriptionId, GetSubscriptionId(toolsNotification)); + + // Tear down the open subscription request before the client is disposed. + await CancelSubscriptionAsync(listenCts, listenTask); + + // The prompt change fired before the (delivered) tool change, and notifications arrive in order + // on the subscription stream, so any prompt notification would already be buffered by now. + // Complete the writer and assert the channel drains empty - i.e. nothing was ever delivered, + // not merely "nothing is buffered at this instant". WaitToReadAsync returns false only when the + // channel is both empty and completed; a buffered erroneous notification would make it true. + promptsChannel.Writer.Complete(); + Assert.False(await promptsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Draft_WithoutSubscription_DoesNotBroadcastListChanged() + { + await using McpClient client = await CreateMcpClientForServer(); + + var toolsChannel = Channel.CreateUnbounded(); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + + // The change notification must not be broadcast to a draft client that never opened a + // subscriptions/listen stream. The list-changed handler runs synchronously during Add (before + // the ListTools round-trip below completes), so any erroneous broadcast would already be + // buffered once the round-trip returns. + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Complete the writer and assert the channel drains empty rather than just checking the current + // buffer: 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 Legacy_ListChanged_IsBroadcast_WithoutSubscription() + { + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpSession.LatestProtocolVersion, + }); + + var toolsChannel = Channel.CreateUnbounded(); + await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, + (notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); + + // Legacy sessions keep the session-wide broadcast and the notification carries no subscription id. + var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); + Assert.Null(GetSubscriptionId(notification)); + } + + private static Task SendSubscriptionsListenAsync(McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams { Notifications = notifications }, + McpJsonUtilities.DefaultOptions), + }; + + 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(); + + [McpServerToolType] + private sealed class ListenTools + { + [McpServerTool, Description("Echoes the input back to the caller.")] + public static string Echo([Description("The message to echo.")] string message) => message; + } + + [McpServerPromptType] + private sealed class ListenPrompts + { + [McpServerPrompt, Description("A simple prompt.")] + public static ChatMessage Simple() => new(ChatRole.User, "hello"); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs new file mode 100644 index 000000000..97dba0fce --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs @@ -0,0 +1,189 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; + +#pragma warning disable MCPEXP001 + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the SEP-2663 Tasks extension is gated to the draft protocol revision on both the +/// client and the server. Explicit task operations throw on a legacy session; best-effort task +/// augmentation silently downgrades to a direct result so that legacy peers never see a task. +/// +public class TaskDraftGatingTests : ClientServerTestBase +{ + private const string LatestStableVersion = "2025-11-25"; + + private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + private const string ExtensionsKey = "extensions"; + + public TaskDraftGatingTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { +#if !NET + Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); +#endif + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.TaskStore = new InMemoryMcpTaskStore + { + DefaultPollIntervalMs = 50, + }; + }); + + mcpServerBuilder.WithTools([McpServerTool.Create( + async (string input, CancellationToken ct) => + { + await Task.Delay(50, ct); + return $"Processed: {input}"; + }, + new McpServerToolCreateOptions + { + Name = "test-tool", + Description = "A test tool" + })]); + } + + private static IDictionary CreateArguments(string key, string value) + { + return new Dictionary + { + [key] = JsonDocument.Parse($"\"{value}\"").RootElement.Clone() + }; + } + + private static JsonObject CreateForgedTaskOptInMeta() => + new() + { + [ClientCapabilitiesMetaKey] = new JsonObject + { + [ExtensionsKey] = new JsonObject + { + [McpExtensions.Tasks] = new JsonObject(), + }, + }, + }; + + [Fact] + public async Task LegacyClient_GetTaskAsync_ThrowsInvalidOperationException() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.GetTaskAsync("some-task-id", ct)); + + Assert.Contains("draft protocol revision", ex.Message); + } + + [Fact] + public async Task LegacyClient_UpdateTaskAsync_ThrowsInvalidOperationException() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.UpdateTaskAsync(new UpdateTaskRequestParams { TaskId = "some-task-id" }, ct)); + + Assert.Contains("draft protocol revision", ex.Message); + } + + [Fact] + public async Task LegacyClient_CancelTaskAsync_ThrowsInvalidOperationException() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(async () => + await client.CancelTaskAsync("some-task-id", ct)); + + Assert.Contains("draft protocol revision", ex.Message); + } + + [Fact] + public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "legacy"), + }, ct); + + Assert.False(result.IsTask); + Assert.NotNull(result.Result); + } + + [Fact] + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy + // request. The server must still refuse to create a task because the per-request protocol + // version is not the draft revision. + var result = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "forged"), + Meta = CreateForgedTaskOptInMeta(), + }, ct); + + Assert.False(result.IsTask); + Assert.NotNull(result.Result); + } + + [Fact] + public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); + var ct = TestContext.Current.CancellationToken; + + // Bypass the typed GetTaskAsync client guard by sending a raw tasks/get request. The server + // gates tasks/* to the draft revision and must reject this legacy request with MethodNotFound. + var request = new JsonRpcRequest + { + Method = RequestMethods.TasksGet, + Params = JsonSerializer.SerializeToNode( + new GetTaskRequestParams { TaskId = "some-task-id" }, + McpJsonUtilities.DefaultOptions), + }; + + var ex = await Assert.ThrowsAsync(async () => + await client.SendRequestAsync(request, ct)); + + Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); + } + + [Fact] + public async Task DraftClient_CallToolRaw_CreatesTask() + { + // Sanity: the default client negotiates the draft revision, so the task flow still works. + await using var client = await CreateMcpClientForServer(); + var ct = TestContext.Current.CancellationToken; + + var result = await client.CallToolRawAsync( + new CallToolRequestParams + { + Name = "test-tool", + Arguments = CreateArguments("input", "draft"), + }, ct); + + Assert.True(result.IsTask); + Assert.NotNull(result.TaskCreated); + } +} From e7b450efc153ec91c9b30febcf5f98f9a5ad27f9 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Wed, 24 Jun 2026 10:20:09 -0700 Subject: [PATCH 37/50] Reduce absolute time dependencies in tests (#1649) --- .../Server/McpServerTaskTests.cs | 190 +++++++++++------- .../Server/TaskPollStuckDetectorTests.cs | 13 +- 2 files changed, 123 insertions(+), 80 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 7166e1f25..9708722f3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -1,7 +1,6 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Microsoft.Extensions.DependencyInjection; -using System.Collections.Concurrent; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -179,17 +178,25 @@ public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - _ = Task.Run(async () => + var failedTask = new TaskCompletionSource(); + + // Run failure task once the task from the tool call is created + _taskStore.OnTaskCreated += taskId => { - await Task.Delay(100, ct); - var taskId = _taskStore.GetAllTaskIds().Single(); - _taskStore.FailTask(taskId, JsonElement.Parse("""{"code":-32000,"message":"something went wrong"}""")); - }, ct); + _ = Task.Run(async () => + { + await Task.Delay(100, ct); + _taskStore.FailTask(taskId, JsonElement.Parse("""{"code":-32000,"message":"something went wrong"}""")); + failedTask.SetResult(true); + }, ct); + }; await Assert.ThrowsAsync(async () => await client.CallToolAsync( new CallToolRequestParams { Name = "async-tool" }, ct)); + + Assert.True(await failedTask.Task); } [Fact] @@ -198,17 +205,25 @@ public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - _ = Task.Run(async () => + var cancelledTask = new TaskCompletionSource(); + + // Run cancellation task once the task from the tool call is created + _taskStore.OnTaskCreated += taskId => { - await Task.Delay(100, ct); - var taskId = _taskStore.GetAllTaskIds().Single(); - _taskStore.CancelTask(taskId); - }, ct); + Task.Run(async () => + { + await Task.Delay(100, ct); + _taskStore.CancelTask(taskId); + cancelledTask.SetResult(true); + }, ct); + }; await Assert.ThrowsAsync(async () => await client.CallToolAsync( new CallToolRequestParams { Name = "async-tool" }, ct)); + + Assert.True(await cancelledTask.Task); } [Fact] @@ -538,106 +553,135 @@ public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet() /// private sealed class InMemoryTaskStore { - private readonly ConcurrentDictionary _tasks = new(); + private readonly Dictionary _tasks = new(); + + internal Action? OnTaskCreated; public string CreateTask(McpTaskStatus initialStatus = McpTaskStatus.Working) { var taskId = Guid.NewGuid().ToString("N"); - _tasks[taskId] = new TaskEntry + lock (_tasks) { - Status = initialStatus, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }; + _tasks[taskId] = new TaskEntry + { + Status = initialStatus, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }; + } + + OnTaskCreated?.Invoke(taskId); + return taskId; } - public IEnumerable GetAllTaskIds() => _tasks.Keys; - - public GetTaskResult GetTask(string taskId) + public IEnumerable GetAllTaskIds() { - if (!_tasks.TryGetValue(taskId, out var entry)) + lock (_tasks) { - throw new McpException($"Unknown task: '{taskId}'"); + return _tasks.Keys.ToArray(); } + } - return entry.Status switch + public GetTaskResult GetTask(string taskId) + { + lock (_tasks) { - McpTaskStatus.Working => new WorkingTaskResult - { - TaskId = taskId, - CreatedAt = entry.CreatedAt, - LastUpdatedAt = entry.LastUpdatedAt, - PollIntervalMs = 50, - }, - McpTaskStatus.Completed => new CompletedTaskResult + if (!_tasks.TryGetValue(taskId, out var entry)) { - TaskId = taskId, - CreatedAt = entry.CreatedAt, - LastUpdatedAt = entry.LastUpdatedAt, - Result = JsonSerializer.SerializeToElement(entry.Result, McpJsonUtilities.DefaultOptions), - }, - McpTaskStatus.Failed => new FailedTaskResult - { - TaskId = taskId, - CreatedAt = entry.CreatedAt, - LastUpdatedAt = entry.LastUpdatedAt, - Error = entry.Error!.Value, - }, - McpTaskStatus.Cancelled => new CancelledTaskResult - { - TaskId = taskId, - CreatedAt = entry.CreatedAt, - LastUpdatedAt = entry.LastUpdatedAt, - }, - McpTaskStatus.InputRequired => new InputRequiredTaskResult + throw new McpException($"Unknown task: '{taskId}'"); + } + + return entry.Status switch { - TaskId = taskId, - CreatedAt = entry.CreatedAt, - LastUpdatedAt = entry.LastUpdatedAt, - InputRequests = entry.InputRequests ?? new Dictionary(), - }, - _ => throw new InvalidOperationException($"Unexpected status: {entry.Status}") - }; + McpTaskStatus.Working => new WorkingTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + PollIntervalMs = 50, + }, + McpTaskStatus.Completed => new CompletedTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + Result = JsonSerializer.SerializeToElement(entry.Result, McpJsonUtilities.DefaultOptions), + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + Error = entry.Error!.Value, + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = taskId, + CreatedAt = entry.CreatedAt, + LastUpdatedAt = entry.LastUpdatedAt, + InputRequests = entry.InputRequests ?? new Dictionary(), + }, + _ => throw new InvalidOperationException($"Unexpected status: {entry.Status}") + }; + } } public void CompleteTask(string taskId, CallToolResult result) { - if (_tasks.TryGetValue(taskId, out var entry)) + lock (_tasks) { - entry.Status = McpTaskStatus.Completed; - entry.Result = result; - entry.LastUpdatedAt = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.Result = result; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + entry.Status = McpTaskStatus.Completed; + } } } public void FailTask(string taskId, JsonElement error) { - if (_tasks.TryGetValue(taskId, out var entry)) + lock (_tasks) { - entry.Status = McpTaskStatus.Failed; - entry.Error = error; - entry.LastUpdatedAt = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.Error = error; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + entry.Status = McpTaskStatus.Failed; + } } } public void CancelTask(string taskId) { - if (_tasks.TryGetValue(taskId, out var entry)) + lock (_tasks) { - entry.Status = McpTaskStatus.Cancelled; - entry.LastUpdatedAt = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + entry.Status = McpTaskStatus.Cancelled; + } } } public void ProvideInput(string taskId, IDictionary inputResponses) { - if (_tasks.TryGetValue(taskId, out var entry)) + lock (_tasks) { - entry.InputResponses = inputResponses; - // Transition back to working after receiving input - entry.Status = McpTaskStatus.Working; - entry.LastUpdatedAt = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var entry)) + { + entry.InputResponses = inputResponses; + entry.LastUpdatedAt = DateTimeOffset.UtcNow; + // Transition back to working after receiving input + entry.Status = McpTaskStatus.Working; + } } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index 2f00d925a..1d17be223 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -15,6 +15,8 @@ namespace ModelContextProtocol.Tests.Server; /// public class TaskPollStuckDetectorTests : ClientServerTestBase { + private int _pollCount = 0; + public TaskPollStuckDetectorTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -48,6 +50,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // misbehaving-server condition the stuck-detector exists to break out of. options.Handlers.GetTaskHandler = (context, cancellationToken) => { + Interlocked.Increment(ref _pollCount); + return new ValueTask(new InputRequiredTaskResult { TaskId = context.Params!.TaskId, @@ -77,19 +81,13 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var sw = System.Diagnostics.Stopwatch.StartNew(); - var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); - sw.Stop(); - Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message); Assert.Contains("consecutive polls", ex.Message); - // 60 polls × 5ms ≈ 300ms; allow generous slack for CI. - Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), - $"Stuck-detector should give up promptly but took {sw.Elapsed}."); + Assert.Equal(60, _pollCount); } [Fact] @@ -111,6 +109,7 @@ public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() // The message embeds the configured threshold, which is the strongest signal that the // option value (not the 60-default constant) is what governed the loop. Assert.Contains($"{CustomThreshold} consecutive polls", ex.Message); + Assert.Equal(CustomThreshold, _pollCount); } [Theory] From 90c853103df98ec8f4e83be03256519f76aff7b4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:47:14 -0700 Subject: [PATCH 38/50] Remove `ExperimentalAttribute` from official Extensions/Tasks/MRTR APIs and align `MCPEXP001` usage (#1642) --- docs/list-of-diagnostics.md | 2 +- src/Common/Experimentals.cs | 40 ++++--------------- .../Protocol/ClientCapabilities.cs | 2 - .../Protocol/InputRequest.cs | 2 - .../Protocol/InputRequiredException.cs | 3 -- .../Protocol/InputRequiredResult.cs | 2 - .../Protocol/InputResponse.cs | 2 - .../Protocol/RequestParams.cs | 3 -- .../Protocol/ServerCapabilities.cs | 2 - .../Server/IMcpTaskStore.cs | 2 - .../Server/InMemoryMcpTaskStore.cs | 2 - .../Server/InputResponseReceivedEventArgs.cs | 2 - .../Server/McpServer.Methods.cs | 1 - .../Server/McpServer.cs | 1 - .../Server/McpTaskExecutionContext.cs | 3 -- .../Server/McpTaskInfo.cs | 2 - 16 files changed, 9 insertions(+), 62 deletions(-) diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 9bf6c982c..8e0e892da 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -27,7 +27,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | Diagnostic ID | Description | | :------------ | :---------- | -| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | +| `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). | | `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)). | diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index b55eba7cd..1af5ce5a3 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -9,8 +9,8 @@ namespace ModelContextProtocol; /// Experimental diagnostic IDs are grouped by category: /// /// -/// MCPEXP001 covers APIs related to experimental features in the MCP specification itself, -/// such as Extensions. These APIs may change as the specification evolves. +/// MCPEXP001 covers APIs related to experimental features in the MCP specification itself. +/// These APIs may change as the specification evolves. /// /// /// MCPEXP002 covers experimental SDK APIs that are unrelated to the MCP specification, @@ -36,19 +36,13 @@ namespace ModelContextProtocol; internal static class Experimentals { /// - /// Diagnostic ID for the experimental MCP Extensions feature. + /// Diagnostic ID for experimental MCP specification features. /// - public const string Extensions_DiagnosticId = "MCPEXP001"; - - /// - /// Message for the experimental MCP Extensions feature. - /// - public const string Extensions_Message = "The Extensions feature is part of a future MCP specification version that has not yet been ratified and is subject to change."; - - /// - /// URL for the experimental MCP Extensions feature. - /// - public const string Extensions_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001"; + /// + /// When introducing a new experimental specification feature, add feature-specific message + /// and URL constants that use this diagnostic ID. + /// + public const string SpecificationFeature_DiagnosticId = "MCPEXP001"; /// /// Diagnostic ID for experimental MCP Apps extension APIs. @@ -110,22 +104,4 @@ internal static class Experimentals /// public const string RunSessionHandler_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; - /// - /// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature. - /// - /// - /// This uses the same diagnostic ID as because MRTR - /// is an experimental feature in the MCP specification (SEP-2322). - /// - public const string Mrtr_DiagnosticId = "MCPEXP001"; - - /// - /// Message for the experimental MRTR feature. - /// - public const string Mrtr_Message = "The Multi Round-Trip Requests (MRTR) feature is experimental per the MCP specification (SEP-2322) and is subject to change."; - - /// - /// URL for the experimental MRTR feature. - /// - public const string Mrtr_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001"; } diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index fd93dcee7..22245f335 100644 --- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using ModelContextProtocol.Client; using ModelContextProtocol.Server; @@ -84,7 +83,6 @@ public sealed class ClientCapabilities /// interoperability. Clients advertise extension support via this field during the initialization handshake. /// /// - [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] [JsonIgnore] public IDictionary? Extensions { diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs index 68d767d99..1757fa6f6 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -20,7 +19,6 @@ namespace ModelContextProtocol.Protocol; /// parameters can be accessed via the typed accessor properties. /// /// -[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] [JsonConverter(typeof(Converter))] public sealed class InputRequest { diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs index 4f39b17a5..89f2e538e 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace ModelContextProtocol.Protocol; /// @@ -54,7 +52,6 @@ namespace ModelContextProtocol.Protocol; /// } /// /// -[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] public class InputRequiredException : Exception { /// diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs index b02bfa9b2..e82c3ec4d 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol; @@ -22,7 +21,6 @@ namespace ModelContextProtocol.Protocol; /// This type is part of the Multi Round-Trip Requests (MRTR) mechanism defined in SEP-2322. /// /// -[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] public sealed class InputRequiredResult : Result { /// diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs index d2e51eeb1..24081c06e 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -21,7 +20,6 @@ namespace ModelContextProtocol.Protocol; /// the corresponding key in the map. /// /// -[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] [JsonConverter(typeof(Converter))] public sealed class InputResponse { diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 004f1711f..67a8f00ba 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -36,7 +35,6 @@ private protected RequestParams() /// the value is the client's response to that input request. /// /// - [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] [JsonIgnore] public IDictionary? InputResponses { @@ -59,7 +57,6 @@ public IDictionary? InputResponses /// exact value without modification. /// /// - [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] [JsonIgnore] public string? RequestState { diff --git a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs index 0f1c4f540..bfc6318d6 100644 --- a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using ModelContextProtocol.Server; @@ -82,7 +81,6 @@ public sealed class ServerCapabilities /// interoperability. Servers advertise extension support via this field during the initialization handshake. /// /// - [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] [JsonIgnore] public IDictionary? Extensions { diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs index 9740ca378..337cb1946 100644 --- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs @@ -1,5 +1,4 @@ using ModelContextProtocol.Protocol; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace ModelContextProtocol.Server; @@ -31,7 +30,6 @@ namespace ModelContextProtocol.Server; /// specification for details on the tasks extension. /// /// -[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public interface IMcpTaskStore { /// diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index 00c69844b..762598226 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -1,7 +1,6 @@ using ModelContextProtocol.Protocol; using System.Collections.Concurrent; using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace ModelContextProtocol.Server; @@ -20,7 +19,6 @@ namespace ModelContextProtocol.Server; /// implement a custom . /// /// -[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public class InMemoryMcpTaskStore : IMcpTaskStore { private readonly ConcurrentDictionary _tasks = new(); diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs index f54152368..14447bd6f 100644 --- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs +++ b/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs @@ -1,12 +1,10 @@ using ModelContextProtocol.Protocol; -using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Server; /// /// Provides data for the event. /// -[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public sealed class InputResponseReceivedEventArgs { /// diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 99cd7e80d..4f739c28a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -600,7 +600,6 @@ private void ThrowIfRootsUnsupported() /// The task ID in the store. /// The task store to write input requests to. /// An that restores the previous context when disposed. - [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public IDisposable CreateMcpTaskScope( string taskId, IMcpTaskStore store) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index fb6d7074a..5f8ebf69a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -81,7 +81,6 @@ protected McpServer() /// the required feature) instead of throwing . /// /// - [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] public virtual bool IsMrtrSupported => false; /// diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs index b691956cf..749f36517 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs +++ b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace ModelContextProtocol.Server; /// @@ -9,7 +7,6 @@ namespace ModelContextProtocol.Server; /// and /// are redirected through the task store as input requests rather than sent directly to the client. /// -[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] internal sealed class McpTaskExecutionContext { internal static readonly AsyncLocal Current = new(); diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs index 87fa8ef1c..d275fb3a6 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs @@ -1,5 +1,4 @@ using ModelContextProtocol.Protocol; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace ModelContextProtocol.Server; @@ -14,7 +13,6 @@ namespace ModelContextProtocol.Server; /// types (, ) when communicating with clients. /// /// -[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public sealed record McpTaskInfo( string TaskId, McpTaskStatus Status, From d6b1615988d3a73ffb653d08778b14fa695e1318 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 25 Jun 2026 14:07:52 -0700 Subject: [PATCH 39/50] Follow-ups to #1610: de-draft 2026-07-28 terminology, renumber error codes, ProtocolVersion floor (#1671) --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/mrtr/mrtr.md | 10 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 41 ++++--- docs/list-of-diagnostics.md | 2 +- package-lock.json | 8 +- package.json | 2 +- src/Common/McpHttpHeaders.cs | 54 ++++----- .../HttpServerTransportOptions.cs | 17 +-- .../StreamableHttpHandler.cs | 103 ++++++++-------- .../AutoDetectingClientSessionTransport.cs | 6 +- .../Client/McpClient.Methods.cs | 19 ++- .../Client/McpClientImpl.cs | 103 ++++++++-------- .../Client/McpClientOptions.cs | 61 +++------- .../StreamableHttpClientSessionTransport.cs | 24 ++-- src/ModelContextProtocol.Core/McpErrorCode.cs | 10 +- .../McpJsonUtilities.cs | 2 +- src/ModelContextProtocol.Core/McpSession.cs | 32 +---- .../McpSessionHandler.cs | 67 ++++------- ...issingRequiredClientCapabilityException.cs | 4 +- .../Protocol/DiscoverRequestParams.cs | 2 +- .../Protocol/DiscoverResult.cs | 10 +- .../Protocol/JsonRpcMessageContext.cs | 6 +- .../Protocol/MetaKeys.cs | 14 +-- ...issingRequiredClientCapabilityErrorData.cs | 2 +- .../Protocol/NotificationMethods.cs | 2 +- .../Protocol/RequestMethods.cs | 6 +- ...criptionsAcknowledgedNotificationParams.cs | 2 +- .../SubscriptionsListenRequestParams.cs | 2 +- .../UnsupportedProtocolVersionErrorData.cs | 2 +- .../Server/AIFunctionMcpServerTool.cs | 14 +-- .../Server/McpServerImpl.cs | 111 +++++++++--------- .../UnsupportedProtocolVersionException.cs | 4 +- tests/Common/Utils/NodeHelpers.cs | 53 +-------- .../CachingConformanceTests.cs | 17 ++- .../ClientConformanceTests.cs | 5 +- .../HttpHeaderConformanceTests.cs | 52 ++++---- .../HttpServerIntegrationTests.cs | 8 +- ...s => July2026ProtocolHttpFallbackTests.cs} | 52 ++++---- ...cs => July2026ProtocolHttpHandlerTests.cs} | 65 +++++----- ... July2026ProtocolStatefulFallbackTests.cs} | 28 ++--- .../MapMcpStreamableHttpTests.cs | 10 +- .../MapMcpTests.Mrtr.cs | 54 ++++----- .../MapMcpTests.cs | 14 +-- .../MrtrProtocolTests.cs | 15 ++- .../RawHttpConformanceTests.cs | 40 +++---- .../RequestAbortCancellationTests.cs | 28 ++--- .../ResumabilityIntegrationTestsBase.cs | 6 +- .../ServerConformanceTests.cs | 63 +++++++--- .../StreamableHttpClientConformanceTests.cs | 4 +- .../StreamableHttpServerConformanceTests.cs | 30 ++--- .../Program.cs | 11 +- .../Program.cs | 4 +- .../Program.cs | 2 +- ....cs => July2026ProtocolConnectionTests.cs} | 33 +++--- ...ts.cs => July2026ProtocolFallbackTests.cs} | 60 +++++----- ... July2026ProtocolListMetaEmissionTests.cs} | 69 ++++++----- .../Client/McpClientCreationTests.cs | 2 +- .../Client/McpClientMetaTests.cs | 2 +- .../Client/McpClientTests.cs | 4 +- .../Client/McpRequestHeadersTests.cs | 2 +- .../Client/MrtrIntegrationTests.cs | 8 +- .../ClientIntegrationTests.cs | 4 +- ...rverBuilderExtensionsMessageFilterTests.cs | 6 +- .../McpServerBuilderExtensionsPromptsTests.cs | 6 +- ...cpServerBuilderExtensionsResourcesTests.cs | 6 +- .../McpServerBuilderExtensionsToolsTests.cs | 6 +- .../McpServerResourceRoutingTests.cs | 6 +- .../DiagnosticTests.cs | 2 +- .../Protocol/DiscoverProtocolTests.cs | 2 +- .../Protocol/DiscoverResultCacheableTests.cs | 2 +- .../Protocol/JsonRpcMessageConverterTests.cs | 2 +- ...s.cs => July2026ProtocolErrorDataTests.cs} | 4 +- .../SubscriptionsListenProtocolTests.cs | 2 +- ....cs => July2026ProtocolBackcompatTests.cs} | 16 +-- .../Server/McpServerToolTests.cs | 18 +-- .../Server/MrtrHandlerLifecycleTests.cs | 3 +- .../Server/MrtrServerBackcompatTests.cs | 4 +- .../Server/NegotiatedProtocolVersionTests.cs | 16 +-- .../Server/PingProtocolGatingTests.cs | 6 +- .../Server/RawStreamConformanceTests.cs | 33 +++--- .../Server/Sep2106ListToolsBackCompatTests.cs | 18 +-- .../Server/SubscriptionsListenTests.cs | 10 +- ...ingTests.cs => TaskProtocolGatingTests.cs} | 22 ++-- 85 files changed, 785 insertions(+), 898 deletions(-) rename tests/ModelContextProtocol.AspNetCore.Tests/{DraftHttpFallbackTests.cs => July2026ProtocolHttpFallbackTests.cs} (85%) rename tests/ModelContextProtocol.AspNetCore.Tests/{DraftHttpHandlerTests.cs => July2026ProtocolHttpHandlerTests.cs} (72%) rename tests/ModelContextProtocol.AspNetCore.Tests/{DraftStatefulFallbackTests.cs => July2026ProtocolStatefulFallbackTests.cs} (76%) rename tests/ModelContextProtocol.Tests/Client/{DraftConnectionTests.cs => July2026ProtocolConnectionTests.cs} (65%) rename tests/ModelContextProtocol.Tests/Client/{DraftProtocolFallbackTests.cs => July2026ProtocolFallbackTests.cs} (81%) rename tests/ModelContextProtocol.Tests/Client/{DraftListMetaEmissionTests.cs => July2026ProtocolListMetaEmissionTests.cs} (70%) rename tests/ModelContextProtocol.Tests/Protocol/{DraftErrorDataTests.cs => July2026ProtocolErrorDataTests.cs} (96%) rename tests/ModelContextProtocol.Tests/Server/{DraftProtocolBackcompatTests.cs => July2026ProtocolBackcompatTests.cs} (88%) rename tests/ModelContextProtocol.Tests/Server/{TaskDraftGatingTests.cs => TaskProtocolGatingTests.cs} (87%) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 003723e39..60530d85d 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,7 +172,7 @@ Here's an example implementation of how a console application might handle elici ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] > `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 2168a300a..5044e6d51 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -33,10 +33,10 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers the draft revision by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support draft. Servers accept the draft automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support it. Servers accept `2026-07-28` automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. ```csharp -// Client — the SDK prefers the 2026-07-28 draft (and therefore MRTR) by default. +// Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. var clientOptions = new McpClientOptions { Handlers = new McpClientHandlers @@ -47,7 +47,7 @@ var clientOptions = new McpClientOptions }; ``` -Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (which is stateless-only under the draft revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. +Under `2026-07-28`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `2026-07-28` Streamable HTTP server (where Streamable HTTP no longer supports sessions) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working on `2026-07-28` today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or `2026-07-28`. Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. @@ -281,6 +281,6 @@ The SDK supports `InputRequiredException` across two protocol revisions and two `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). -Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. +Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. -Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP runs statelessly whenever a client speaks the draft. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves draft requests sessionlessly and creates a legacy session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP runs statelessly whenever a client speaks `2026-07-28`. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves `2026-07-28` requests without a session and creates a legacy session only when an older client falls back to `initialize`. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 220887fae..bb0218f97 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -106,7 +106,7 @@ server.RegisterNotificationHandler( ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] > `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 1dd0b90ec..825acfb0a 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -123,7 +123,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han ### Multi Round-Trip Requests (MRTR) -[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. Under the draft protocol, 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. +[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] > `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 861a1cd6b..6750443df 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -24,42 +24,41 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a > [!NOTE] -> **Why is stateless now the default?** Earlier versions of the SDK defaulted to stateful for back-compat with the `2025-11-25` (and older) protocol revisions, which require the `Mcp-Session-Id` header. The `2026-07-28` draft revision removes that header (SEP-2567) and the `initialize` handshake (SEP-2575) entirely, 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 `true` to match the new wire format. You can still opt back into sessions with `Stateless = false` for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation, or server-to-client requests against clients that don't support [MRTR](xref:mrtr) — see [Stateful mode (sessions)](#stateful-mode-sessions). ## Forward and backward compatibility -The `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` draft revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` draft and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` draft 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 draft clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` draft, however, so keep a session if you need server-to-client requests against clients that don't speak the draft. Note that even with `Stateless = false`, draft requests are still served sessionlessly because the protocol forbids the session header — the stateful path activates only when a client falls back to a legacy revision. +- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that even with `Stateless = false`, a `2026-07-28` request is still served without a session because the protocol has no session header; the stateful path activates only when a client falls back to a legacy revision. > [!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. -### The 2026-07-28 draft revision +### The 2026-07-28 protocol revision -The `2026-07-28` draft 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 = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -**Server side.** With `Stateless = true` (the default), the SDK already meets the draft on the wire. Any HTTP POST that arrives with the draft `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a sessionless draft request on a stateful server is refused with a `-32004 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A draft request that carries an `Mcp-Session-Id` is always rejected, since the draft revision has no session concept. +**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP POST that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request (which carries no session) on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A `2026-07-28` request that carries an `Mcp-Session-Id` is always rejected, since the revision has no session concept. -**Stateful options marked obsolete.** Because the draft revision is unconditionally sessionless, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. +**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. -**Client side — automatic fallback.** Clients automatically probe the draft revision first and fall back to the `initialize` handshake when the server doesn't support it: +**Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it: -- **HTTP**: the client sends its first request with the draft `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32004` / `-32003` / `-32001` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. -- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms the draft revision; a `-32004` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. +- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. The era is cached per instance, so the probe cost is paid only on the first connect. -**Opting out of fallback.** Set to when you want the client to refuse to fall back. The connect call throws an instead of silently degrading. This is useful for strict-modern production code and for tests that need to assert draft-only behavior. +**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to a legacy revision. This is useful for strict-modern production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. ```csharp var clientOptions = new McpClientOptions { - ProtocolVersion = McpSession.DraftProtocolVersion, - MinProtocolVersion = McpSession.DraftProtocolVersion, + ProtocolVersion = "2026-07-28", }; ``` @@ -118,7 +117,7 @@ When - [Roots](xref:roots) (`RequestRootsAsync`) - Ping — the server cannot ping the client to verify connectivity - [MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak the `2026-07-28` draft — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). + [MRTR](xref:mrtr) brings elicitation (and the deprecated sampling and roots) to stateless mode when both client and server speak `2026-07-28` — see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions). - **[Unsolicited](#how-streamable-http-delivers-messages) server-to-client notifications** (e.g., resource update notifications, logging messages) are not supported. Every notification must be part of a direct response to a client POST request — see [How Streamable HTTP delivers messages](#how-streamable-http-delivers-messages) for why. - **No concurrent client isolation.** Every request is independent — the server cannot distinguish between two agents calling the same tool simultaneously, and there is no mechanism to maintain separate state per client. - **No state reset on reconnect.** Stateless servers have no concept of "the previous connection." There is no session to close and no fresh session to start. If your server holds any external state, you must manage cleanup through other means. @@ -140,9 +139,9 @@ Most MCP servers fall into this category. Tools that call APIs, query databases, ### Stateless alternatives for server-to-client interactions -The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a sessionless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. +The traditional approach to server-to-client interactions (elicitation, sampling, roots) requires sessions because the server must hold an open connection to send JSON-RPC requests back to the client. [Multi Round-Trip Requests (MRTR)](xref:mrtr) is a stateless alternative — instead of sending a request, the server returns an **incomplete result** that tells the client what input is needed. The client fulfills the requests and retries the tool call with the responses attached. -This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` draft, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak the draft. +This means servers that need user confirmation ([elicitation](xref:elicitation)) — or the now-deprecated (SEP-2577) [sampling](xref:sampling) and [roots](xref:roots) — can run in stateless mode when both sides support MRTR. Because MRTR rides on the unratified `2026-07-28` revision, it is far less broadly supported than session-based requests; keep a session if you must interact with clients that don't speak it. ## Stateful mode (sessions) @@ -175,7 +174,7 @@ The [deployment considerations](#deployment-considerations) below are real conce | **Scaling** | Horizontal scaling without constraints | Limited by session-affinity routing | | **Server restarts** | No impact — each request is independent | All sessions lost; clients must reinitialize | | **Memory** | Per-request only | Per-session (default: up to 10,000 sessions × 2 hours) | -| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (draft-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) | +| **Server-to-client requests** | Available via [MRTR](xref:mrtr) (`2026-07-28`-only) for elicitation, plus deprecated sampling and roots | Supported (elicitation; deprecated sampling and roots) | | **[Unsolicited notifications](#how-streamable-http-delivers-messages)** | Not supported | Supported (resource updates, logging) | | **Resource subscriptions** | Not supported | Supported | | **Client compatibility** | Works with all Streamable HTTP clients | Also supports legacy SSE-only clients via (disabled by default), but some Streamable HTTP clients [may not send `Mcp-Session-Id` correctly](#deployment-considerations) | @@ -417,16 +416,16 @@ 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` draft revision. | +| | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` protocol revision. | | | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. | | | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. | -| | `Func?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all draft-revision requests), this runs on every HTTP request. | +| | `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. | | | `Func?` | `null` | *(Experimental)* Custom session lifecycle handler. Consider `ConfigureSessionOptions` instead. | | | `ISessionMigrationHandler?` | `null` | _Stateful only (`MCP9006`)._ Enables cross-instance session migration. Can also be registered in DI. | | | `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 sessionlessly (every draft-revision 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 `Stateless = true`). They remain available as back-compat knobs for the legacy stateful Streamable HTTP path. ### ConfigureSessionOptions diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 8e0e892da..a4a71dd48 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -44,4 +44,4 @@ 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 — 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`. The draft protocol revision (`2026-07-28`) is sessionless, 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 `Stateless = false`. Starting with the `2026-07-28` protocol revision, Streamable HTTP no longer supports sessions, and the SDK now defaults `Stateless` to `true`. These knobs remain available for back-compat with the legacy stateful Streamable HTTP transport but new code should target the stateless path. | diff --git a/package-lock.json b/package-lock.json index 77ce83884..fc400cc42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.2", + "@modelcontextprotocol/conformance": "0.2.0-alpha.5", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,9 +23,9 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.2.tgz", - "integrity": "sha512-/8bde9d0mfsvgd9IwQgNIl1AS9uNOp/+ZG+2nNRWXtPs6xrz/cNp4ObBMmGY9kP8dkDaF3bvjtC/2Hj8TStMRg==", + "version": "0.2.0-alpha.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.5.tgz", + "integrity": "sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 21d33001c..3dbb9fba1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.2", + "@modelcontextprotocol/conformance": "0.2.0-alpha.5", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index ae5c84d6f..31800e2fa 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -10,27 +10,23 @@ namespace ModelContextProtocol.Protocol; internal static class McpHttpHeaders { /// - /// The draft MCP protocol version string used to gate behaviors that are only enabled - /// for clients negotiating the in-progress draft specification. + /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize + /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled + /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) + /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request + /// version against it (see ), so it underpins the more + /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it + /// by default. /// - /// - /// Behaviors currently gated on this version include: - /// - /// - /// Requiring the standard MCP request headers (Mcp-Method and Mcp-Name) - /// on Streamable HTTP POST requests; servers treat missing headers as errors only when - /// the client's MCP-Protocol-Version header matches this value. - /// - /// - /// Reporting unresolvable resource URIs from resources/read with the standard - /// JSON-RPC (-32602) code rather than the - /// legacy (-32002) code. - /// - /// - /// The associated helpers perform exact ordinal matches against this single value rather - /// than any ordered comparison. - /// - public const string DraftProtocolVersion = "2026-07-28"; + public const string July2026ProtocolVersion = "2026-07-28"; + + /// + /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP + /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. + /// It is the default version for the legacy initialize and session-resume code paths, and the + /// version the server advertises when a peer requests an unsupported version on the legacy handshake. + /// + public const string November2025ProtocolVersion = "2025-11-25"; /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -76,18 +72,20 @@ internal static class McpHttpHeaders internal const string ToolContextKey = "Mcp.Tool"; /// - /// Protocol versions that require standard MCP request headers (Mcp-Method, Mcp-Name). + /// Returns if the given protocol version is + /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. + /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. /// - private static readonly HashSet s_versionsWithStandardHeaders = new(StringComparer.Ordinal) - { - DraftProtocolVersion, - }; + internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) + => !string.IsNullOrEmpty(protocolVersion) + && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; /// - /// Returns if the given protocol version requires standard MCP request headers. + /// Returns if the given protocol version requires standard MCP request headers + /// (Mcp-Method, Mcp-Name). /// public static bool SupportsStandardHeaders(string? protocolVersion) - => !string.IsNullOrEmpty(protocolVersion) && s_versionsWithStandardHeaders.Contains(protocolVersion!); + => IsJuly2026OrLaterProtocolVersion(protocolVersion); /// /// Returns if the negotiated protocol version reports unresolvable @@ -95,5 +93,5 @@ public static bool SupportsStandardHeaders(string? protocolVersion) /// rather than the legacy (-32002). /// internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) - => string.Equals(protocolVersion, DraftProtocolVersion, StringComparison.Ordinal); + => IsJuly2026OrLaterProtocolVersion(protocolVersion); } diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index b24bb6f17..e0c8a8826 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using ModelContextProtocol.Server; namespace ModelContextProtocol.AspNetCore; @@ -51,7 +51,7 @@ public class HttpServerTransportOptions /// /// /// if the server runs in a stateless mode; if the server tracks state between requests. - /// The default is as of the 2026-07-28 draft protocol revision (SEP-2567); + /// The default is as of the 2026-07-28 protocol revision (SEP-2567); /// set to only when you need to support legacy clients that rely on session affinity. /// /// @@ -61,12 +61,13 @@ public class HttpServerTransportOptions /// 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. /// - /// The 2026-07-28 draft protocol revision is sessionless and removes Mcp-Session-Id entirely - /// (SEP-2567), so over HTTP draft requests are only ever served when . When this - /// property is , a sessionless draft request is refused with a - /// -32004 UnsupportedProtocolVersion error so that a dual-era client downgrades to the legacy - /// initialize handshake and obtains the session that the server was configured to provide. A draft - /// request that carries an Mcp-Session-Id is always rejected, regardless of this property's value. + /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: + /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served + /// when this property is . When it is , such a request is + /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-era client downgrades to + /// the legacy initialize handshake and obtains the session the server was configured to provide. + /// A request that carries an Mcp-Session-Id is always rejected by the 2026-07-28 and later + /// revisions, regardless of this property's value. /// /// public bool Stateless { get; set; } = true; diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 0b1a9df60..3224ceda8 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -39,17 +39,17 @@ internal sealed class StreamableHttpHandler( "2024-11-05", "2025-03-26", "2025-06-18", - "2025-11-25", - McpHttpHeaders.DraftProtocolVersion, + McpHttpHeaders.November2025ProtocolVersion, + McpHttpHeaders.July2026ProtocolVersion, ]; /// - /// The supported protocol versions excluding the draft revision. Used when refusing a sessionless - /// draft request on a stateful (Stateless = false) server so a dual-era client falls back to a - /// legacy initialize handshake instead of retrying the draft version. + /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and + /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-era + /// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version. /// - private static readonly string[] s_supportedProtocolVersionsExcludingDraft = - [.. s_supportedProtocolVersions.Where(static v => !string.Equals(v, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal))]; + private static readonly string[] s_sessionSupportingProtocolVersions = + [.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))]; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -140,14 +140,13 @@ public async Task HandleGetRequestAsync(HttpContext context) var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - // Under the draft protocol revision the standalone HTTP GET endpoint for unsolicited - // server-to-client messages is removed (SEP-2575); clients use subscriptions/listen (POST) - // instead. The draft is also sessionless (SEP-2567), so a draft GET is invalid whether or - // not it carries an Mcp-Session-Id. - if (IsDraftProtocolRequest(context)) + // The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited + // server-to-client messages; clients use subscriptions/listen (POST) instead. Because Streamable HTTP + // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id. + if (IsJuly2026OrLaterProtocol(context)) { await WriteJsonRpcErrorAsync(context, - "Bad Request: The GET endpoint is not supported by the draft protocol revision. Use subscriptions/listen via POST instead.", + "Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", StatusCodes.Status400BadRequest); return; } @@ -258,12 +257,12 @@ public async Task HandleDeleteRequestAsync(HttpContext context) var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - // Under the draft revision there are no sessions to terminate (SEP-2567). A draft DELETE is - // invalid whether or not it carries an Mcp-Session-Id. - if (IsDraftProtocolRequest(context)) + // Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567), + // so the DELETE is invalid whether or not it carries an Mcp-Session-Id. + if (IsJuly2026OrLaterProtocol(context)) { await WriteJsonRpcErrorAsync(context, - "Bad Request: The DELETE endpoint is not supported by the draft protocol revision (the draft protocol is sessionless).", + "Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", StatusCodes.Status400BadRequest); return; } @@ -371,33 +370,31 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - bool isDraftRequest = IsDraftProtocolRequest(context); - // Under the draft protocol revision, the draft is sessionless: SEP-2567 removes the - // Mcp-Session-Id header (and the session concept) and SEP-2575 removes the initialize - // handshake. So over HTTP, draft <=> sessionless, with no exceptions: - if (isDraftRequest) + // The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) + // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: + if (IsJuly2026OrLaterProtocol(context)) { if (!string.IsNullOrEmpty(sessionId)) { - // A draft request carrying an Mcp-Session-Id is non-conformant (SEP-2567). + // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). await WriteJsonRpcErrorAsync(context, - "Bad Request: Mcp-Session-Id is not supported under the draft protocol revision; the draft protocol is sessionless (SEP-2567).", + "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", StatusCodes.Status400BadRequest); return null; } - if (HttpServerTransportOptions.Stateless) + if (!HttpServerTransportOptions.Stateless) { - // The default (stateless) HTTP transport serves sessionless draft requests natively. - return await StartNewSessionAsync(context); + // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 + // revision cannot provide. Refuse it so a dual-era client falls back to the legacy + // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). + await WriteUnsupportedProtocolVersionErrorAsync(context); + return null; } - // The author explicitly opted into sessions (Stateless = false), which the draft revision - // cannot provide. Refuse the draft version so a dual-era client falls back to the legacy - // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). - await WriteUnsupportedDraftVersionErrorAsync(context); - return null; + // The default (stateless) HTTP transport serves these requests natively. + return await StartNewSessionAsync(context); } if (string.IsNullOrEmpty(sessionId)) @@ -431,14 +428,15 @@ await WriteJsonRpcErrorAsync(context, } /// - /// Returns when the request declares the draft protocol revision via - /// the MCP-Protocol-Version header. Draft requests are always sessionless and do not perform - /// the legacy initialize handshake (SEP-2575 + SEP-2567). + /// Returns when the request's MCP-Protocol-Version header declares a + /// revision that operates without sessions, so the server must serve it statelessly. Such requests + /// never carry an Mcp-Session-Id and never perform the legacy initialize handshake + /// (SEP-2575 + SEP-2567). /// - private static bool IsDraftProtocolRequest(HttpContext context) + private static bool IsJuly2026OrLaterProtocol(HttpContext context) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return string.Equals(protocolVersionHeader, McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); + return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader); } private async ValueTask StartNewSessionAsync(HttpContext context) @@ -446,9 +444,7 @@ private async ValueTask StartNewSessionAsync(HttpContext string sessionId; StreamableHttpServerTransport transport; - bool isStateless = HttpServerTransportOptions.Stateless; - - if (!isStateless) + if (!HttpServerTransportOptions.Stateless) { sessionId = MakeNewSessionId(); #pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally. @@ -467,7 +463,7 @@ private async ValueTask StartNewSessionAsync(HttpContext } else { - // In stateless mode (legacy or draft), each request is independent. Don't set any session ID on the transport. + // In stateless mode, each request is independent. Don't set any session ID on the transport. // If in the future we support resuming stateless requests, we should populate // the event stream store and retry interval here as well. sessionId = ""; @@ -488,13 +484,12 @@ private async ValueTask CreateSessionAsync( { var mcpServerServices = applicationServices; var mcpServerOptions = mcpServerOptionsSnapshot.Value; - bool effectivelyStateless = HttpServerTransportOptions.Stateless; - if (effectivelyStateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) + if (HttpServerTransportOptions.Stateless || HttpServerTransportOptions.ConfigureSessionOptions is not null || configureOptions is not null) { mcpServerOptions = mcpServerOptionsFactory.Create(Options.DefaultName); - if (effectivelyStateless) + if (HttpServerTransportOptions.Stateless) { // The session does not outlive the request in stateless mode. mcpServerServices = context.RequestServices; @@ -690,23 +685,25 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW } /// - /// Refuses a sessionless draft request on a stateful (Stateless = false) server. The draft revision - /// is sessionless (SEP-2567) and cannot honor the author's opt-in to sessions, so we return - /// with a supported-versions list that excludes - /// the draft. A dual-era client then falls back to the legacy initialize handshake (SEP-2575). + /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that + /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to + /// sessions; we return with a supported-versions list + /// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake + /// (SEP-2575). /// - private static Task WriteUnsupportedDraftVersionErrorAsync(HttpContext context) + private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context) { + var requestedProtocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: The draft protocol revision '{McpHttpHeaders.DraftProtocolVersion}' is sessionless and is not supported when the server is configured with sessions (HttpServerTransportOptions.Stateless = false). " + - "Use the initialize handshake with a supported non-draft protocol version instead.", + Message = $"Bad Request: Starting with protocol version '{McpHttpHeaders.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + + "Use the initialize handshake with a protocol version that still supports sessions instead.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = s_supportedProtocolVersionsExcludingDraft, - Requested = McpHttpHeaders.DraftProtocolVersion, + Supported = s_sessionSupportingProtocolVersions, + Requested = requestedProtocolVersion, }, GetRequiredJsonTypeInfo()), }; diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 3247dd8a7..015d4048d 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -76,9 +76,9 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can } 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., -32004 UnsupportedProtocolVersion, - // -32003 MissingRequiredClientCapability, -32001 HeaderMismatch, or any other + // 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 diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 218cd55d0..bfe81d19b 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -1174,10 +1174,10 @@ public async ValueTask> CallToolRawAsync( { Name = requestParams.Name, Arguments = requestParams.Arguments, - // The SEP-2663 Tasks extension is draft-only. On a legacy session, send a plain tools/call + // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call // (no task capability envelope) so the server returns a direct CallToolResult and never // creates a task. - Meta = IsDraftProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, + Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, }; JsonRpcRequest jsonRpcRequest = new() @@ -1405,19 +1405,18 @@ private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) } /// - /// Throws when the negotiated protocol version is not the draft revision. The SEP-2663 Tasks - /// extension is draft-only, and a task id only ever exists when the session negotiated draft, so - /// invoking tasks/get, tasks/update, or tasks/cancel on a legacy session is a - /// programming error rather than a recoverable protocol condition. + /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks + /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session + /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel + /// on an older session is a programming error rather than a recoverable protocol condition. /// private void ThrowIfTasksNotSupported(string operationName) { - if (!IsDraftProtocol()) + if (!IsJuly2026OrLaterProtocol()) { throw new InvalidOperationException( - $"'{operationName}' requires the draft protocol revision ('{DraftProtocolVersion}'). " + - $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'. " + - "The Tasks extension is only available under the draft revision."); + $"'{operationName}' requires a newer protocol revision that supports tasks. " + + $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'."); } } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index eb3ced9a4..eb2fedc85 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -289,21 +289,21 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - // The draft protocol revision (SEP-2575) is the default: there is no initialize + // The 2026-07-28 revision (SEP-2575) is the default: there is no initialize // handshake. Instead, the client calls server/discover to learn the server's // capabilities and then begins sending normal RPCs that carry protocolVersion / // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion - // prefers the draft revision and automatically falls back to the legacy initialize - // handshake when the server doesn't support it. The legacy branch below runs only - // when the caller explicitly pins a non-draft version (opting out of draft). - if (_options.ProtocolVersion is null || _options.ProtocolVersion == McpSessionHandler.DraftProtocolVersion) + // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize + // handshake when the server doesn't support it. The legacy branch below runs only when + // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). + if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) { - string draftVersion = McpSessionHandler.DraftProtocolVersion; + string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; - // Eagerly set the negotiated version so InjectDraftMetaIfNeeded recognizes us as - // a draft client when SendRequestAsync is invoked for server/discover. - _negotiatedProtocolVersion = draftVersion; - _sessionHandler.NegotiatedProtocolVersion = draftVersion; + // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being + // on the 2026-07-28 revision when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = preferredVersion; + _sessionHandler.NegotiatedProtocolVersion = preferredVersion; DiscoverResult? discoverResult = null; bool fallbackToLegacy = false; @@ -331,7 +331,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } catch (UnsupportedProtocolVersionException ex) { - // Spec-recognized modern-server signal: -32004 with data.supported[]. The server is + // Spec-recognized modern-server signal: -32022 with data.supported[]. The server is // modern but doesn't speak our preferred version. Retry with a mutually supported // version from data.supported[] instead of falling back to legacy initialize. fallbackToLegacy = true; @@ -339,13 +339,13 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } catch (MissingRequiredClientCapabilityException) { - // Spec-recognized modern-server signal: -32003. The server is modern but rejected + // Spec-recognized modern-server signal: -32021. The server is modern but rejected // our capability set. Surface as-is (no fallback): the user must add capabilities. throw; } catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) { - // Spec-recognized modern-server signal: -32001. The server is modern but rejected + // Spec-recognized modern-server signal: -32020. The server is modern but rejected // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. @@ -353,14 +353,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } catch (McpProtocolException) { - // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code — - // any non-modern JSON-RPC error from the probe indicates a legacy server. + // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. + // Any non-modern JSON-RPC error from the probe indicates a legacy server. // Common causes include MethodNotFound from a server that has no // server/discover handler, InvalidParams from a server confused by the // SEP-2575 _meta envelope, ParseError from a server that can't handle our // payload shape, or any other transport-defined error. The three modern-server - // signals (-32004 UnsupportedProtocolVersion, -32003 - // MissingRequiredClientCapability, -32001 HeaderMismatch) are caught above and + // signals (-32022 UnsupportedProtocolVersion, -32021 + // MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and // never reach here. fallbackToLegacy = true; } @@ -371,10 +371,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) fallbackToLegacy = true; } - if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(draftVersion)) + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // experimental version. Fall back to legacy initialize with the highest + // 2026-07-28 version. Fall back to legacy initialize with the highest // mutually-supported version from supportedVersions[]. fallbackToLegacy = true; serverSupportedVersions = discoverResult.SupportedVersions; @@ -390,15 +390,17 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) .Where(McpSessionHandler.SupportedProtocolVersions.Contains) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() - ?? McpSessionHandler.LatestProtocolVersion; + ?? McpHttpHeaders.November2025ProtocolVersion; - // Honor MinProtocolVersion: refuse to fall back below the configured minimum. - // String.Compare is the spec's prescribed ordering for ISO-8601 date-based versions. - if (_options.MinProtocolVersion is { } minVersion && - StringComparer.Ordinal.Compare(fallbackVersion, minVersion) < 0) + // A non-null ProtocolVersion is also the minimum: refuse to fall back below the + // explicitly requested version. String.Compare is the spec's prescribed ordering + // for ISO-8601 date-based versions. + if (_options.ProtocolVersion is { } pinnedVersion && + StringComparer.Ordinal.Compare(fallbackVersion, pinnedVersion) < 0) { throw new McpException( - $"Server does not support the configured minimum protocol version '{minVersion}'. " + + $"The server does not support the requested protocol version '{pinnedVersion}'. " + + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + (serverSupportedVersions is null ? "The server appears to be a legacy server that requires the deprecated initialize handshake." : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); @@ -423,9 +425,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) else { // Legacy initialize handshake. Reached only when the caller explicitly pinned a - // non-draft ProtocolVersion (opting out of the draft default), so + // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // _options.ProtocolVersion is non-null here. - string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; + string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } } @@ -475,13 +477,13 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella _serverInfo = initializeResponse.ServerInfo; _serverInstructions = initializeResponse.Instructions; - // When the user explicitly pinned a legacy (non-draft) protocol version, the server MUST - // respect it. When the user pinned the draft version but we fell back (e.g., legacy server - // rejected server/discover), or when no version was pinned, accept any supported response. - // This is the spec-mandated behavior: a draft client must be able to downgrade to whatever - // legacy version the server advertises. + // When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. + // When the user pinned the 2026-07-28 version but we fell back (e.g., legacy server rejected + // server/discover), or when no version was pinned, accept any supported response. This is the + // spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever + // version the server advertises. bool isResponseProtocolValid; - if (_options.ProtocolVersion is { } optionsProtocol && optionsProtocol != McpSessionHandler.DraftProtocolVersion) + if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) { isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; } @@ -495,15 +497,6 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella throw new McpException($"Server protocol version mismatch. Expected {requestProtocol}, got {initializeResponse.ProtocolVersion}"); } - // If the user set a MinProtocolVersion, also enforce it against the negotiated response - // (the server could have downgraded further than the version we asked for). - if (_options.MinProtocolVersion is { } minVersion && - StringComparer.Ordinal.Compare(initializeResponse.ProtocolVersion, minVersion) < 0) - { - throw new McpException( - $"Server negotiated protocol version '{initializeResponse.ProtocolVersion}' is below the configured minimum '{minVersion}'."); - } - _negotiatedProtocolVersion = initializeResponse.ProtocolVersion; _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; @@ -531,7 +524,7 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) _serverInstructions = resumeOptions.ServerInstructions; _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion ?? _options.ProtocolVersion - ?? McpSessionHandler.LatestProtocolVersion; + ?? McpHttpHeaders.November2025ProtocolVersion; // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; @@ -626,7 +619,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && const int maxRetries = 10; - InjectDraftMetaIfNeeded(request); + InjectRequestMetaIfNeeded(request); for (int attempt = 0; attempt <= maxRetries; attempt++) { @@ -665,7 +658,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && } request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; - InjectDraftMetaIfNeeded(request); + InjectRequestMetaIfNeeded(request); } else if (inputRequiredResult.RequestState is not null) { @@ -675,7 +668,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && paramsObj.Remove("inputResponses"); request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; - InjectDraftMetaIfNeeded(request); + InjectRequestMetaIfNeeded(request); } else { @@ -695,25 +688,25 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && } /// - /// Injects the draft-protocol per-request _meta fields (protocol version, client info, - /// client capabilities) into the request when this client is using the draft protocol revision - /// (SEP-2575). No-op for legacy clients. + /// Injects the 2026-07-28 protocol's per-request _meta fields (protocol version, client info, + /// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision + /// (SEP-2575). No-op on a legacy session. /// - private void InjectDraftMetaIfNeeded(JsonRpcRequest request) + private void InjectRequestMetaIfNeeded(JsonRpcRequest request) { - if (!IsDraftProtocol()) + if (!IsJuly2026OrLaterProtocol()) { return; } - // Initialize is never sent under the draft revision, but guard defensively in case a caller + // Initialize is never sent on a 2026-07-28 session, but guard defensively in case a caller // routes it through here (e.g., during back-compat fallback negotiation). if (request.Method == RequestMethods.Initialize) { return; } - McpSessionHandler.InjectDraftMeta( + McpSessionHandler.InjectRequestMeta( request, _negotiatedProtocolVersion!, _options.ClientInfo ?? DefaultImplementation, @@ -755,7 +748,7 @@ public override async ValueTask DisposeAsync() /// Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request. private void WarnIfLegacyRequestOnMrtrSession(string method) { - if (IsDraftProtocol()) + if (IsJuly2026OrLaterProtocol()) { LogLegacyRequestOnMrtrSession(_endpointName, method); } @@ -764,7 +757,7 @@ private void WarnIfLegacyRequestOnMrtrSession(string method) /// Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult. private void WarnIfInputRequiredResultOnNonMrtrSession(string method) { - if (!IsDraftProtocol()) + if (!IsJuly2026OrLaterProtocol()) { LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 4e9a7acce..aaf3cefa6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,52 +52,23 @@ public sealed class McpClientOptions /// /// /// - /// When non-, this version is requested from the server. Setting it to a - /// legacy (non-draft) version such as 2025-11-25 opts out of the draft revision and forces - /// the initialize handshake; the handshake then fails if the server's negotiated version - /// does not match. + /// When (the default), the client prefers the latest revision (2026-07-28), + /// which removed the initialize handshake and Streamable HTTP sessions. It probes with + /// server/discover and automatically falls back to the legacy initialize handshake, + /// downgrading to any version the server advertises, when the server does not support that revision. /// /// - /// When (the default), the client prefers the draft revision - /// (): it probes with server/discover and - /// automatically falls back to a legacy initialize handshake, downgrading to any version - /// the server advertises, when the server does not support the draft revision. + /// When non-, this value is both the requested version and the minimum the client + /// will accept: the client requests exactly this version and refuses to downgrade below it, throwing an + /// instead of falling back. Setting it to 2026-07-28 therefore disables + /// the automatic legacy-server fallback, and setting it to a version that still supports Streamable HTTP + /// sessions, such as 2025-11-25, forces the initialize handshake and fails if the server + /// negotiates a different version. To try more than one version, leave this unset for automatic fallback + /// or retry the connection with a different value. /// /// public string? ProtocolVersion { get; set; } - /// - /// Gets or sets the minimum protocol version the client will accept during version negotiation. - /// - /// - /// - /// When negotiating with a server that advertises multiple supported versions, or when falling back - /// to a legacy server, the client will refuse any version older than this minimum and surface an - /// instead. - /// - /// - /// This is useful when the client requires features (such as the draft revision's removal of the - /// initialize handshake or Mcp-Session-Id) that are not available in older protocol - /// revisions. Because the client already prefers the draft revision by default, setting this to - /// disables the automatic legacy-server fallback - /// that otherwise switches to the initialize handshake. - /// - /// - /// If (the default), the client falls back to any version the server - /// advertises, including legacy versions such as 2025-11-25. - /// - /// - /// - /// // The draft revision is already the default; pin the minimum to refuse the legacy fallback. - /// var clientOptions = new McpClientOptions - /// { - /// MinProtocolVersion = McpSession.DraftProtocolVersion, - /// }; - /// - /// - /// - public string? MinProtocolVersion { get; set; } - /// /// Gets or sets a timeout for the client-server initialization handshake sequence. /// @@ -128,10 +99,10 @@ public sealed class McpClientOptions /// /// /// - /// This timeout only has an effect when the client prefers the draft protocol revision — that is, - /// when is (the default) or - /// . In that mode the client first probes the server - /// with a server/discover request. A legacy server that predates the draft revision may + /// This timeout only has an effect when the client prefers the 2026-07-28 protocol revision, that is, + /// when is (the default) or 2026-07-28. + /// In that mode the client first probes the server with a + /// server/discover request. A legacy server that predates the 2026-07-28 revision may /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the /// client concludes the server is legacy and falls back to the initialize handshake on the /// same connection. When the caller pins a legacy , no probe is issued @@ -140,7 +111,7 @@ public sealed class McpClientOptions /// /// The default is intentionally short so that dual-era clients fall back quickly against legacy /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or - /// satellite links) where a short probe could trigger the legacy fallback before a draft-capable + /// satellite links) where a short probe could trigger the legacy fallback before a server on the new revision /// server has had a chance to respond. The probe is always also bounded by /// , which governs the overall connect budget: if this value is /// greater than or equal to , the probe is effectively bounded by diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index c02d5eaea..26ffdce65 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -67,19 +67,19 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a // JSON-RPC error envelope means the peer is signalling something application-level about // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the - // connect-time logic can react — for example, the three modern draft-protocol error codes - // (-32004 UnsupportedProtocolVersion, -32003 MissingRequiredClientCapability, - // -32001 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from - // legacy servers that don't understand the draft _meta envelope) become generic + // connect-time logic can react. For example, the three modern protocol error codes + // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, + // -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from + // legacy servers that don't understand the SEP-2575 _meta envelope) become generic // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. - // The three modern draft-protocol error codes are also surfaced for non-400 status codes - // for robustness — servers occasionally emit them with 4xx codes other than 400. + // The three modern protocol error codes are also surfaced for non-400 status codes + // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && (response.StatusCode == HttpStatusCode.BadRequest || - IsModernDraftErrorCode((McpErrorCode)parsedError.Error.Code))) + IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -87,7 +87,7 @@ await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsModernDraftErrorCode(McpErrorCode code) => + private static bool IsModernProtocolErrorCode(McpErrorCode code) => code is McpErrorCode.UnsupportedProtocolVersion or McpErrorCode.MissingRequiredClientCapability or McpErrorCode.HeaderMismatch; @@ -144,9 +144,9 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes LogTransportSendingMessageSensitive(message); - // Under the draft protocol revision (SEP-2575), every request carries its protocol version in + // Under the 2026-07-28 or later protocol revision (SEP-2575), every request carries its protocol version in // _meta/io.modelcontextprotocol/protocolVersion (and the matching MCP-Protocol-Version HTTP - // header). Pick the value off the message so the first draft request (server/discover) can + // header). Pick the value off the message so the first request (server/discover) can // include the header even before we've recorded a negotiated version from an initialize reply. var protocolVersionForRequest = ExtractProtocolVersionFromMeta(message) ?? _negotiatedProtocolVersion; @@ -229,7 +229,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes } else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse) { - // Under the draft protocol revision (SEP-2575), server/discover replaces the initialize + // Under the 2026-07-28 or later protocol revision (SEP-2575), server/discover replaces the initialize // handshake. The transport caches the protocol version from the outgoing request's _meta // so subsequent requests carry the matching MCP-Protocol-Version header without re-parsing. _negotiatedProtocolVersion ??= ExtractProtocolVersionFromMeta(message); @@ -240,7 +240,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes /// /// Reads the protocol version from a request's _meta/io.modelcontextprotocol/protocolVersion field, - /// introduced by the draft protocol revision (SEP-2575). Returns for messages that + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Returns for messages that /// don't have that field. /// private static string? ExtractProtocolVersionFromMeta(JsonRpcMessage message) diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 74f9110bb..65c662a1a 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -23,7 +23,7 @@ public enum McpErrorCode /// This error code is in the JSON-RPC implementation-defined server error range (-32000 to -32099). /// /// - HeaderMismatch = -32001, + HeaderMismatch = -32020, /// /// Indicates that the requested resource could not be found. @@ -49,24 +49,24 @@ public enum McpErrorCode /// /// /// - /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). The error data MUST include a /// requiredCapabilities object describing the capabilities the server requires from the client /// to process the request. For HTTP, the response status code is 400 Bad Request. /// /// - MissingRequiredClientCapability = -32003, + MissingRequiredClientCapability = -32021, /// /// Indicates that the request's declared protocol version is not supported by the server. /// /// /// - /// Introduced by the draft protocol revision (SEP-2575). The error data MUST include a + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). The error data MUST include a /// supported array of protocol version strings the server supports and the original /// requested protocol version. For HTTP, the response status code is 400 Bad Request. /// /// - UnsupportedProtocolVersion = -32004, + UnsupportedProtocolVersion = -32022, /// /// Indicates that URL-mode elicitation is required to complete the requested operation. diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 32a68104b..da1b898dd 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -89,7 +89,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) // matching JSON Schema 2020-12: a schema may be either a JSON object (the usual form // with keywords like "type", "properties", etc.) or a boolean (`true` matches anything, // `false` matches nothing). Stricter keyword-level validation is intentionally not - // performed. Pre-2026-06-30 clients still receive the legacy wrapped wire shape — that + // performed. Pre-2026-07-28 clients still receive the legacy wrapped wire shape — that // wiring lives in AIFunctionMcpServerTool.CreateStructuredResponse and McpServerImpl's // listToolsHandler. internal static bool IsValidToolOutputSchema(JsonElement element) => diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 4804e1ad3..e1bd0844b 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -28,27 +28,6 @@ namespace ModelContextProtocol; /// public abstract partial class McpSession : IAsyncDisposable { - /// The latest stable protocol revision this SDK supports. - /// - /// Set or - /// to this value to explicitly pin to the current stable revision instead of accepting whatever - /// the runtime negotiates. - /// - public const string LatestProtocolVersion = McpSessionHandler.LatestProtocolVersion; - - /// The in-progress draft protocol revision this SDK supports. - /// - /// The draft revision removes the initialize handshake (SEP-2575) and the - /// Mcp-Session-Id header (SEP-2567), so it is sessionless on the wire and over HTTP is only - /// served when the server is stateless. A stateful (HttpServerTransportOptions.Stateless = false) - /// server refuses a sessionless draft request so that a dual-era client downgrades to the legacy - /// initialize flow. Clients prefer this revision by default and automatically fall back to the - /// legacy flow when the server does not support it; pin - /// to a legacy version to opt out, or set to this - /// value to keep the draft preference while refusing the legacy fallback. - /// - public const string DraftProtocolVersion = McpSessionHandler.DraftProtocolVersion; - /// Gets an identifier associated with the current MCP session. /// /// Typically populated in transports supporting multiple sessions, such as Streamable HTTP or SSE. @@ -67,15 +46,16 @@ public abstract partial class McpSession : IAsyncDisposable public abstract string? NegotiatedProtocolVersion { get; } /// - /// Gets a value indicating whether the negotiated protocol version is the draft revision - /// (, which carries SEP-2575 + SEP-2567 + MRTR). + /// Gets a value indicating whether the negotiated protocol version is 2026-07-28 or later: the + /// revision that removed the initialize handshake (SEP-2575) and Mcp-Session-Id (SEP-2567) + /// and enabled MRTR (SEP-2322). /// /// /// Returns when no version has been negotiated yet. This is the shared - /// definition of "is this peer speaking the draft revision" used by both the client and server. + /// definition of "is this peer speaking the 2026-07-28 or later revision" used by both the client and server. /// - internal bool IsDraftProtocol() => - NegotiatedProtocolVersion == DraftProtocolVersion; + internal bool IsJuly2026OrLaterProtocol() => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); /// /// Sends a JSON-RPC request to the connected session and waits for a response. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 1a33fe670..a0122c9a4 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -28,22 +28,9 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable private static readonly Histogram s_serverOperationDuration = Diagnostics.CreateDurationHistogram( "mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent."); - /// The latest version of the protocol supported by this implementation. - internal const string LatestProtocolVersion = "2025-11-25"; - /// - /// The draft protocol version (SEP-2575 + SEP-2567) that removes the initialize handshake - /// and Mcp-Session-Id and enables MRTR (Multi Round-Trip Requests) per SEP-2322. - /// Clients prefer this revision by default and fall back to the legacy initialize handshake - /// when the server does not support it; pin to a - /// legacy version to opt out. Servers remain reactive: with - /// left they honor whichever - /// supported revision each peer requests, so a single server serves both draft and legacy clients. - /// - internal const string DraftProtocolVersion = "2026-07-28"; - - /// - /// All protocol versions supported by this implementation. + /// All protocol versions supported by this implementation. The version constants live on + /// so the shared source file is the single source of truth. /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. /// internal static readonly string[] SupportedProtocolVersions = @@ -51,8 +38,8 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "2024-11-05", "2025-03-26", "2025-06-18", - LatestProtocolVersion, - DraftProtocolVersion, + McpHttpHeaders.November2025ProtocolVersion, + McpHttpHeaders.July2026ProtocolVersion, ]; /// @@ -66,7 +53,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// internal static bool SupportsPrimingEvent(string? protocolVersion) { - const string MinResumabilityProtocolVersion = "2025-11-25"; + const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; if (protocolVersion is null) { @@ -82,22 +69,19 @@ internal static bool SupportsPrimingEvent(string? protocolVersion) /// /// The negotiated protocol version, or null if /// negotiation has not completed. - /// true if the version is "2026-06-30" or later (including the - /// in-flight "DRAFT-2026-06-v1", since 'D' > '2' ordinally); false - /// otherwise. A false return signals that the wire emission boundary must apply - /// the {"result": <value>} envelope expected by clients on protocol versions - /// that pre-date SEP-2106's widening of outputSchema to any JSON Schema 2020-12 - /// document. + /// true if the version is the 2026-07-28 revision or later, which is where + /// SEP-2106 widened outputSchema to any JSON Schema 2020-12 document; false otherwise. + /// A false return signals that the wire emission boundary must apply the + /// {"result": <value>} envelope expected by clients on protocol versions that pre-date + /// SEP-2106. internal static bool SupportsNaturalOutputSchemas(string? protocolVersion) { - const string MinNaturalOutputSchemasProtocolVersion = "2026-06-30"; - if (protocolVersion is null) { return false; } - return string.Compare(protocolVersion, MinNaturalOutputSchemasProtocolVersion, StringComparison.Ordinal) >= 0; + return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; } private readonly bool _isServer; @@ -169,17 +153,16 @@ public McpSessionHandler( _outgoingMessageFilter = outgoingMessageFilter ?? (next => next); _logger = logger; - // ping was removed in the draft protocol revision (SEP-2575). Under draft, return - // MethodNotFound; under legacy, the per-spec behavior is to always answer with PingResult. - // Liveness on draft sessions belongs to transport- and request-level timeouts, not a - // dedicated MCP RPC. + // ping was removed in the 2026-07-28 protocol revision (SEP-2575). On the 2026-07-28 or later version, + // return MethodNotFound; on an older version, the per-spec behavior is to always answer + // with PingResult. Liveness on those requests belongs to transport- and request-level + // timeouts, not a dedicated MCP RPC. _requestHandlers.Set( RequestMethods.Ping, (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (perRequestVersion is not null && - StringComparer.Ordinal.Compare(perRequestVersion, DraftProtocolVersion) >= 0) + if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", @@ -426,7 +409,7 @@ private static async Task GetCompletionDetailsAsync(Tas private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { - // Project the draft-protocol per-request _meta fields onto the message context before any + // 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) @@ -570,7 +553,7 @@ await SendMessageAsync(new JsonRpcResponse } /// - /// Reads the draft-protocol per-request _meta fields off the request and projects them onto + /// 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. /// /// @@ -599,8 +582,8 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) { // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with - // -32001 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant draft clients recognize as a modern-server signal and surface as-is rather + // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), + // which conformant 2026-07-28 clients recognize as a modern-server signal and surface as-is rather // than mistaking it for a legacy server and falling back to the initialize handshake. if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) { @@ -629,16 +612,16 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) } /// - /// Injects the draft-protocol per-request _meta fields into an outgoing request. + /// 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 /// so per-request capability opt-ins already present in the envelope are preserved. /// /// - /// Used by in draft mode to carry protocol version, client info, and - /// client capabilities on every outgoing request (replacing what the legacy initialize handshake - /// previously negotiated once). + /// Used by on a 2026-07-28 or later session to carry protocol version, client + /// info, and client capabilities on every outgoing request (replacing what the legacy + /// initialize handshake previously negotiated once). /// - internal static void InjectDraftMeta( + internal static void InjectRequestMeta( JsonRpcRequest request, string protocolVersion, Implementation clientInfo, diff --git a/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs index aca6d4902..9beaa7a51 100644 --- a/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs +++ b/src/ModelContextProtocol.Core/MissingRequiredClientCapabilityException.cs @@ -10,9 +10,9 @@ namespace ModelContextProtocol; /// in the request's per-request _meta/io.modelcontextprotocol/clientCapabilities field. /// /// -/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when a handler cannot +/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Servers throw this exception when a handler cannot /// proceed because the client did not declare a required capability for the request. The exception is converted -/// to a JSON-RPC error response with code (-32003) +/// to a JSON-RPC error response with code (-32021) /// and a payload. /// public sealed class MissingRequiredClientCapabilityException : McpProtocolException diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs index e9a343f46..198fb4311 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverRequestParams.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol; /// The discover RPC takes no payload of its own. Per-request metadata /// (protocol version, client info, client capabilities) flows through the /// inherited property under the -/// io.modelcontextprotocol/* keys defined by the draft protocol revision (SEP-2575). +/// io.modelcontextprotocol/* keys defined by the 2026-07-28 protocol revision (SEP-2575). /// /// public sealed class DiscoverRequestParams : RequestParams diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index 7a4e75453..5ec348c06 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// -/// Introduced by the draft protocol revision (SEP-2575) as the canonical way for a client +/// Introduced by the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client /// to learn what a server supports without performing the legacy initialize handshake. /// /// @@ -47,8 +47,9 @@ public sealed class DiscoverResult : Result, ICacheableResult /// /// /// Spec PR #2855 makes ttlMs a required field on . The - /// server emits a safe default (, i.e. immediately stale) on - /// draft sessions when the application has not set an explicit value, preserving today's + /// server emits a safe default (, i.e. immediately stale) for the + /// 2026-07-28 and later protocol revisions when the application has not set an explicit value, + /// preserving today's /// "do not cache" behavior while satisfying the wire requirement. /// [JsonPropertyName("ttlMs")] @@ -58,7 +59,8 @@ public sealed class DiscoverResult : Result, ICacheableResult /// /// /// Spec PR #2855 makes cacheScope a required field on . The - /// server emits a safe default () on draft sessions + /// server emits a safe default () for the 2026-07-28 and + /// later protocol revisions /// when the application has not set an explicit value. /// [JsonPropertyName("cacheScope")] diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index b804c288a..28d7774cb 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -91,7 +91,7 @@ public sealed class JsonRpcMessageContext /// _meta/io.modelcontextprotocol/clientInfo field. /// /// - /// Introduced by the draft protocol revision (SEP-2575). When the request was made under the draft revision, + /// 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. /// public Implementation? ClientInfo { get; set; } @@ -101,7 +101,7 @@ public sealed class JsonRpcMessageContext /// _meta/io.modelcontextprotocol/clientCapabilities field. /// /// - /// Introduced by the draft protocol revision (SEP-2575). Per the spec, the server MUST NOT infer client + /// 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. /// public ClientCapabilities? ClientCapabilities { get; set; } @@ -111,7 +111,7 @@ public sealed class JsonRpcMessageContext /// _meta/io.modelcontextprotocol/logLevel field. /// /// - /// Introduced by the draft protocol revision (SEP-2575). Replaces the legacy + /// 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. /// diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs index 495f71553..4d9139953 100644 --- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -9,9 +9,9 @@ public static class MetaKeys /// The metadata key used to carry the MCP protocol version in a request's _meta field. /// /// - /// Introduced by the draft protocol revision (SEP-2575). For HTTP transports, the value MUST - /// match the MCP-Protocol-Version header. Servers reject mismatched versions with - /// . + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). For HTTP transports, the value MUST + /// match the MCP-Protocol-Version header. Servers reject a header/body mismatch with + /// . /// public const string ProtocolVersion = "io.modelcontextprotocol/protocolVersion"; @@ -19,7 +19,7 @@ public static class MetaKeys /// The metadata key used to identify the client software in a request's _meta field. /// /// - /// Introduced by the draft protocol revision (SEP-2575). Carries an + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Carries an /// describing the client; replaces the clientInfo previously sent only with initialize. /// public const string ClientInfo = "io.modelcontextprotocol/clientInfo"; @@ -28,7 +28,7 @@ public static class MetaKeys /// The metadata key used to declare client capabilities in a request's _meta field. /// /// - /// Introduced by the draft protocol revision (SEP-2575). Carries a + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Carries a /// describing what optional features the client supports for this specific request. Servers MUST NOT /// infer capabilities from previous requests. /// @@ -38,7 +38,7 @@ public static class MetaKeys /// The metadata key used to specify the desired log level for a request's resulting log notifications. /// /// - /// Introduced by the draft protocol revision (SEP-2575). Carries a . + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Carries a . /// Replaces the legacy RPC. When absent, the server /// MUST NOT send log notifications for the request. /// @@ -49,7 +49,7 @@ public static class MetaKeys /// subscription. /// /// - /// Introduced by the draft protocol revision (SEP-2575). Allows clients to demultiplex notifications + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Allows clients to demultiplex notifications /// belonging to different subscriptions on a shared channel (especially STDIO). /// public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId"; diff --git a/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs index 8370aaf9a..8e5991f26 100644 --- a/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs +++ b/src/ModelContextProtocol.Core/Protocol/MissingRequiredClientCapabilityErrorData.cs @@ -6,7 +6,7 @@ namespace ModelContextProtocol.Protocol; /// Represents the payload for the JSON-RPC error. /// /// -/// Introduced by the draft protocol revision (SEP-2575). When a server cannot fulfill a request because +/// Introduced by the 2026-07-28 protocol revision (SEP-2575). When a server cannot fulfill a request because /// the client did not declare a required capability in its per-request /// _meta/io.modelcontextprotocol/clientCapabilities field, it MUST return this error so clients /// know which capabilities to advertise on a retry. diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 39fe0780f..0bd3348f8 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -159,7 +159,7 @@ public static class NotificationMethods /// response stream to indicate which notification types the server agreed to deliver. /// /// - /// Introduced by the draft protocol revision (SEP-2575). The notification's params mirror the shape + /// Introduced by the 2026-07-28 protocol revision (SEP-2575). The notification's params mirror the shape /// of the requested notifications and include only the entries the server actually supports. /// public const string SubscriptionsAcknowledgedNotification = "notifications/subscriptions/acknowledged"; diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index 028a5629d..a2884efba 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -158,7 +158,7 @@ public static class RequestMethods /// /// /// - /// This RPC is introduced in the draft protocol revision (SEP-2575) as the canonical way for a client + /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client /// to learn what a server supports without performing the legacy initialize handshake. /// /// @@ -166,7 +166,7 @@ public static class RequestMethods /// information, and optional usage instructions. /// /// - /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Draft-revision clients + /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Clients on the 2026-07-28 revision /// typically call this once during connection establishment. /// /// @@ -178,7 +178,7 @@ public static class RequestMethods /// /// /// - /// This RPC is introduced in the draft protocol revision (SEP-2575) and replaces the unsolicited + /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) and replaces the unsolicited /// HTTP GET endpoint and the legacy / /// request methods. /// diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs index f4212b2b7..577cd3566 100644 --- a/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsAcknowledgedNotificationParams.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// -/// Introduced by the draft protocol revision (SEP-2575). This notification is the first message on a +/// Introduced by the 2026-07-28 protocol revision (SEP-2575). This notification is the first message on a /// response stream and informs the client which /// subset of requested notification types the server has agreed to deliver. /// diff --git a/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs index a81d45669..ac6c38c5b 100644 --- a/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/SubscriptionsListenRequestParams.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// -/// Introduced by the draft protocol revision (SEP-2575). The client uses this request to open a +/// Introduced by the 2026-07-28 protocol revision (SEP-2575). The client uses this request to open a /// long-lived channel for receiving notifications outside the context of a specific request. /// /// diff --git a/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs index ac394db90..ab684e6cf 100644 --- a/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs +++ b/src/ModelContextProtocol.Core/Protocol/UnsupportedProtocolVersionErrorData.cs @@ -6,7 +6,7 @@ namespace ModelContextProtocol.Protocol; /// Represents the payload for the JSON-RPC error. /// /// -/// Introduced by the draft protocol revision (SEP-2575). When a server receives a request whose +/// Introduced by the 2026-07-28 protocol revision (SEP-2575). When a server receives a request whose /// declared protocol version it does not implement, it MUST return this error so clients can /// fall back to a mutually supported version. /// diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 6fad7766b..82b6ceb9d 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using System.ComponentModel; @@ -236,8 +236,8 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider /// /// Returns a clone whose is rewritten /// into the wire shape required by clients on protocol versions older than - /// "2026-06-30". Those versions require outputSchema.type == "object"; - /// SEP-2106 (negotiated at "2026-06-30" and later) widens that to any JSON + /// "2026-07-28". Those versions require outputSchema.type == "object"; + /// SEP-2106 (negotiated at "2026-07-28" and later) widens that to any JSON /// Schema 2020-12 document. To stay compatible, non-object schemas are wrapped in /// {"type":"object","properties":{"result":<schema>}} and the /// type:["object","null"] array form is normalized to plain "object" @@ -510,7 +510,7 @@ schema.ValueKind is not JsonValueKind.Object || // Per SEP-2106, any valid JSON Schema document is acceptable for outputSchema — // arrays, primitives, compositions, and nullable types pass through unchanged. // Explicit OutputSchema takes precedence over AIFunction's return schema. - // Back-compat for pre-2026-06-30 clients is applied at the wire emission sites + // Back-compat for pre-2026-07-28 clients is applied at the wire emission sites // (CreateStructuredResponse for tools/call, listToolsHandler for tools/list). if (toolCreateOptions.OutputSchema is { } explicitSchema) { @@ -531,7 +531,7 @@ schema.ValueKind is not JsonValueKind.Object || /// is neither plain object-typed (type:"object") nor the /// type:["object","null"] array form. Used by /// to decide whether to apply the envelope when emitting to a client that negotiated a - /// protocol version older than "2026-06-30" (those versions pre-date SEP-2106's + /// protocol version older than "2026-07-28" (those versions pre-date SEP-2106's /// allowance of non-object output schemas). The inner type:["object","null"] /// check is hoisted into a named bool to keep the surrounding control flow free of /// empty branches. @@ -565,11 +565,11 @@ schemaNode is JsonObject objSchema && /// /// Transforms into the wire shape required by clients - /// on protocol versions older than "2026-06-30": non-object schemas are wrapped + /// on protocol versions older than "2026-07-28": non-object schemas are wrapped /// in {"type":"object","properties":{"result":<schema>},"required":["result"]}, /// the type:["object","null"] array form is normalized to plain "object", /// and plain object-typed schemas pass through unchanged. SEP-2106 clients - /// ("2026-06-30"+) see the natural schema and never need this transform. + /// ("2026-07-28"+) see the natural schema and never need this transform. /// Dispatches on so the wrap decision lives /// in one place. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2cc5d4621..f6eb0d60e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -107,7 +107,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact // 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. - if (IsStatefulSession()) + if (HasStatefulTransport()) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification); @@ -134,9 +134,9 @@ void Register(McpServerPrimitiveCollection? collection, ServerCapabilities.Resources.ListChanged = null; } - // And initialize the session. The built-in draft state-sync filter runs ahead of any - // user-supplied incoming filters; see PrependDraftStateSyncFilter for what it records and why. - var incomingMessageFilter = PrependDraftStateSyncFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters)); + // 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. + var incomingMessageFilter = PrependMetaReadingFilter(BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters)); var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); _sessionHandler = new McpSessionHandler( @@ -158,13 +158,13 @@ void Register(McpServerPrimitiveCollection? collection, /// version, before delegating to the user-supplied incoming filters. /// /// - /// Under the draft protocol revision (SEP-2575) there is no initialize handshake, so these values + /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in /// filter is a no-op (the values were captured during the initialize handler). /// - private JsonRpcMessageFilter PrependDraftStateSyncFilter(JsonRpcMessageFilter inner) + private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { - JsonRpcMessageFilter draftStateSync = next => async (message, cancellationToken) => + JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) { @@ -174,7 +174,7 @@ private JsonRpcMessageFilter PrependDraftStateSyncFilter(JsonRpcMessageFilter in { // 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 (-32004) carrying the supported list. + // with an UnsupportedProtocolVersionError (-32022) carrying the supported list. if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) { throw new UnsupportedProtocolVersionException( @@ -185,12 +185,12 @@ private JsonRpcMessageFilter PrependDraftStateSyncFilter(JsonRpcMessageFilter in SetNegotiatedProtocolVersion(protocolVersion); } - if (context.ClientCapabilities is { } clientCapabilities && IsDraftProtocol() && IsStatefulSession()) + if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) { - // Under the draft revision the per-request _meta envelope carries the client's FULL - // capabilities (SEP-2575), so a plain overwrite is correct. The IsDraftProtocol() gate + // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL + // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate // makes any legacy per-request envelope a no-op (legacy capabilities stay as the - // initialize handshake established them); the IsStatefulSession() gate keeps + // initialize handshake established them); the HasStatefulTransport() gate keeps // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } // (where the same server instance handles every request, so persisting per-request // capability state would both leak across requests and break the StatelessServerTests @@ -216,7 +216,7 @@ private JsonRpcMessageFilter PrependDraftStateSyncFilter(JsonRpcMessageFilter in await next(message, cancellationToken).ConfigureAwait(false); }; - return next => draftStateSync(inner(next)); + return next => metaReadingFilter(inner(next)); } /// @@ -368,12 +368,12 @@ private void ConfigureInitialize(McpServerOptions options) protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? clientProtocolVersion : - McpSessionHandler.LatestProtocolVersion; + McpHttpHeaders.November2025ProtocolVersion; // The legacy initialize handshake is authoritative: it may supersede a protocol version - // a prior draft server/discover probe established on the same connection (the dual-era + // a prior server/discover probe established on the same connection (the dual-era // fallback path a permissive client takes against an unknown server). Unlike the - // per-request draft version - which SetNegotiatedProtocolVersion locks once negotiated - + // per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated - // initialize force-sets the version. _negotiatedProtocolVersion = protocolVersion; _sessionHandler.NegotiatedProtocolVersion = protocolVersion; @@ -391,7 +391,7 @@ private void ConfigureInitialize(McpServerOptions options) } /// - /// Registers the server/discover request handler introduced by the draft protocol revision (SEP-2575). + /// Registers the server/discover request handler introduced by the 2026-07-28 protocol revision (SEP-2575). /// /// /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's @@ -421,7 +421,7 @@ private void ConfigureDiscover(McpServerOptions options) } /// - /// Registers the subscriptions/listen request handler introduced by the draft protocol revision (SEP-2575). + /// Registers the subscriptions/listen request handler introduced by the 2026-07-28 protocol revision (SEP-2575). /// /// /// @@ -449,7 +449,7 @@ private void ConfigureSubscriptions(McpServerOptions options) // request granting no notifications and complete immediately. This runs after protocol // negotiation, so it is not a legacy-server signal and never triggers a client fallback to the // initialize handshake. - if (!IsStatefulSession()) + if (!HasStatefulTransport()) { var statelessSubscription = new ActiveSubscription( jsonRpcRequest.Id, @@ -521,16 +521,16 @@ private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifi /// /// /// Pre-SEP-2575 clients do not open subscriptions/listen streams, so they keep receiving a single - /// session-wide broadcast. Draft clients instead receive only the change notifications they explicitly + /// session-wide broadcast. Clients on the 2026-07-28 or later revision instead receive only the change notifications they explicitly /// requested, each routed back over the originating subscription stream and tagged with its id; the server - /// MUST NOT send a draft client notification types it never subscribed to. + /// MUST NOT send such a client notification types it never subscribed to. /// private async Task SendListChangedNotificationAsync(string notificationMethod) { // Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. - // subscriptions/listen is a SEP-2575 draft feature, so draft clients instead get a fan-out limited - // to the notification types they explicitly subscribed to. - if (!IsDraftProtocol()) + // subscriptions/listen is a SEP-2575 feature, so clients on the 2026-07-28 or later revision instead get + // a fan-out limited to the notification types they explicitly subscribed to. + if (!IsJuly2026OrLaterProtocol()) { await this.SendNotificationAsync(notificationMethod).ConfigureAwait(false); return; @@ -809,12 +809,12 @@ private void ConfigureTasks(McpServerOptions options) updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - // The tasks/* methods do not exist before the draft revision (SEP-2663). Reject them with + // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for draft requests. - getTaskHandler = GateTaskMethodToDraft(getTaskHandler, RequestMethods.TasksGet); - updateTaskHandler = GateTaskMethodToDraft(updateTaskHandler, RequestMethods.TasksUpdate); - cancelTaskHandler = GateTaskMethodToDraft(cancelTaskHandler, RequestMethods.TasksCancel); + // stay registered so a dual-era server still serves them for 2026-07-28 requests. + getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); + updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); + cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); // Advertise tasks extension in server capabilities. ServerCapabilities.Extensions ??= new Dictionary(); @@ -841,17 +841,17 @@ private void ConfigureTasks(McpServerOptions options) /// /// Wraps a tasks/* request handler so it throws unless the - /// request was negotiated under the draft revision. The tasks extension (SEP-2663) only interoperates - /// under draft, and these methods don't exist on legacy peers. + /// request was negotiated under the 2026-07-28 or later revision. The tasks extension (SEP-2663) only + /// interoperates on the 2026-07-28 revision, and these methods don't exist on older peers. /// - private McpRequestHandler GateTaskMethodToDraft( + private McpRequestHandler GateTaskMethodToJuly2026OrLaterProtocol( McpRequestHandler inner, string method) => (request, cancellationToken) => { - if (!IsDraftProtocolRequest(request.JsonRpcRequest)) + if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) { throw new McpProtocolException( - $"The method '{method}' requires the draft protocol revision ('{DraftProtocolVersion}'); " + + $"The method '{method}' requires a newer protocol revision that supports tasks; " + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", McpErrorCode.MethodNotFound); } @@ -1176,11 +1176,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) if (request.Params?.Cursor is null) { // SEP-2106 wire shaping: clients on protocol versions older than - // 2026-06-30 require outputSchema.type == "object", so the natural + // 2026-07-28 require outputSchema.type == "object", so the natural // schema is reshaped before emission (type:["object","null"] normalized // to "object", any other non-object schema wrapped in // {"type":"object","properties":{"result":}}). Clients on - // 2026-06-30+ receive the natural JSON Schema 2020-12 document stored + // 2026-07-28+ receive the natural JSON Schema 2020-12 document stored // on Tool.OutputSchema. Only AIFunctionMcpServerTool tools go through // reshaping; custom McpServerTool subclasses build their Tool directly // and pass through unchanged at every protocol version. @@ -1260,12 +1260,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) var innerTaskHandler = callToolWithTaskHandler; callToolWithTaskHandler = async (request, cancellationToken) => { - // The SEP-2663 Tasks extension is draft-only: the task wire shapes we ship do not + // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the - // request was negotiated under the draft revision AND the client opted in; otherwise + // request was negotiated under the 2026-07-28 or later revision AND the client opted in; otherwise // run the inner handler and return the direct result (best-effort downgrade, which also // defends against a non-conformant legacy client that forges the opt-in envelope). - if (IsDraftProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) + if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) { var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); var taskId = taskInfo.TaskId; @@ -1770,12 +1770,13 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => }; /// - /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (2026-07-28). MRTR rides on - /// the draft revision, so this is the MRTR-meaning alias of - - /// use it at the input-required/handler-suspension sites where the intent is "the client understands - /// " rather than "the peer speaks the draft revision". + /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (first available in the + /// 2026-07-28 revision). MRTR rides on the 2026-07-28 revision, so this is the MRTR-meaning alias of + /// - use it at the input-required/handler-suspension + /// sites where the intent is "the client understands " rather than + /// "the peer speaks the 2026-07-28 or later revision". /// - internal bool ClientSupportsMrtr() => IsDraftProtocol(); + internal bool ClientSupportsMrtr() => IsJuly2026OrLaterProtocol(); /// /// Returns when the session is stateful - the same server instance handles @@ -1784,23 +1785,21 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => /// elicitation/create / sampling/createMessage / roots/list to the client and /// retry the handler with the responses. /// - internal bool IsStatefulSession() => + internal bool HasStatefulTransport() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; /// - /// Returns when the given request was negotiated under the draft protocol + /// Returns when the given request was negotiated under the 2026-07-28 or later protocol /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works - /// for sessionless draft over stateless HTTP) and falling back to the session-negotiated version. - /// Used to gate the SEP-2663 Tasks extension, which only interoperates under the draft revision. + /// for requests over stateless HTTP) and falling back to the session-negotiated version. + /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// - private bool IsDraftProtocolRequest(JsonRpcRequest? request) => - string.Equals( - request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion, - DraftProtocolVersion, - StringComparison.Ordinal); + private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( + request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); /// - public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession(); + public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport(); /// /// Invokes a handler and catches to convert it to an @@ -1834,7 +1833,7 @@ private bool IsDraftProtocolRequest(JsonRpcRequest? request) => // 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 (!IsStatefulSession()) + if (!HasStatefulTransport()) { throw new McpException( "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + @@ -2056,7 +2055,7 @@ private void WrapHandlerWithMrtr(string method) // 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() || !IsStatefulSession()) + if (!ClientSupportsMrtr() || !HasStatefulTransport()) { return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); } diff --git a/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs index fc37e05cd..72ba8906d 100644 --- a/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs +++ b/src/ModelContextProtocol.Core/UnsupportedProtocolVersionException.cs @@ -9,10 +9,10 @@ namespace ModelContextProtocol; /// Represents an exception used to signal that a request's declared protocol version is not supported by the server. /// /// -/// Introduced by the draft protocol revision (SEP-2575). Servers throw this exception when they cannot process +/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Servers throw this exception when they cannot process /// a request because the per-request _meta/io.modelcontextprotocol/protocolVersion (or the equivalent /// transport-level header) names a version the server does not implement. The exception is converted to a -/// JSON-RPC error response with code (-32004) and +/// JSON-RPC error response with code (-32022) and /// a payload. /// public sealed class UnsupportedProtocolVersionException : McpProtocolException diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index b549bdd76..45ecdcde2 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -2,7 +2,6 @@ using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; -using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests.Utils; @@ -85,7 +84,7 @@ public static void EnsureNpmDependenciesInstalled() /// When (the default) and the MCP_CONFORMANCE_PROTOCOL_VERSION /// environment variable is set, a "--spec-version <value>" argument is appended. /// Pass for scenarios that pin their own spec version (e.g. the - /// draft-only caching scenario) to avoid a conflicting duplicate flag. + /// caching scenario specific to the 2026-07-28 protocol) to avoid a conflicting duplicate flag. /// /// A configured ProcessStartInfo for running the binary. public static ProcessStartInfo ConformanceTestStartInfo(string arguments, bool appendProtocolVersionFromEnv = true) @@ -188,12 +187,9 @@ public static bool IsNodeInstalled() /// the pinned version in package.json) means this also returns /// when a newer private build has been installed locally via /// npm install --no-save <path-to-conformance>. - /// Additionally requires that the installed conformance package emits the draft wire - /// version this SDK speaks — see . /// public static bool HasSep2243Scenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) - && HasMatchingDraftWireVersion(); + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance @@ -202,47 +198,9 @@ public static bool HasSep2243Scenarios() /// Reading the installed version (rather than the pinned version in package.json) means /// this also returns when a newer private build has been installed /// locally via npm install --no-save <path-to-conformance>. - /// Additionally requires that the installed conformance package emits the draft wire - /// version this SDK speaks — see . /// public static bool HasCachingScenario() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) - && HasMatchingDraftWireVersion(); - - /// - /// Returns when the installed conformance package's bundled - /// dist emits the same draft protocol version string as this SDK - /// (). Used to suppress draft-only - /// conformance scenarios when the published conformance binary is still pinned to a - /// stale wire string (for example, conformance 0.2.0-alpha.2 ships - /// "DRAFT-2026-v1" while this SDK speaks "2026-07-28"). - /// - /// - /// This check is a pragmatic alternative to inspecting the conformance package's - /// internal constants: the bundled dist/index.js is minified so we can't grep - /// the constant name, but the literal version string survives bundling and is unique - /// enough to be a reliable signal. - /// - public static bool HasMatchingDraftWireVersion() - { - try - { - var repoRoot = FindRepoRoot(); - var distPath = Path.Combine( - repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); - if (!File.Exists(distPath)) - { - return false; - } - - var bundled = File.ReadAllText(distPath); - return bundled.Contains(McpHttpHeaders.DraftProtocolVersion, StringComparison.Ordinal); - } - catch - { - return false; - } - } + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); /// /// Returns when the conformance package installed in node_modules @@ -425,12 +383,9 @@ private static bool ConformanceOutputIndicatesSuccess(string output) /// Reading the installed version (rather than the pinned version in package.json) means /// this also returns when a newer private build has been installed /// locally via npm install --no-save <path-to-conformance>. - /// Additionally requires that the installed conformance package emits the draft wire - /// version this SDK speaks — see . /// public static bool HasMrtrScenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)) - && HasMatchingDraftWireVersion(); + => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 27cba0d40..38e503258 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -4,8 +4,9 @@ namespace ModelContextProtocol.ConformanceTests; /// -/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the draft -/// SEP-2549 "caching" conformance scenario requires. Started on demand (so it is not bound +/// A ConformanceServer instance started in the SEP-2575 stateless lifecycle, which the +/// SEP-2549 "caching" conformance scenario (new in the 2026-07-28 protocol revision) +/// requires. Started on demand (so it is not bound /// when the caching test is skipped) and torn down via . Uses a /// distinct port range from the stateful ConformanceServerFixture (3001/3002/3003) so /// the two can run in parallel without TCP conflicts. @@ -105,13 +106,9 @@ public async ValueTask DisposeAsync() /// (tools/list, prompts/list, resources/list, resources/templates/list, resources/read). /// /// -/// The scenario is draft-only (introduced in spec wire version 2026-07-28) and uses the -/// stateless lifecycle. It is gated on the installed conformance package version (>= 0.2.0) -/// AND on the installed package emitting the draft wire string this SDK speaks (so it stays -/// skipped under conformance 0.2.0-alpha.2 which still ships the placeholder -/// DRAFT-2026-v1). It activates automatically once a conformance package emitting -/// 2026-07-28 is installed (e.g. via -/// npm install --no-save <path-to-conformance>). The stateless server is +/// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. +/// It is gated on the installed conformance +/// package version (>= 0.2.0). The stateless server is /// started only after the gates pass, so a skipped run binds no port. /// public class CachingConformanceTests(ITestOutputHelper output) @@ -126,7 +123,7 @@ public async Task RunCachingConformanceTest() await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); - // The caching scenario only exists in the draft spec, so pin the spec version + // The caching scenario only exists in the 2026-07-28 protocol revision, so pin the spec version // explicitly (and suppress the MCP_CONFORMANCE_PROTOCOL_VERSION override to avoid a // conflicting duplicate --spec-version flag). var result = await NodeHelpers.RunServerConformanceAsync( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index f389ffbba..2990b3d83 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -65,8 +65,11 @@ public async Task RunConformanceTest(string scenario) // HTTP Standardization (SEP-2243) [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] - [InlineData("http-custom-headers")] [InlineData("http-invalid-tool-headers")] + // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, + // which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers, + // failing every positive check. Re-enable once a conformant conformance package ships (#1655). + // [InlineData("http-custom-headers")] public async Task RunConformanceTest_Sep2243(string scenario) { // Run the conformance test suite diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index db751af6b..4da16a3eb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -114,7 +114,7 @@ private static McpServerTool CreateUnionHeaderTestTool() public async Task Server_AcceptsUnionIntegerCanonicalForm() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body // carries the decimal form 42.0. The server must treat the union type as integer and match. @@ -135,7 +135,7 @@ public async Task Server_AcceptsUnionIntegerCanonicalForm() public async Task Server_RejectsUnionIntegerOutsideSafeRange() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); var callJson = CallTool("union_test", """{"priority":9007199254740993}"""); @@ -154,7 +154,7 @@ public async Task Server_RejectsUnionIntegerOutsideSafeRange() public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100". var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}"""); @@ -177,7 +177,7 @@ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values // and compare the trimmed value to the request body. @@ -201,7 +201,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); @@ -224,7 +224,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Send a tools/call with an empty string param that has an x-mcp-header. // The header should be present with an empty value, matching the body's empty string. @@ -248,7 +248,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Send a tools/call where the body has a non-empty value but the header is empty var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}"""); @@ -271,7 +271,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Encode a value with a newline control character using Base64 var valueWithNewline = "line1\nline2"; @@ -297,7 +297,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without // losing precision through a double conversion. @@ -326,7 +326,7 @@ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243 integer values MUST be within the JavaScript safe integer range. // A matching header and body that are both outside the range must still be rejected. @@ -355,7 +355,7 @@ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue) { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and // "4.2e1" are preserved in the body exactly as another SDK might serialize them. @@ -382,7 +382,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue) { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected // even when the header and body strings are byte-for-byte identical (it must not slip through @@ -407,7 +407,7 @@ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(strin public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Header says "99" but body says priority:42 — must reject even with numeric comparison var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}"""); @@ -427,17 +427,17 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() } [Fact] - public async Task Server_SkipsHeaderValidation_ForNonDraftVersion() + public async Task Server_SkipsHeaderValidation_ForLegacyVersion() { await StartAsync(); - await InitializeWithNonDraftVersionAsync(); + await InitializeWithLegacyVersionAsync(); - // With non-draft version, Mcp-Param-* headers are NOT validated even if mismatched + // With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - // Send the WRONG header value — this should still succeed because version is non-draft + // Send the WRONG header value. This should still succeed because the version is legacy. request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); @@ -451,7 +451,7 @@ public async Task Server_SkipsHeaderValidation_ForNonDraftVersion() public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() { await StartAsync(); - await InitializeWithDraftVersionAsync(); + await InitializeWithJuly2026ProtocolVersionAsync(); // Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers // instead of properly base64-encoding non-ASCII values. @@ -570,32 +570,32 @@ public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool #region Helpers - private async Task InitializeWithDraftVersionAsync() + private async Task InitializeWithJuly2026ProtocolVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestDraft); + request.Content = JsonContent(InitializeRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Draft protocol revision (SEP-2567) is sessionless: the server does not return a + // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 - // to route through the sessionless path. + // so each one is handled independently. } - private async Task InitializeWithNonDraftVersionAsync() + private async Task InitializeWithLegacyVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Server is stateless by default (SEP-2567), so initializing with the non-draft protocol does not return - // a mcp-session-id header. Subsequent requests are independent, just like the draft path. + // Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return + // a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision. } private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); @@ -614,7 +614,7 @@ private string CallTool(string toolName, string arguments = "{}") {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; - private static string InitializeRequestDraft => """ + private static string InitializeRequestJuly2026Protocol => """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index 5fd4d49e4..abd3823f2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -34,8 +34,8 @@ public async Task ConnectAndPing_Sse_TestServer() // Arrange // Act - // ping was removed in the draft revision (SEP-2575), so pin to the latest stable protocol - // version to keep exercising the legacy ping RPC. Draft liveness relies on the transport. + // ping was removed in the 2026-07-28 protocol revision (SEP-2575), so pin to the latest stable + // protocol version to keep exercising the legacy ping RPC. On the 2026-07-28 protocol, liveness relies on the transport. await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -49,8 +49,8 @@ public async Task Connect_TestServer_ShouldProvideServerFields() // Arrange // Act - // Stateful Streamable HTTP only provisions a session ID under the legacy handshake; the draft - // revision is sessionless. Pin to the latest stable version to keep covering session-ID provisioning. + // Stateful Streamable HTTP only provisions a session ID under the legacy handshake. Starting with the + // 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version to keep covering session-ID provisioning. await using var client = await GetClientAsync(new McpClientOptions { ProtocolVersion = "2025-11-25" }); // Assert diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs similarity index 85% rename from tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 55401c15a..02bfba288 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -14,10 +14,10 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Regression tests for the draft-protocol-to-legacy fallback path over Streamable HTTP. These +/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These /// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's /// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body -/// on a draft probe; vanilla Go does the same on POST /) so the client's HTTP-fallback +/// on a 2026-07-28 probe; vanilla Go does the same on POST /) so the client's HTTP-fallback /// logic can be exercised in isolation without the cross-SDK harness. /// /// @@ -27,10 +27,10 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// /// -/// only surfaced the three modern draft -/// error codes (-32004, -32003, -32001) as ; +/// only surfaced the three error codes +/// introduced by the 2026-07-28 revision (-32022, -32021, -32020) as ; /// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server -/// that doesn't understand the draft _meta envelope) threw +/// that doesn't understand the 2026-07-28 _meta envelope) threw /// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger /// on ANY non-modern JSON-RPC error in a 400 body. /// @@ -43,7 +43,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// /// -public class DraftHttpFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +public class July2026ProtocolHttpFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { private WebApplication? _app; @@ -84,13 +84,13 @@ private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatus } /// - /// Mimics Python's simple-streamablehttp-stateless on a draft probe: returns + /// Mimics Python's simple-streamablehttp-stateless on a 2026-07-28 probe: returns /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the /// initial server/discover, then performs a normal legacy initialize handshake /// when the client falls back. /// [Fact] - public async Task DraftClient_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() { var ct = TestContext.Current.CancellationToken; @@ -107,11 +107,11 @@ await StartServerAsync(async context => return; } - // Draft probe: simulate a legacy server that rejects the unknown protocol version with + // 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). if (request.Method == RequestMethods.ServerDiscover) { - await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: -32600, message: "Bad Request: Unsupported protocol version: draft"); + await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, code: -32600, message: "Bad Request: Unsupported protocol version: 2026-07-28"); return; } @@ -157,10 +157,9 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, new McpClientOptions - { - ProtocolVersion = McpSession.DraftProtocolVersion, - }, loggerFactory: LoggerFactory, cancellationToken: ct); + // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); @@ -170,12 +169,12 @@ await StartServerAsync(async context => } /// - /// Mimics vanilla Go: returns 400 + JSON-RPC -32004 with - /// data.supported[] on a draft probe so the client retries legacy + /// Mimics vanilla Go: returns 400 + JSON-RPC -32022 with + /// data.supported[] on a 2026-07-28 probe so the client retries legacy /// initialize with one of the advertised versions. /// [Fact] - public async Task DraftClient_OnUnsupportedProtocolVersion_AdoptsStreamableHttp_NoSseFallback() + public async Task Client_OnUnsupportedProtocolVersion_AdoptsStreamableHttp_NoSseFallback() { var ct = TestContext.Current.CancellationToken; @@ -194,12 +193,12 @@ await StartServerAsync(async context => if (request.Method == RequestMethods.ServerDiscover) { - // -32004 with the spec-shaped data: client should retry with one of supported[]. + // -32022 with the spec-shaped data: client should retry with one of supported[]. // Use the typed payload type so the source-generated serializer can handle it. var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData { Supported = new List { "2025-11-25" }, - Requested = "draft", + Requested = "2026-07-28", }, GetJsonTypeInfo()); var rpcError = new JsonRpcError @@ -245,20 +244,19 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - await using var client = await McpClient.CreateAsync(transport, new McpClientOptions - { - ProtocolVersion = McpSession.DraftProtocolVersion, - }, loggerFactory: LoggerFactory, cancellationToken: ct); + // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); } /// - /// A 400 with a JSON-RPC -32001 HeaderMismatch envelope must be surfaced to the - /// caller (no legacy fallback) — falling back wouldn't fix a malformed envelope. + /// A 400 with a JSON-RPC -32020 HeaderMismatch envelope must be surfaced to the + /// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope. /// [Fact] - public async Task DraftClient_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() + public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() { var ct = TestContext.Current.CancellationToken; bool initializeReceived = false; @@ -295,7 +293,7 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpSession.DraftProtocolVersion, + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs similarity index 72% rename from tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index b6f3352ae..9cce9b0db 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/DraftHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -9,26 +9,24 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// HTTP-level tests for the draft protocol revision (SEP-2575 + SEP-2567): verify that the server -/// suppresses the Mcp-Session-Id header for draft requests and returns structured +/// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server +/// suppresses the Mcp-Session-Id header for those requests and returns structured /// errors instead of plain 400s. /// -public class DraftHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; - private WebApplication? _app; private async Task StartAsync(bool stateless = false) { Builder.Services.AddMcpServer(options => { - options.ServerInfo = new Implementation { Name = nameof(DraftHttpHandlerTests), Version = "1" }; + options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolHttpHandlerTests), Version = "1" }; }).WithHttpTransport(options => { - // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions, which the - // draft revision cannot honor (so sessionless draft requests are refused). Stateless = true (the - // default) serves sessionless draft natively. + // Stateless = false maps the GET/DELETE endpoints and opts the author into sessions. Starting with + // the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so such a request is + // refused on a session-enabled server. Stateless = true (the default) serves them natively. options.Stateless = stateless; }); @@ -50,33 +48,33 @@ public async ValueTask DisposeAsync() } [Fact] - public async Task DraftRequest_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() + public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() { await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); - // On a stateless server, sessionless draft server/discover succeeds without creating a session. + // On a stateless server, server/discover succeeds without creating a session. var content = new StringContent( """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Draft responses must not include Mcp-Session-Id"); + Assert.False(response.Headers.Contains("Mcp-Session-Id"), "Responses on the 2026-07-28 revision must not include Mcp-Session-Id"); } [Fact] - public async Task DraftRequest_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() + public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVersionError() { - // The draft revision is sessionless (SEP-2567), so it cannot honor a server configured with - // sessions (Stateless = false). The server refuses the draft version with - // UnsupportedProtocolVersion (excluding draft from Supported) so a dual-era client falls back + // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), + // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that + // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back // to the legacy initialize handshake. await StartAsync(stateless: false); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( @@ -95,10 +93,10 @@ public async Task DraftRequest_OnStatefulServer_IsRefused_WithUnsupportedProtoco var dataElement = (JsonElement)rpcError.Error.Data!; var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); Assert.NotNull(errorData); - Assert.Equal(DraftVersion, errorData.Requested); - // The draft version is excluded from Supported so the client downgrades to a legacy version. + Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested); + // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version. Assert.NotEmpty(errorData.Supported); - Assert.DoesNotContain(DraftVersion, errorData.Supported); + Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported); } [Fact] @@ -130,13 +128,14 @@ public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProto } [Fact] - public async Task DraftRequest_WithMcpSessionIdHeader_IsRejected() + public async Task Request_WithMcpSessionIdHeader_IsRejected() { - // The draft revision is sessionless (SEP-2567): a draft request carrying an Mcp-Session-Id is - // non-conformant and is rejected with 400 regardless of the Stateless setting. + // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): + // a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the + // Stateless setting. await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); @@ -149,11 +148,11 @@ public async Task DraftRequest_WithMcpSessionIdHeader_IsRejected() } [Fact] - public async Task DraftGet_WithoutSessionId_IsRejected() + public async Task Get_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); @@ -161,11 +160,11 @@ public async Task DraftGet_WithoutSessionId_IsRejected() } [Fact] - public async Task DraftGet_WithSessionId_IsRejected() + public async Task Get_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); @@ -174,11 +173,11 @@ public async Task DraftGet_WithSessionId_IsRejected() } [Fact] - public async Task DraftDelete_WithoutSessionId_IsRejected() + public async Task Delete_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); @@ -186,11 +185,11 @@ public async Task DraftDelete_WithoutSessionId_IsRejected() } [Fact] - public async Task DraftDelete_WithSessionId_IsRejected() + public async Task Delete_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs similarity index 76% rename from tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs rename to tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index 18815fd00..ef2d3f6aa 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/DraftStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -10,15 +10,15 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// End-to-end coverage for a default (draft-first) client connecting to a real C# Streamable HTTP +/// 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). Draft is sessionless (SEP-2567 / SEP-2575), so the server refuses the -/// sessionless draft probe with -32004 UnsupportedProtocolVersion. The client must then -/// auto-downgrade to the legacy initialize handshake, obtain the stateful session the server -/// author opted into, and continue to work — including a server→client elicitation round-trip +/// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports +/// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. +/// The client must then auto-downgrade to the legacy initialize handshake, obtain the stateful session +/// the server author opted into, and continue to work, including a server→client elicitation round-trip /// resolved over the stateful session via the legacy backcompat resolver. /// -public class DraftStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { private WebApplication? _app; @@ -38,7 +38,7 @@ public async ValueTask DisposeAsync() private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { // Server→client round-trip: only works when the session is stateful, which is exactly what - // the legacy fallback re-establishes for the draft-first client. + // the legacy fallback re-establishes for the 2026-07-28-first client. var elicitResult = await server.ElicitAsync(new ElicitRequestParams { Message = "What is your name?", @@ -56,10 +56,10 @@ private async Task StartStatefulServerAsync() { Builder.Services.AddMcpServer(options => { - options.ServerInfo = new Implementation { Name = nameof(DraftStatefulFallbackTests), Version = "1" }; + options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolStatefulFallbackTests), Version = "1" }; }) - // Stateless = false is a deliberate opt-in to sessions. Draft can never be served - // statefully, so the server refuses the sessionless draft probe and the client downgrades. + // Stateless = false is a deliberate opt-in to sessions. Starting with the 2026-07-28 protocol revision, + // Streamable HTTP can never be served statefully, so the server refuses the probe and the client downgrades. .WithHttpTransport(options => options.Stateless = false) .WithTools([McpServerTool.Create(Greet), McpServerTool.Create(GreetViaElicit)]); @@ -76,7 +76,7 @@ private async Task ConnectDefaultClientAsync(Action TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - // Default options: ProtocolVersion is null, which now prefers the draft revision and probes + // Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes // with server/discover before falling back to a legacy initialize handshake. var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); @@ -84,13 +84,13 @@ private async Task ConnectDefaultClientAsync(Action } [Fact] - public async Task DefaultDraftClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() { await StartStatefulServerAsync(); await using var client = await ConnectDefaultClientAsync(); - // The sessionless draft probe was refused (-32004), so the client downgraded to legacy. + // The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy. Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet", @@ -102,7 +102,7 @@ public async Task DefaultDraftClient_AgainstStatefulServer_DowngradesToLegacy_An } [Fact] - public async Task DefaultDraftClient_AgainstStatefulServer_ServerToClientElicitation_RoundTrips() + public async Task DefaultClient_AgainstStatefulServer_ServerToClientElicitation_RoundTrips() { await StartStatefulServerAsync(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index dcd1bcf50..889a7daab 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -486,8 +486,8 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenNoEvent await app.StartAsync(TestContext.Current.CancellationToken); - // Polling via an event-stream store is a stateful-session feature. Under draft, Streamable HTTP - // is sessionless, so pin to the latest stable version to keep exercising the stateful path. + // Polling via an event-stream store is a stateful-session feature. Starting with the 2026-07-28 + // protocol revision, Streamable HTTP no longer supports sessions, so pin to the latest stable version to keep exercising the stateful path. await using var mcpClient = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); @@ -541,7 +541,7 @@ public async Task AdditionalHeaders_AreSent_InPostAndDeleteRequests() }; // DELETE requests are only sent when there's a session ID to delete - a legacy stateful - // behavior. Under draft, Streamable HTTP is sessionless. Pin to the latest stable version. + // behavior. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version. await using var mcpClient = await ConnectAsync(transportOptions: transportOptions, configureClient: options => options.ProtocolVersion = "2025-11-25"); // Do a tool call to ensure there's more than just the initialize request @@ -722,7 +722,7 @@ public async Task Client_CanReconnect_AfterSessionExpiry() // Connect the first client and verify it works. // Server-side session expiry and reconnect rely on session IDs, a legacy stateful behavior. - // Under draft, Streamable HTTP is sessionless. Pin both clients to the latest stable version. + // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin both clients to the latest stable version. var client1 = await ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25"); var originalSessionId = client1.SessionId; Assert.NotNull(originalSessionId); @@ -797,7 +797,7 @@ public async Task EndpointFilter_CanReadSessionId_BeforeAndAfterHandler() await app.StartAsync(TestContext.Current.CancellationToken); // The stateful (else) branch below asserts session-ID behavior, which only exists under the - // legacy handshake; the draft revision is sessionless. Pin legacy only for the stateful variant. + // legacy handshake. Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin legacy only for the stateful variant. await using var client = await ConnectAsync(configureClient: options => { if (!Stateless) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index e5c5a123f..3d8abb0f1 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -10,12 +10,12 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { - // Draft is sessionless (SEP-2567): the Streamable HTTP handler refuses a sessionless draft request - // when the server opted into sessions (Stateless = false), so a draft-pinned client downgrades to - // legacy instead of negotiating 2026-07-28. These draft 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 DraftStatefulStreamableHttpSkipReason = - "Draft is sessionless (SEP-2567); stateful Streamable HTTP refuses sessionless draft. Covered by the stateless and SSE fixtures."; + // 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 + // 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 = + "Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567); stateful Streamable HTTP refuses it. Covered by the stateless and SSE fixtures."; private ServerMessageTracker ConfigureServer(params Delegate[] tools) { @@ -25,7 +25,7 @@ private ServerMessageTracker ConfigureServer(params Delegate[] tools) options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; // Do not pin a protocol version - let it be negotiated based on what the client requests. // 2026-07-28 is in SupportedProtocolVersions, so an opt-in client gets it; others get - // the latest non-draft. + // the latest legacy version. messageTracker.AddFilters(options.Filters.Message); }) .WithHttpTransport(ConfigureStateless) @@ -40,8 +40,8 @@ private Task ConnectExperimentalAsync() => options.ProtocolVersion = "2026-07-28"; }); - // The default client now negotiates draft (2026-07-28). The legacy JSON-RPC MRTR back-compat - // resolver only applies to legacy clients, so pin these to the latest non-draft version. + // The default client now negotiates the 2026-07-28 protocol revision. The legacy + // JSON-RPC MRTR back-compat resolver only applies to legacy clients, so pin these to the latest legacy version. private Task ConnectLegacyAsync() => ConnectAsync(configureClient: options => { @@ -169,16 +169,16 @@ private static async Task MrtrMixed(McpServer server, RequestContext configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } - // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. @@ -289,10 +289,10 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) // Parallel awaits work with regular JSON-RPC but fail with MRTR because // MrtrContext only supports one exchange at a time (TrySetResult gate). Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); - // Under the draft protocol revision (SEP-2567), the server is implicitly stateless for draft - // clients, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client + // Starting with the 2026-07-28 protocol revision (SEP-2567), the server is implicitly stateless for + // clients on that revision, so parallel-await MRTR can't reach its concurrency gate. Skip the experimental-client // case for the same reason as Mrtr_MixedExceptionAndAwaitStyle. - Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; draft protocol revision (SEP-2567) is sessionless."); + Assert.SkipWhen(experimentalClient, "Await-style MRTR requires session affinity; starting with the 2026-07-28 protocol revision (SEP-2567) Streamable HTTP no longer supports sessions."); ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); @@ -301,7 +301,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } - // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); @@ -357,7 +357,7 @@ private static string MrtrElicit(RequestContext context) [Fact] public async Task Mrtr_Roots_CompletesViaMrtr() { - Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => @@ -435,7 +435,7 @@ private static string MrtrMulti(RequestContext context) [InlineData(false)] public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) { - Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer(MrtrMulti); await using var app = Builder.Build(); @@ -445,7 +445,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } - // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); @@ -484,7 +484,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) [InlineData(false)] public async Task Mrtr_IsMrtrSupported(bool experimentalClient) { - Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + Assert.SkipWhen(experimentalClient && UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); await using var app = Builder.Build(); @@ -494,7 +494,7 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient) // Configure client - experimental or default based on parameter. Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2026-07-28"; } - // ProtocolVersion null now defaults to draft, so pin the legacy client explicitly to keep dual-era coverage. + // ProtocolVersion null now defaults to the 2026-07-28 protocol revision, so pin the legacy client explicitly to keep dual-era coverage. : options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "2025-11-25"; }; await using var client = await ConnectAsync(configureClient: configureClient); Assert.Equal(experimentalClient ? "2026-07-28" : "2025-11-25", client.NegotiatedProtocolVersion); @@ -550,7 +550,7 @@ private static string MrtrConcurrentThree(RequestContext [Fact] public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() { - Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer(MrtrConcurrentThree); await using var app = Builder.Build(); @@ -604,7 +604,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() [Fact] public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() { - Assert.SkipWhen(UseStreamableHttp && !Stateless, DraftStatefulStreamableHttpSkipReason); + Assert.SkipWhen(UseStreamableHttp && !Stateless, July2026StatefulStreamableHttpSkipReason); var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index b269c9951..ef6832101 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -111,8 +111,8 @@ public async Task Messages_FromNewUser_AreRejected() await app.StartAsync(TestContext.Current.CancellationToken); - // Session-scoped user validation across requests is a legacy stateful-session behavior; the - // draft revision is sessionless. Pin to the latest stable version to keep covering it. + // Session-scoped user validation across requests is a legacy stateful-session behavior. Starting with the + // 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version to keep covering it. var httpRequestException = await Assert.ThrowsAsync( () => ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25")); Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode); @@ -163,8 +163,8 @@ public async Task Sampling_DoesNotCloseStreamPrematurely() await using var mcpClient = await ConnectAsync(configureClient: options => { // Server->client sampling over the open response stream is a stateful-session behavior. - // Under draft, Streamable HTTP is forced sessionless, so the implicit-MRTR suspend path - // doesn't apply over HTTP (draft sampling is covered by the stdio MRTR tests). Pin legacy. + // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so the implicit-MRTR suspend path + // doesn't apply over HTTP (this sampling path is covered by the stdio MRTR tests). Pin legacy. options.ProtocolVersion = "2025-11-25"; options.Handlers.SamplingHandler = async (parameters, _, _) => { @@ -326,9 +326,9 @@ await client.CallToolAsync("echo_with_user_name", new Dictionary { ["message"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); - // The client now defaults to the draft revision, whose handshake is server/discover + // The client now defaults to the 2026-07-28 protocol revision, whose handshake is server/discover // rather than the legacy initialize request. On the stateful Streamable HTTP fixture the - // sessionless draft request is refused, so the client downgrades to the legacy initialize. + // request is refused, so the client downgrades to the legacy initialize. var expectedHandshakeMethod = UseStreamableHttp && !Stateless ? RequestMethods.Initialize : RequestMethods.ServerDiscover; @@ -387,7 +387,7 @@ public async Task OutgoingFilter_SeesResponsesAndRequests() await using var client = await ConnectAsync(configureClient: opts => { // Server-originated sampling requests and the initialize response are legacy stateful - // behaviors; the draft revision routes sampling through MRTR and drops initialize. + // behaviors; the 2026-07-28 protocol revision routes sampling through MRTR and drops initialize. opts.ProtocolVersion = "2025-11-25"; opts.Capabilities = clientOptions.Capabilities; opts.Handlers = clientOptions.Handlers; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 15c415683..68076e292 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -13,10 +13,10 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the draft revision. -/// Under the draft protocol (SEP-2575 + SEP-2567) Streamable HTTP is sessionless, so these tests -/// drive the default server with raw, sessionless -/// draft JSON-RPC requests (no initialize, no Mcp-Session-Id) and verify the explicit +/// Protocol-level tests for Multi Round-Trip Requests (MRTR) over the 2026-07-28 protocol revision. +/// Under that revision (SEP-2575 + SEP-2567) Streamable HTTP no longer supports sessions, so these tests +/// drive the default server with raw +/// JSON-RPC requests (no initialize, no Mcp-Session-Id) and verify the explicit /// MRTR structure, retry with inputResponses, and error handling. /// Stateful-session MRTR behaviors (implicit handler suspension, disposal cancellation) are covered /// over stdio by MrtrHandlerLifecycleTests, and unknown-session rejection by @@ -24,7 +24,6 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private WebApplication? _app; @@ -143,12 +142,12 @@ static CallToolResult (RequestContext context) => _app.MapMcp(); await _app.StartAsync(TestContext.Current.CancellationToken); - // Drive the server with sessionless draft requests: every request carries the draft + // Drive the server with raw requests: every request carries the 2026-07-28 protocol // MCP-Protocol-Version header and (via PostJsonRpcAsync) the SEP-2243 Mcp-Method/Mcp-Name // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", DraftVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); } public async ValueTask DisposeAsync() @@ -303,7 +302,7 @@ static string (RequestContext context) => HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - // Initialize with the current (non-draft) protocol so the server's backcompat resolver runs. + // Initialize with the current legacy protocol so the server's backcompat resolver runs. var initJson = """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} """; diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 4ec8ba4d9..050bd428b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -15,11 +15,10 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// Wire-format conformance tests for the Streamable HTTP server driven directly via , /// without going through . These hand-craft HTTP /// requests and assert the exact status codes / response bodies the server emits for the SEP-2575 + -/// SEP-2567 (sessionless, no-initialize) draft revision. +/// SEP-2567 2026-07-28 protocol revision. /// public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private const string ProtocolVersionHeader = "MCP-Protocol-Version"; private WebApplication? _app; @@ -83,22 +82,22 @@ private static async Task ReadJsonResponseAsync(HttpResponseMessage re return JsonNode.Parse(body)!; } - private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; [Fact] - public async Task DraftToolsCall_WithFullMeta_Succeeds_200() + public async Task July2026ToolsCall_WithFullMeta_Succeeds_200() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + - DraftMetaFragment() + "}}"; + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -107,7 +106,8 @@ public async Task DraftToolsCall_WithFullMeta_Succeeds_200() var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal("echo:hi", json["result"]!["content"]![0]!["text"]!.GetValue()); - // Per SEP-2567 draft is sessionless: server MUST NOT issue a Mcp-Session-Id. + // 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. Assert.False(response.Headers.Contains("mcp-session-id")); } @@ -116,17 +116,17 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(DraftVersion, supported); + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. @@ -136,13 +136,13 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() } [Fact] - public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32004() + public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022() { await StartAsync(); var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + - DraftMetaFragment("9999-99-99") + "}}"; + July2026ProtocolMetaFragment("9999-99-99") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; request.Headers.Add(ProtocolVersionHeader, "9999-99-99"); @@ -150,7 +150,7 @@ public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); - // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32004 and a data payload + // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload // listing the supported versions. The dual-era client uses this to switch versions without fallback. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); @@ -160,25 +160,25 @@ public async Task DraftPost_WithUnsupportedProtocolVersionHeader_Returns400_With Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(DraftVersion, supported); + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); } [Fact] - public async Task DraftPost_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32001() + public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); - // The MCP-Protocol-Version header declares the draft revision, but the per-request _meta declares a + // The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a // different (still individually supported) version. Per SEP-2575 the server MUST reject the - // disagreement. It uses -32001 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body - // checks) so a conformant draft client surfaces the error instead of mistaking the modern server for a + // disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body + // checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a // legacy one and falling back to the initialize handshake. var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + - DraftMetaFragment("2025-11-25") + "}}"; + July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, DraftVersion); + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 501d698a6..807daaefe 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -13,7 +13,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// Verifies that aborting an HTTP request flows cancellation into the running request handler's /// . /// -/// Under the draft protocol revision (SEP-2575 + SEP-2567) the HTTP request lifetime is the +/// Starting with the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) the HTTP request lifetime is the /// request lifetime: there are no sessions, so a dropped connection is equivalent to cancelling the /// in-flight request. The same holds for legacy stateless mode, where each request is independent and /// outlived by nothing. These tests pin that behavior so a tool's fires @@ -22,7 +22,6 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// public class RequestAbortCancellationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private WebApplication? _app; @@ -84,13 +83,14 @@ public async ValueTask DisposeAsync() } [Fact] - public async Task DraftSessionlessRequest_AbortFlowsCancellationToToolHandler() + public async Task July2026Request_AbortFlowsCancellationToToolHandler() { - // Draft is sessionless (SEP-2567) and is served natively only on a stateless server; a - // Stateless=false server refuses sessionless draft so dual-era clients fall back to initialize. + // 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. await StartAsync(stateless: true); - using var request = CreateBlockingToolRequest(draft: true); + using var request = CreateBlockingToolRequest(july2026Protocol: true); await AssertAbortCancelsToolAsync(request); } @@ -100,19 +100,19 @@ public async Task StatelessRequest_AbortFlowsCancellationToToolHandler() { await StartAsync(stateless: true); - using var request = CreateBlockingToolRequest(draft: false); + using var request = CreateBlockingToolRequest(july2026Protocol: false); await AssertAbortCancelsToolAsync(request); } - private static HttpRequestMessage CreateBlockingToolRequest(bool draft) + private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protocol) { - // Draft tools/call requires the SEP-2243 Mcp-Method/Mcp-Name headers and the per-request _meta + // A 2026-07-28 tools/call requires the SEP-2243 Mcp-Method/Mcp-Name headers and the per-request _meta // (protocol version, client info, capabilities) that replaces the initialize handshake (SEP-2567). - var body = draft + var body = july2026Protocol ? """ - {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"DRAFT_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} - """.Replace("DRAFT_VERSION", DraftVersion) + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """.Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion) : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; var request = new HttpRequestMessage(HttpMethod.Post, "") @@ -123,9 +123,9 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool draft) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); - if (draft) + if (july2026Protocol) { - request.Headers.Add("MCP-Protocol-Version", DraftVersion); + request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "blockingTool"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs index c79207c2f..29e69483e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ResumabilityIntegrationTestsBase.cs @@ -517,9 +517,9 @@ protected async Task ConnectClientAsync() TransportMode = HttpTransportMode.StreamableHttp, }, HttpClient, LoggerFactory); - // Resumability (Last-Event-ID) and Mcp-Session-Id are removed in the draft revision - // (SEP-2567). Pin the client to the latest stable version so it negotiates the stateful, - // resumable legacy handshake instead of the sessionless draft default. + // Resumability (Last-Event-ID) and Mcp-Session-Id are removed in the 2026-07-28 protocol + // revision (SEP-2567). Pin the client to the latest stable version so it negotiates the stateful, + // resumable legacy handshake instead of the 2026-07-28 default. return await McpClient.CreateAsync(transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index c0990737e..82fb9c020 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -88,13 +88,46 @@ public async ValueTask DisposeAsync() } } +/// +/// Shared fixture that starts a single stateless ConformanceServer for the +/// SEP-2322 MRTR scenarios in . Those scenarios negotiate the +/// 2026-07-28 revision, which is served only on a stateless server, so they +/// cannot reuse the stateful . Reusing one server across all +/// the MRTR theory rows avoids the TCP TIME_WAIT conflicts that per-test restarts on a single port +/// cause on Windows. Uses a dedicated port range (303x) so it runs in parallel with the stateful +/// fixture (300x), the caching server (301x), and the SEP-2243 servers (302x) without colliding. +/// +public sealed class StatelessMrtrConformanceServerFixture : IAsyncLifetime +{ + private StatelessConformanceServer? _server; + + public string ServerUrl => _server?.ServerUrl + ?? throw new InvalidOperationException("The stateless conformance server has not been started."); + + public async ValueTask InitializeAsync() + { + _server = await StatelessConformanceServer.StartAsync(CancellationToken.None, basePort: 3031); + } + + public async ValueTask DisposeAsync() + { + if (_server is not null) + { + await _server.DisposeAsync(); + } + } +} + /// /// Runs the official MCP conformance tests against the ConformanceServer. /// Uses a shared so the server is started once /// and reused across all tests, avoiding TCP port conflicts on Windows. /// -public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output) - : IClassFixture +public class ServerConformanceTests( + ConformanceServerFixture fixture, + StatelessMrtrConformanceServerFixture statelessFixture, + ITestOutputHelper output) + : IClassFixture, IClassFixture { [Fact] public async Task RunConformanceTests() @@ -143,8 +176,8 @@ public async Task RunConformanceTest_HttpHeaderValidation() !NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); - // SEP-2243 is a draft (2026-07-28) scenario that uses the stateless lifecycle, so it - // requires a stateless server (a stateful server rejects the un-initialized list/call + // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless + // lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call // requests with JSON-RPC -32000). Use a dedicated port range so it never collides with // the stateful class fixture (300x) or the caching stateless server (301x). await using var server = await StatelessConformanceServer.StartAsync( @@ -180,18 +213,20 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts // (the class names predate the conformance-suite rename from "incomplete-result-*" to // "input-required-result-*"; the wire-level tool names now match the new convention). - // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28 - // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the - // installed conformance package ships SEP-2322 scenarios and emits this SDK's - // draft wire string (see ). + // Each scenario uses the conformance harness's RawMcpSession, which negotiates 2026-07-28, + // so the csharp-sdk emits InputRequiredResult on the wire. Because the 2026-07-28 revision is + // served only on a stateless server, the scenarios run against a dedicated stateless server + // (StatelessMrtrConformanceServerFixture); a stateful server refuses these requests. + // These tests skip until the installed conformance package ships SEP-2322 scenarios + // (see ). // // input-required-result-tampered-state and input-required-result-capability-check are // implemented by ConformanceServer.Tools.IncompleteResultTools.ToolWithTamperedState // (HMAC-protected requestState; a tampered requestState surfaces a -32602 JSON-RPC error) // and ToolWithCapabilityCheck (gates inputRequests on the per-request // _meta clientCapabilities envelope). Both behaviors also have in-process wire-level - // regression coverage in MrtrProtocolTests so they stay verified even while the published - // conformance package's draft wire string lags this SDK. + // regression coverage in MrtrProtocolTests so they stay verified independent of the + // published conformance package. [Theory] [InlineData("input-required-result-basic-elicitation")] [InlineData("input-required-result-basic-sampling")] @@ -210,10 +245,10 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package (or installed version uses a stale draft wire string)."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); - var result = await RunConformanceTestsAsync( - $"server --url {fixture.ServerUrl} --scenario {scenario}"); + var result = await RunStatelessConformanceTestAsync( + $"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28"); Assert.True(result.Success, $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); @@ -227,7 +262,7 @@ public async Task RunMrtrConformanceTest(string scenario) cancellationToken: TestContext.Current.CancellationToken); } - // For draft scenarios that pin --spec-version explicitly, suppress the + // For 2026-07-28 protocol scenarios that pin --spec-version explicitly, suppress the // MCP_CONFORMANCE_PROTOCOL_VERSION override so a duplicate --spec-version is not appended. private async Task<(bool Success, string Output, string Error)> RunStatelessConformanceTestAsync(string arguments) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs index 849941d8e..57b12d246 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpClientConformanceTests.cs @@ -20,7 +20,7 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper private WebApplication? _app; private readonly List _deleteRequestSessionIds = []; - // Don't add the delete endpoint by default to ensure the client still works with basic sessionless servers. + // Don't add the delete endpoint by default to ensure the client still works with basic stateless servers. private async Task StartAsync(bool enableDelete = false) { Builder.Services.Configure(options => @@ -128,7 +128,7 @@ private async Task StartResumeServerAsync(string expectedSessi } [Fact] - public async Task CanCallToolOnSessionlessStreamableHttpServer() + public async Task CanCallToolOnStatelessStreamableHttpServer() { await StartAsync(); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index d3a3681dd..1ff58f978 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -916,13 +916,13 @@ public async Task McpServer_UsedOutOfScope_CanSendNotifications() #region SEP-2243 Header Validation Tests [Fact] - public async Task DraftVersion_RejectsMissingMcpMethodHeader() + public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() { - // Draft is sessionless and served only on a stateless server (SEP-2567). + // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server. await StartAsync(stateless: true); - // Initialize with draft version to enable header validation - await CallInitializeWithDraftVersionAndValidateAsync(); + // Initialize with the 2026-07-28 protocol version to enable header validation + await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); @@ -935,10 +935,10 @@ public async Task DraftVersion_RejectsMissingMcpMethodHeader() } [Fact] - public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() + public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithDraftVersionAndValidateAsync(); + await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); @@ -951,10 +951,10 @@ public async Task DraftVersion_RejectsMismatchedMcpMethodHeader() } [Fact] - public async Task DraftVersion_AcceptsCorrectMcpMethodHeader() + public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithDraftVersionAndValidateAsync(); + await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); @@ -968,12 +968,12 @@ public async Task DraftVersion_AcceptsCorrectMcpMethodHeader() } [Fact] - public async Task NonDraftVersion_DoesNotRequireMcpMethodHeader() + public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() { await StartAsync(); await CallInitializeAndValidateAsync(); - // With non-draft version, Mcp-Method header is not required + // With the legacy version, Mcp-Method header is not required using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); request.Headers.Add("MCP-Protocol-Version", "2025-03-26"); @@ -983,12 +983,12 @@ public async Task NonDraftVersion_DoesNotRequireMcpMethodHeader() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task CallInitializeWithDraftVersionAndValidateAsync() + private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestDraft); + request.Content = JsonContent(InitializeRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "initialize"); @@ -996,11 +996,11 @@ private async Task CallInitializeWithDraftVersionAndValidateAsync() var rpcResponse = await AssertSingleSseResponseAsync(response); AssertServerInfo(rpcResponse); - // Draft protocol revision (SEP-2567) is sessionless; the server does not return mcp-session-id. - // Subsequent requests carry MCP-Protocol-Version=2026-07-28 to opt back into the draft path. + // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id. + // Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently. } - private static string InitializeRequestDraft => """ + private static string InitializeRequestJuly2026Protocol => """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} """; diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 342cf9743..13af5d170 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -36,11 +36,12 @@ }, }; -// The default client now prefers the draft revision (probing with server/discover and falling back -// to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios specifically exercise -// the legacy initialize handshake and SSE resumability (removed in draft) and strictly expect -// initialize as the first message, so pin them to the latest stable version. Other scenarios run on -// the draft default and exercise the server/discover probe plus the transparent legacy fallback. +// The default client now prefers the 2026-07-28 protocol (probing with server/discover and +// falling back to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios +// specifically exercise the legacy initialize handshake and SSE resumability (removed in the +// 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the +// latest stable version. Other scenarios run on the 2026-07-28 default and exercise the +// server/discover probe plus the transparent legacy fallback. if (scenario is "initialize" or "sse-retry") { options.ProtocolVersion = "2025-11-25"; diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 9416ec8db..b1de1aa98 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -25,8 +25,8 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide // because .NET does not have a built-in concurrent HashSet ConcurrentDictionary> subscriptions = new(); - // Allow running the server in the SEP-2575 stateless lifecycle, which the draft - // "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" + // Allow running the server in the SEP-2575 stateless lifecycle, which the 2026-07-28 + // protocol's "caching" (SEP-2549) conformance scenario requires. A "--stateless true|false" // command-line switch (read via configuration) takes precedence so an in-process test // fixture can opt in or out per-instance deterministically; when it is not supplied, // fall back to the MCP_CONFORMANCE_STATELESS environment variable for standalone runs. diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index f434c6e01..f93b6ab2b 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -428,7 +428,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide .WithHttpTransport(options => { // The test fixture exercises legacy stateful behaviors (SSE + session-id flows). - // Set Stateless = false explicitly now that draft (SEP-2567) defaults to true. + // Set Stateless = false explicitly now that the 2026-07-28 protocol (SEP-2567) defaults to true. options.Stateless = false; options.EnableLegacySse = true; }); diff --git a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs similarity index 65% rename from tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs rename to tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index 659bfa4b0..a06548a4f 100644 --- a/tests/ModelContextProtocol.Tests/Client/DraftConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -7,17 +7,16 @@ namespace ModelContextProtocol.Tests.Client; /// -/// Tests for the draft protocol revision (SEP-2575 + SEP-2567) connection flow on -/// — the client should call server/discover instead of -/// initialize when is set to -/// . +/// Connection-flow tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) +/// on . A client that requests +/// calls server/discover rather than +/// initialize. /// -public class DraftConnectionTests : ClientServerTestBase +public class July2026ProtocolConnectionTests : ClientServerTestBase { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private const string LatestStableVersion = "2025-11-25"; - public DraftConnectionTests(ITestOutputHelper testOutputHelper) + public July2026ProtocolConnectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { } @@ -26,31 +25,31 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ServerInfo = new Implementation { Name = nameof(DraftConnectionTests), Version = "1.0" }; + options.ServerInfo = new Implementation { Name = nameof(July2026ProtocolConnectionTests), Version = "1.0" }; }); } [Fact] - public async Task DraftClient_ConnectingToDraftServer_NegotiatesDraftVersion() + public async Task Client_RequestingJuly2026Protocol_NegotiatesIt() { StartServer(); - var options = new McpClientOptions { ProtocolVersion = DraftVersion }; + var options = new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }; await using var client = await CreateMcpClientForServer(options); - Assert.Equal(DraftVersion, client.NegotiatedProtocolVersion); + Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); Assert.NotNull(client.ServerCapabilities); - Assert.Equal(nameof(DraftConnectionTests), client.ServerInfo.Name); + Assert.Equal(nameof(July2026ProtocolConnectionTests), client.ServerInfo.Name); } [Fact] - public async Task LegacyClient_ConnectingToDraftServer_NegotiatesLegacyVersion() + public async Task Client_RequestingLegacyVersion_NegotiatesLegacy() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - Assert.NotEqual(DraftVersion, client.NegotiatedProtocolVersion); + Assert.NotEqual(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] @@ -70,11 +69,11 @@ public async Task LegacyClient_CanCallServerDiscover() Assert.NotNull(discoverResult); Assert.NotEmpty(discoverResult.SupportedVersions); Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); - Assert.Equal(nameof(DraftConnectionTests), discoverResult.ServerInfo.Name); + Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name); } [Fact] - public async Task DraftServer_DiscoverIncludesDraftVersion() + public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() { StartServer(); @@ -86,6 +85,6 @@ public async Task DraftServer_DiscoverIncludesDraftVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); - Assert.Contains(DraftVersion, discoverResult.SupportedVersions); + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs similarity index 81% rename from tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs rename to tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index ee624ede0..f2bc24cb5 100644 --- a/tests/ModelContextProtocol.Tests/Client/DraftProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -8,34 +8,29 @@ namespace ModelContextProtocol.Tests.Client; /// -/// Regression tests for the draft-protocol-to-legacy fallback path in -/// . These verify that a client configured with -/// McpClientOptions.ProtocolVersion = McpSession.DraftProtocolVersion -/// correctly probes for a draft-aware server with server/discover, falls -/// back to the legacy initialize handshake when the server is legacy, -/// and accepts whatever supported protocol version the legacy server -/// negotiates - including a version different from the one the client -/// originally requested. +/// Regression tests for the fallback from the 2026-07-28 protocol revision to a legacy protocol in +/// . With default options (ProtocolVersion = null) the client prefers +/// 2026-07-28 but probes with server/discover, falls back to the legacy initialize +/// handshake when the server is legacy, and accepts whatever supported protocol version the legacy +/// server negotiates. Pinning ProtocolVersion to 2026-07-28 instead makes it the +/// minimum too, so the client refuses to fall back. /// /// -/// The originally shipped logic in PerformLegacyInitializeAsync compared -/// the server's response against _options.ProtocolVersion, which under -/// draft is "2026-07-28". When the legacy server downgraded to (say) -/// "2025-06-18", the comparison threw, even though the legacy -/// negotiation succeeded. These tests guard against that regression. +/// The originally shipped logic in PerformLegacyInitializeAsync compared the server's response +/// against the requested version and threw when a legacy server downgraded to (say) "2025-06-18", +/// even though the legacy negotiation succeeded. These tests guard against that regression. /// -public class DraftProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { [Fact] - public async Task DraftClient_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() + public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { var ct = TestContext.Current.CancellationToken; await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); - await using var client = await McpClient.CreateAsync(transport, new McpClientOptions - { - ProtocolVersion = McpSession.DraftProtocolVersion, - }, loggerFactory: LoggerFactory, cancellationToken: ct); + // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.ServerDiscoverProbed); Assert.True(transport.LegacyInitializeReceived); @@ -43,24 +38,23 @@ public async Task DraftClient_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDow } [Fact] - public async Task DraftClient_OnInvalidParams_FallsBackTo_Initialize() + public async Task Client_OnInvalidParams_FallsBackTo_Initialize() { var ct = TestContext.Current.CancellationToken; await using var transport = new LegacyServerTestTransport( serverNegotiatedVersion: "2025-11-25", probeErrorCode: (int)McpErrorCode.InvalidParams); - await using var client = await McpClient.CreateAsync(transport, new McpClientOptions - { - ProtocolVersion = McpSession.DraftProtocolVersion, - }, loggerFactory: LoggerFactory, cancellationToken: ct); + // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.LegacyInitializeReceived); Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); } [Fact] - public async Task DraftClient_WithMinProtocolVersion_RefusesFallback_BelowMinimum() + public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer() { var ct = TestContext.Current.CancellationToken; await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); @@ -69,13 +63,13 @@ public async Task DraftClient_WithMinProtocolVersion_RefusesFallback_BelowMinimu { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpSession.DraftProtocolVersion, - MinProtocolVersion = McpSession.DraftProtocolVersion, + // Pinning the version makes it the minimum too, so the client refuses to fall back. + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.IsType(exception); - Assert.Contains("minimum", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("2026-07-28", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -98,9 +92,9 @@ public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() } [Fact] - public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback() + public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() { - // The peer is modern (returns the spec-defined -32001 HeaderMismatch on the probe). + // The peer is modern (returns the spec-defined -32020 HeaderMismatch on the probe). // Falling back to legacy initialize would just produce another malformed envelope. // Verify the connect-time logic surfaces the error to the caller instead of falling back. var ct = TestContext.Current.CancellationToken; @@ -112,7 +106,7 @@ public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback() { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpSession.DraftProtocolVersion, + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -122,7 +116,7 @@ public async Task DraftClient_OnHeaderMismatch_Surfaces_NoFallback() } [Fact] - public async Task DraftClient_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() + public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() { // Simulate a legacy server that silently drops the unknown server/discover method (it never // responds to the probe). The client must fall back to legacy initialize once the configured @@ -133,9 +127,9 @@ public async Task DraftClient_OnSilentProbe_FallsBackTo_Initialize_AfterConfigur silentDiscoverProbe: true); var stopwatch = Stopwatch.StartNew(); + // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpSession.DraftProtocolVersion, DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250), InitializationTimeout = TestConstants.DefaultTimeout, }, loggerFactory: LoggerFactory, cancellationToken: ct); diff --git a/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs similarity index 70% rename from tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs rename to tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs index 71215cdad..19ff40287 100644 --- a/tests/ModelContextProtocol.Tests/Client/DraftListMetaEmissionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs @@ -9,30 +9,29 @@ namespace ModelContextProtocol.Tests.Client; /// /// Verifies that the C# client emits the SEP-2575 _meta envelope on every list-style -/// request (and on server/discover) under the draft protocol revision, even when the -/// caller supplies no RequestOptions / no params. +/// request (and on server/discover) under the 2026-07-28 protocol revision, even when +/// the caller supplies no RequestOptions / no params. /// /// /// Spec PR #2759 promotes params._meta to required on tools/list, /// resources/list, resources/templates/list, prompts/list, and -/// server/discover under draft. This test class drives the C# client through -/// with the draft revision negotiated, attaches a request +/// server/discover. This test class drives the C# client through +/// with the 2026-07-28 protocol negotiated, attaches a request /// filter on each list endpoint that captures the incoming _meta envelope, and asserts /// the three required SEP-2575 keys are present: /// io.modelcontextprotocol/protocolVersion, /// io.modelcontextprotocol/clientInfo, and /// io.modelcontextprotocol/clientCapabilities. /// -public class DraftListMetaEmissionTests : ClientServerTestBase +public class July2026ProtocolListMetaEmissionTests : ClientServerTestBase { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private const string LatestStableVersion = "2025-11-25"; // Captured _meta envelopes for each request method we exercise. Populated by the per-method // server-side filters and asserted from each test method. private readonly Dictionary _capturedMeta = new(StringComparer.Ordinal); - public DraftListMetaEmissionTests(ITestOutputHelper testOutputHelper) + public July2026ProtocolListMetaEmissionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { } @@ -74,62 +73,62 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task DraftClient_ListTools_NoOptions_EmitsRequiredMeta() + public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - AssertDraftMetaPresent(RequestMethods.ToolsList); + AssertRequiredMetaPresent(RequestMethods.ToolsList); } [Fact] - public async Task DraftClient_ListPrompts_NoOptions_EmitsRequiredMeta() + public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); - AssertDraftMetaPresent(RequestMethods.PromptsList); + AssertRequiredMetaPresent(RequestMethods.PromptsList); } [Fact] - public async Task DraftClient_ListResources_NoOptions_EmitsRequiredMeta() + public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); - AssertDraftMetaPresent(RequestMethods.ResourcesList); + AssertRequiredMetaPresent(RequestMethods.ResourcesList); } [Fact] - public async Task DraftClient_ListResourceTemplates_NoOptions_EmitsRequiredMeta() + public async Task Client_ListResourceTemplates_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); - AssertDraftMetaPresent(RequestMethods.ResourcesTemplatesList); + AssertRequiredMetaPresent(RequestMethods.ResourcesTemplatesList); } [Fact] - public async Task DraftClient_ServerDiscover_EmitsRequiredMeta() + public async Task Client_ServerDiscover_EmitsRequiredMeta() { // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, - // which still flows through the client's draft-meta injector. + // which still flows through the client's per-request _meta injector. StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = DraftVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); // Hook the server-side handler invocation via a notification handler is awkward here; assert // instead by sending the request and parsing the wire-shape echo from the response context. - // Easier path: rely on the existing JsonRpcRequest capture in the message context — see the - // raw conformance tests for the wire-level proof. For this in-process test, we instead drive - // the request and rely on the response being a valid DiscoverResult; the draft meta injector + // Easier path: rely on the existing JsonRpcRequest capture in the message context (see the + // raw conformance tests for the wire-level proof). For this in-process test, we instead drive + // the request and rely on the response being a valid DiscoverResult; the _meta injector // would otherwise have failed the server's per-request envelope validation. var response = await client.SendRequestAsync( new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, @@ -137,19 +136,19 @@ public async Task DraftClient_ServerDiscover_EmitsRequiredMeta() Assert.NotNull(response.Result); var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; - Assert.Contains(DraftVersion, discover.SupportedVersions); + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discover.SupportedVersions); - // The server enforces draft envelope shape per request; if the client had omitted _meta, the - // request would have failed with -32602 / -32003 rather than returning a DiscoverResult. The + // The server enforces the per-request envelope shape; if the client had omitted _meta, the + // request would have failed with -32602 / -32021 rather than returning a DiscoverResult. The // successful round-trip is the assertion. } [Fact] - public async Task LegacyClient_ListTools_DoesNotEmitDraftMeta() + public async Task LegacyClient_ListTools_DoesNotEmitMeta() { - // Sanity guard: the legacy (non-draft) client must NOT emit the SEP-2575 envelope — the meta - // injector is gated on the negotiated protocol version. If this ever started writing draft keys - // under legacy protocols, every legacy server would reject the request. + // Sanity guard: a client on the session-supporting (legacy) protocol must NOT emit the SEP-2575 + // envelope. The injector is gated on the negotiated protocol version; if it ever started writing + // those keys on a legacy request, every legacy server would reject it. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); @@ -164,7 +163,7 @@ public async Task LegacyClient_ListTools_DoesNotEmitDraftMeta() } } - private void AssertDraftMetaPresent(string method) + private void AssertRequiredMetaPresent(string method) { Assert.True(_capturedMeta.TryGetValue(method, out var meta), $"No capture for {method}"); Assert.NotNull(meta); @@ -175,7 +174,7 @@ private void AssertDraftMetaPresent(string method) Assert.True(meta.ContainsKey(MetaKeys.ClientCapabilities), $"Missing clientCapabilities key on {method} _meta envelope"); - // The protocolVersion value must match the negotiated draft version. - Assert.Equal(DraftVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + // The protocolVersion value must match the negotiated 2026-07-28 protocol version. + Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 1a0785630..42af028b2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -178,7 +178,7 @@ public virtual Task SendMessageAsync(JsonRpcMessage message, CancellationToken c Result = JsonSerializer.SerializeToNode(new DiscoverResult { Capabilities = new ServerCapabilities(), - SupportedVersions = [McpSession.DraftProtocolVersion], + SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], ServerInfo = new Implementation { Name = "NopTransport", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 18cd5e232..e1e9a08db 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Client; public class McpClientMetaTests : ClientServerTestBase { - // InitializeMeta is carried on the legacy initialize request, which the draft revision removes. + // InitializeMeta is carried on the legacy initialize request, which the 2026-07-28 protocol removes. // The two InitializeMeta_* tests pin to the latest stable version so the handshake actually runs. private const string LatestStableVersion = "2025-11-25"; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index e3d90bced..4dda7bc38 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -584,7 +584,7 @@ public async Task AsClientLoggerProvider_MessagesSentToClient() public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) { await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = protocolVersion }); - // A null ProtocolVersion now prefers the draft revision, which the reactive test server advertises. + // A null ProtocolVersion now prefers the 2026-07-28 protocol, which the reactive test server advertises. Assert.Equal(protocolVersion ?? "2026-07-28", client.NegotiatedProtocolVersion); } @@ -795,7 +795,7 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task ServerCanPingClient() { - // ping is a legacy-only RPC (removed in the draft revision per SEP-2575), so pin the client + // ping is a legacy-only RPC (removed in the 2026-07-28 protocol per SEP-2575), so pin the client // to a legacy protocol version to exercise the server-initiated ping round-trip. await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 3dd944ce5..5868c3c63 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -18,7 +18,7 @@ public void McpHttpHeaders_HasCorrectValues() [Fact] public void McpErrorCode_HeaderMismatch_HasCorrectValue() { - Assert.Equal(-32001, (int)McpErrorCode.HeaderMismatch); + Assert.Equal(-32020, (int)McpErrorCode.HeaderMismatch); } [Theory] diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index b22bcffc3..70a9d1112 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -165,7 +165,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() Model = "test-model" }); - // Start the client task — it will send server/discover (draft) and block waiting for response + // Start the client task. It will send server/discover (2026-07-28 protocol) and block waiting for response var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -180,7 +180,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - // Read the server/discover request from client (draft revision skips initialize per SEP-2575). + // Read the server/discover request from client (the 2026-07-28 protocol skips initialize per SEP-2575). var discoverLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); Assert.NotNull(discoverLine); var discoverRequest = JsonSerializer.Deserialize(discoverLine, McpJsonUtilities.DefaultOptions); @@ -200,7 +200,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() }; await WriteJsonRpcAsync(serverWriter, discoverResponse); - // Client is now connected with MRTR negotiated (no initialized notification under draft). + // Client is now connected with MRTR negotiated (no initialized notification under the 2026-07-28 protocol). await using var client = await clientTask; Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); @@ -406,7 +406,7 @@ public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFro var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Pin to the draft revision so the client performs the server/discover handshake and + // Pin to the 2026-07-28 protocol so the client performs the server/discover handshake and // treats InputRequiredResult as an MRTR round-trip. var clientOptions = new McpClientOptions { ProtocolVersion = "2026-07-28" }; clientOptions.Handlers.ElicitationHandler = (_, _) => diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 3d62f8636..7690a75f9 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,8 +33,8 @@ public async Task ConnectAndPing_Stdio(string clientId) // Arrange // Act - // ping was removed in the draft revision (SEP-2575), so pin to the latest stable - // protocol version to keep exercising the legacy ping RPC. Draft liveness relies on + // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest stable + // protocol version to keep exercising the legacy ping RPC. The 2026-07-28 protocol relies on // the transport/request lifecycle instead of an explicit ping. await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs index 3b20f39c2..45cf0379c 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsMessageFilterTests.cs @@ -74,7 +74,7 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() { List messageTypes = []; - // Under the draft protocol the client performs a server/discover + tools/list exchange (no + // Under the 2026-07-28 protocol the client performs a server/discover + tools/list exchange (no // fire-and-forget initialized notification), so the tools/list request is a deterministic // synchronization point. Gate recording to it and signal once the filter finishes so a // regression that invokes the filter pipeline more than once per message surfaces as an extra entry. @@ -112,7 +112,7 @@ public async Task AddIncomingMessageFilter_Intercepts_Request_Messages() [Fact] public async Task AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order() { - // Under the draft protocol the client performs a server/discover + tools/list exchange (no + // Under the 2026-07-28 protocol the client performs a server/discover + tools/list exchange (no // fire-and-forget initialized notification), so the tools/list request is a deterministic // synchronization point. Gate the filter logging to it and signal once the outermost filter // finishes so the assertions observe a complete, stable log. @@ -393,7 +393,7 @@ public async Task AddOutgoingMessageFilter_Sees_Responses_Notifications_And_Requ var clientOptions = new McpClientOptions { // This test observes the legacy outgoing flow on the server side: the initialize response and - // the server->client sampling/createMessage request. Under the draft protocol those are replaced + // the server->client sampling/createMessage request. Under the 2026-07-28 protocol those are replaced // by server/discover and implicit MRTR (InputRequiredResult), which is covered by MrtrIntegrationTests. // Pin to the latest stable version to keep exercising the legacy server->client request path here. ProtocolVersion = LatestStableVersion, diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 795b380a4..4182957cf 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -128,12 +128,12 @@ public async Task Can_List_And_Call_Registered_Prompts() [Fact] public async Task Can_Be_Notified_Of_Prompt_Changes() { - // Under the draft revision, list-changed notifications are delivered only over a + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpSession.LatestProtocolVersion, + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, }); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 5630d2b77..d8fd0a231 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -162,12 +162,12 @@ public async Task Can_List_And_Call_Registered_ResourceTemplates() [Fact] public async Task Can_Be_Notified_Of_Resource_Changes() { - // Under the draft revision, list-changed notifications are delivered only over a + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpSession.LatestProtocolVersion, + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, }); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 79eace963..5359ec73c 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -188,12 +188,12 @@ public async Task Can_Create_Multiple_Servers_From_Options_And_List_Registered_T [Fact] public async Task Can_Be_Notified_Of_Tool_Changes() { - // Under the draft revision, list-changed notifications are delivered only over a + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpSession.LatestProtocolVersion, + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 9a78045b9..3c070130c 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -59,7 +59,7 @@ private async Task AssertMatchAsync( /// /// Asserts that the given URI does NOT match the template. Uses the default client, which - /// negotiates the draft protocol revision, so the unknown-resource response carries the + /// negotiates the 2026-07-28 protocol revision, so the unknown-resource response carries the /// standard JSON-RPC (-32602). The version-gated /// legacy mapping to (-32002) is covered by /// . @@ -79,7 +79,7 @@ private async Task AssertNoMatchAsync( } // Unknown-resource-URI responses are version-gated: older clients keep the legacy - // -32002 (McpErrorCode.ResourceNotFound), and clients on the draft protocol version that + // -32002 (McpErrorCode.ResourceNotFound), and clients on the 2026-07-28 protocol version that // moves to the standard JSON-RPC code see -32602 (McpErrorCode.InvalidParams). [Theory] [InlineData("2025-11-25", McpErrorCode.ResourceNotFound)] @@ -134,7 +134,7 @@ public async Task MultipleTemplatedResources_MatchesCorrectResource() // Literal template braces in URI should not match (template literal is not a valid URI) var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken)); - // Draft maps an unmatched resource URI to InvalidParams (-32602); the legacy -32002 ResourceNotFound + // The 2026-07-28 protocol maps an unmatched resource URI to InvalidParams (-32602); the legacy -32002 ResourceNotFound // mapping is covered by the version-gated ResourceNotFound_ErrorCode_IsVersionGated theory. Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode); Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message); diff --git a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs index acaa1b9ae..db6c2c2c4 100644 --- a/tests/ModelContextProtocol.Tests/DiagnosticTests.cs +++ b/tests/ModelContextProtocol.Tests/DiagnosticTests.cs @@ -92,7 +92,7 @@ await WaitForAsync( Assert.Equal(clientListToolsCall.TraceId, serverListToolsCall.TraceId); // Validate that the client trace context encoded to request.params._meta[traceparent]. - // Under the draft revision _meta also carries the per-request envelope (protocolVersion, + // Under the 2026-07-28 protocol _meta also carries the per-request envelope (protocolVersion, // clientInfo, clientCapabilities), so assert on the traceparent property specifically // rather than the entire _meta object. using var listToolsJson = JsonDocument.Parse(clientToServerLog.First(s => s.Contains("\"method\":\"tools/list\""))); diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs index 1e32062da..71afd8980 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverProtocolTests.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Tests.Protocol; /// -/// Serialization tests for the request/result types introduced by the draft protocol revision (SEP-2575). +/// Serialization tests for the request/result types introduced by the 2026-07-28 protocol revision (SEP-2575). /// public static class DiscoverProtocolTests { diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs index 001c05f95..248bf7768 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -17,7 +17,7 @@ public static class DiscoverResultCacheableTests { private static DiscoverResult NewDiscoverResult() => new() { - SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion], + SupportedVersions = [McpHttpHeaders.November2025ProtocolVersion, McpHttpHeaders.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs index ab172aa1e..c23d34bf6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/JsonRpcMessageConverterTests.cs @@ -766,7 +766,7 @@ public static void Deserialize_ErrorWithNullId_IsValid() { // Per JSON-RPC 2.0 §5.1, when an error occurs before the request id can be determined // (parse error or invalid request), the server MUST respond with id=null. This shape is - // produced by some peers (e.g. Python's simple-streamablehttp-stateless on a draft probe) + // produced by some peers (e.g. Python's simple-streamablehttp-stateless on a 2026-07-28 probe) // and must be accepted so the HTTP-fallback path can recognize the structured signal. string json = """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request"}}"""; diff --git a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs b/tests/ModelContextProtocol.Tests/Protocol/July2026ProtocolErrorDataTests.cs similarity index 96% rename from tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs rename to tests/ModelContextProtocol.Tests/Protocol/July2026ProtocolErrorDataTests.cs index fd82f9e0b..88e3e5644 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DraftErrorDataTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/July2026ProtocolErrorDataTests.cs @@ -4,9 +4,9 @@ namespace ModelContextProtocol.Tests.Protocol; /// -/// Serialization tests for the error data payloads introduced by the draft protocol revision (SEP-2575). +/// Serialization tests for the error data payloads introduced by the 2026-07-28 protocol revision (SEP-2575). /// -public static class DraftErrorDataTests +public static class July2026ProtocolErrorDataTests { [Fact] public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_PreservesAllProperties() diff --git a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs index c2d48b888..65c26e035 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/SubscriptionsListenProtocolTests.cs @@ -5,7 +5,7 @@ namespace ModelContextProtocol.Tests.Protocol; /// -/// Serialization tests for the subscriptions/listen types introduced by the draft protocol revision (SEP-2575). +/// Serialization tests for the subscriptions/listen types introduced by the 2026-07-28 protocol revision (SEP-2575). /// public static class SubscriptionsListenProtocolTests { diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/July2026ProtocolBackcompatTests.cs similarity index 88% rename from tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs rename to tests/ModelContextProtocol.Tests/Server/July2026ProtocolBackcompatTests.cs index ca6134a24..4d78d9435 100644 --- a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/July2026ProtocolBackcompatTests.cs @@ -9,7 +9,7 @@ namespace ModelContextProtocol.Tests.Server; /// Verifies that the server-to-client request methods (, /// , /// ) keep working when the negotiated protocol revision is -/// 2026-07-28 on a stateful session - for example, stdio. +/// 2026-07-28 on a stateful transport - for example, stdio. /// /// /// Under 2026-07-28 the spec removes the corresponding server-to-client request methods, but @@ -17,12 +17,12 @@ namespace ModelContextProtocol.Tests.Server; /// throw "X is not supported in stateless mode" because is /// ). Stdio is implicitly stateful - one per process - so the /// legacy elicitation/create / sampling/createMessage / roots/list flow still works. -/// A future PR is expected to force 2026-07-28 Streamable HTTP servers to stateless mode, at which -/// point those configurations will start throwing through the existing stateless guard. +/// Starting with 2026-07-28, Streamable HTTP servers are stateless by default, so those configurations +/// throw through the existing stateless guard unless the author explicitly opts back into sessions. /// -public sealed class DraftProtocolBackcompatTests : ClientServerTestBase +public sealed class July2026ProtocolBackcompatTests : ClientServerTestBase { - public DraftProtocolBackcompatTests(ITestOutputHelper testOutputHelper) + public July2026ProtocolBackcompatTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { } @@ -42,7 +42,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + public async Task ElicitAsync_OnStatefulTransport_ResolvesViaLegacyRequest() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions @@ -64,7 +64,7 @@ public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() } [Fact] - public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + public async Task SampleAsync_OnStatefulTransport_ResolvesViaLegacyRequest() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions @@ -91,7 +91,7 @@ public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() } [Fact] - public async Task RequestRootsAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + public async Task RequestRootsAsync_OnStatefulTransport_ResolvesViaLegacyRequest() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index e11152301..8fd1d9954 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -651,19 +651,17 @@ public void OutputSchema_Create_StringReturn_NoEnvelope() Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _)); } - // SEP-2106 backward-compat: for clients negotiating a pre-2026-06-30 protocol version, + // SEP-2106 backward-compat: for clients negotiating a pre-2026-07-28 protocol version, // non-object structured content is wrapped in the legacy {"result": } envelope. - // Clients on the SEP-2106 protocol ("2026-06-30" and later, including the draft) see the - // natural value shape. In-memory storage stays natural in both modes — only the wire + // Clients on the SEP-2106 protocol ("2026-07-28" and later) see the + // natural value shape. In-memory storage stays natural in both modes; only the wire // emission flips. private const string LegacyProtocolVersion = "2025-11-25"; - private const string DraftSep2106ProtocolVersion = "DRAFT-2026-06-v1"; - private const string Sep2106ProtocolVersion = "2026-06-30"; + private const string Sep2106ProtocolVersion = "2026-07-28"; [Theory] [InlineData(LegacyProtocolVersion, true)] [InlineData(null, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task StructuredContent_StringReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped) { @@ -689,7 +687,6 @@ public async Task StructuredContent_StringReturn_WrapsForLegacyClients(string? p [Theory] [InlineData(LegacyProtocolVersion, true)] [InlineData(null, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task StructuredContent_IntegerReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped) { @@ -715,7 +712,6 @@ public async Task StructuredContent_IntegerReturn_WrapsForLegacyClients(string? [Theory] [InlineData(LegacyProtocolVersion, true)] [InlineData(null, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task StructuredContent_ArrayReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped) { @@ -742,12 +738,11 @@ public async Task StructuredContent_ArrayReturn_WrapsForLegacyClients(string? pr [Theory] [InlineData(LegacyProtocolVersion)] [InlineData(null)] - [InlineData(DraftSep2106ProtocolVersion)] [InlineData(Sep2106ProtocolVersion)] public async Task StructuredContent_ObjectReturn_NeverWrapped(string? protocolVersion) { // Object-typed return: the stored schema is type:"object" — already the form - // expected by clients on protocol versions older than 2026-06-30, so no envelope + // expected by clients on protocol versions older than 2026-07-28, so no envelope // is applied at any protocol version. Wire shape must be identical across versions. McpServerTool tool = McpServerTool.Create(() => new Person("John", 27), new() { @@ -769,11 +764,10 @@ public async Task StructuredContent_ObjectReturn_NeverWrapped(string? protocolVe [Theory] [InlineData(LegacyProtocolVersion)] [InlineData(null)] - [InlineData(DraftSep2106ProtocolVersion)] [InlineData(Sep2106ProtocolVersion)] public async Task StructuredContent_NullableObjectReturn_NeverWrapped(string? protocolVersion) { - // type:["object","null"] — for clients on protocol versions older than 2026-06-30, + // type:["object","null"]: for clients on protocol versions older than 2026-07-28, // the SCHEMA is normalized to plain type:"object" (verified in // Sep2106ListToolsBackCompatTests), but the value side is never envelope-wrapped at // any protocol version. So the emitted structured content stays a plain object diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs index f2cbfef6e..b80effede 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -253,7 +253,8 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() // Disposing the server while a continuation is suspended should log the cancellation of the // pending MRTR continuation once at Debug level (this is the only path that reaches the - // continuation-cancellation log now that HTTP draft is always sessionless). Poll for the + // continuation-cancellation log now that the HTTP transport no longer supports sessions starting with the + // 2026-07-28 protocol). Poll for the // async cancellation to propagate through the handler task. var deadline = DateTime.UtcNow.AddSeconds(30); while (!MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index 14b5dd3a9..221a2ffb4 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -9,10 +9,10 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the legacy MRTR backcompat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync. -/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful - +/// This path runs only when the client did NOT negotiate MRTR (2026-07-28) and the session is stateful, where /// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler /// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks -/// a non-draft version during initialize negotiation. +/// a legacy version during initialize negotiation. /// public class MrtrServerBackcompatTests : ClientServerTestBase { diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index ff48b71c8..29ae7823f 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -53,25 +53,25 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha { var ct = TestContext.Current.CancellationToken; - // The first request establishes the draft version for the stateful session (null -> draft). - Assert.IsType(await RoundTripAsync(id: 1, McpSession.DraftProtocolVersion, ct)); + // The first request establishes the 2026-07-28 version for the stateful session (null -> 2026-07-28). + Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); // Re-sending the same version is an idempotent no-op, not an error. - Assert.IsType(await RoundTripAsync(id: 2, McpSession.DraftProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); // Switching to a different (still-supported) version mid-session is rejected. - var error = Assert.IsType(await RoundTripAsync(id: 3, McpSession.LatestProtocolVersion, ct)); + var error = Assert.IsType(await RoundTripAsync(id: 3, McpHttpHeaders.November2025ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); - // The rejected request must not have mutated the negotiated version: the original draft version still works. - Assert.IsType(await RoundTripAsync(id: 4, McpSession.DraftProtocolVersion, ct)); + // The rejected request must not have mutated the negotiated version: the original 2026-07-28 version still works. + Assert.IsType(await RoundTripAsync(id: 4, McpHttpHeaders.July2026ProtocolVersion, ct)); } private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) { - // tools/list is available under both the legacy and draft revisions (unlike ping/initialize, - // which the draft revision removed), so it exercises the version guard rather than the + // tools/list is available under both the legacy and 2026-07-28 revisions (unlike ping/initialize, + // which the 2026-07-28 protocol removed), so it exercises the version guard rather than the // per-method availability gate. var request = new JsonRpcRequest { diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs index 45c978be2..9e5a9e11f 100644 --- a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that the built-in ping handler is gated by protocol version. -/// SEP-2575 (the draft 2026-07-28 revision) removes ping; servers must +/// SEP-2575 (the 2026-07-28 revision) removes ping; servers must /// respond with -32601 MethodNotFound. Legacy protocol versions still /// support ping per the spec. /// @@ -23,12 +23,12 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task Ping_OnDraftSession_ReturnsMethodNotFound() + public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpSession.DraftProtocolVersion, + ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, }); var ex = await Assert.ThrowsAsync(async () => diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 003077673..818bd2b4b 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -1,4 +1,4 @@ -#if !NET472 +#if !NET472 using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -14,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Wire-format conformance tests for driven directly against the underlying /// stream — without going through . This exercises the -/// SEP-2575 (sessionless / no-initialize) and SEP-2567 (server/discover) flows by hand-crafting JSON-RPC +/// SEP-2575 (no initialize handshake) and SEP-2567 (server/discover, no session id) flows by hand-crafting JSON-RPC /// messages and asserting on the exact responses the server emits. /// /// @@ -24,7 +24,6 @@ namespace ModelContextProtocol.Tests.Server; /// public sealed class RawStreamConformanceTests : LoggedTest, IAsyncDisposable { - private const string DraftVersion = McpHttpHeaders.DraftProtocolVersion; private readonly Pipe _clientToServer = new(); private readonly Pipe _serverToClient = new(); @@ -77,15 +76,15 @@ private async Task ReadAsync() return JsonNode.Parse(line!)!; } - private static string DraftMetaFragment(string protocolVersion = DraftVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; [Fact] - public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() + public async Task ServerDiscover_ReturnsSupportedVersionsIncludingJuly2026Protocol() { - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); var response = await ReadAsync(); Assert.Equal("2.0", response["jsonrpc"]!.GetValue()); @@ -97,7 +96,7 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() var supportedVersions = result!["supportedVersions"]!.AsArray() .Select(n => n!.GetValue()) .ToList(); - Assert.Contains(DraftVersion, supportedVersions); + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supportedVersions); // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. Assert.NotNull(result["capabilities"]); @@ -112,13 +111,13 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft() } [Fact] - public async Task DraftToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided() + public async Task July2026ProtocolToolsCall_WithoutInitialize_Succeeds_WhenFullMetaProvided() { // Spec: under SEP-2575 the client may skip server/discover and go straight to a normal RPC, as long // as every request carries the full _meta envelope with protocolVersion, clientInfo and capabilities. await SendAsync( @"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hello""}," + - DraftMetaFragment() + "}}"); + July2026ProtocolMetaFragment() + "}}"); var response = await ReadAsync(); Assert.Equal(42, response["id"]!.GetValue()); @@ -130,12 +129,12 @@ await SendAsync( } [Fact] - public async Task DraftRequest_WithUnsupportedProtocolVersion_ReturnsMinus32004WithSupported() + public async Task July2026ProtocolRequest_WithUnsupportedProtocolVersion_ReturnsMinus32022WithSupported() { - // Server should respond with UnsupportedProtocolVersionError (-32004) and a data.supported[] list. + // Server should respond with UnsupportedProtocolVersionError (-32022) and a data.supported[] list. await SendAsync( @"{""jsonrpc"":""2.0"",""id"":7,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + - DraftMetaFragment("9999-99-99") + "}}"); + July2026ProtocolMetaFragment("9999-99-99") + "}}"); var response = await ReadAsync(); Assert.Equal(7, response["id"]!.GetValue()); @@ -147,13 +146,13 @@ await SendAsync( Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(DraftVersion, supported); + Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); } [Fact] - public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer() + public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() { - // Dual-era: a draft-default server (ProtocolVersion = DraftVersion in McpServerOptions) must still + // Dual-era: a server defaulting to the 2026-07-28 protocol (ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion in McpServerOptions) must still // accept the legacy initialize handshake from clients that don't speak the new protocol. await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); @@ -167,9 +166,9 @@ public async Task LegacyInitialize_StillWorks_OnDraftDefaultServer() [Fact] public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-era servers must accept draft and legacy traffic on the same connection. The exact mix below + // Dual-era servers must accept 2026-07-28 and legacy traffic on the same connection. The exact mix below // is what a permissive client running against an unknown server would emit while probing. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + DraftMetaFragment() + "}}"); + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); var discover = await ReadAsync(); Assert.NotNull(discover["result"]); diff --git a/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs b/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs index e559a40f5..6ae1ab7a4 100644 --- a/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// SEP-2106 backward-compat at the tools/list emission boundary. Clients negotiating a -/// pre-2026-06-30 protocol version must still receive the legacy +/// pre-2026-07-28 protocol version must still receive the legacy /// {"type":"object","properties":{"result":<schema>},"required":["result"]} /// envelope for non-object output schemas. In-memory storage stays natural; only the /// wire emission flips on the negotiated version. @@ -18,8 +18,7 @@ namespace ModelContextProtocol.Tests.Server; public class Sep2106ListToolsBackCompatTests : ClientServerTestBase { private const string LegacyProtocolVersion = "2025-11-25"; - private const string DraftSep2106ProtocolVersion = "DRAFT-2026-06-v1"; - private const string Sep2106ProtocolVersion = "2026-06-30"; + private const string Sep2106ProtocolVersion = "2026-07-28"; public Sep2106ListToolsBackCompatTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) @@ -28,7 +27,6 @@ public Sep2106ListToolsBackCompatTests(ITestOutputHelper testOutputHelper) [Theory] [InlineData(LegacyProtocolVersion, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task ListTools_StringTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped) { @@ -50,7 +48,6 @@ public async Task ListTools_StringTool_WrapsOutputSchemaForLegacyClients(string [Theory] [InlineData(LegacyProtocolVersion, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task ListTools_IntegerTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped) { @@ -71,7 +68,6 @@ public async Task ListTools_IntegerTool_WrapsOutputSchemaForLegacyClients(string [Theory] [InlineData(LegacyProtocolVersion, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task ListTools_ArrayTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped) { @@ -92,7 +88,6 @@ public async Task ListTools_ArrayTool_WrapsOutputSchemaForLegacyClients(string s [Theory] [InlineData(LegacyProtocolVersion)] - [InlineData(DraftSep2106ProtocolVersion)] [InlineData(Sep2106ProtocolVersion)] public async Task ListTools_ObjectTool_NeverWrapsOutputSchema(string serverProtocolVersion) { @@ -110,14 +105,13 @@ public async Task ListTools_ObjectTool_NeverWrapsOutputSchema(string serverProto [Theory] [InlineData(LegacyProtocolVersion, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task ListTools_NullableObjectTool_NormalizesTypeArrayForLegacyClients(string serverProtocolVersion, bool expectNormalized) { - // For clients on protocol versions older than 2026-06-30, type:["object","null"] + // For clients on protocol versions older than 2026-07-28, type:["object","null"] // must be emitted as plain type:"object" (those versions accept object schemas but - // not type-arrays — and the value side stays a plain object, no envelope). SEP-2106 - // clients (2026-06-30+) see the natural type-array intact per the SEP's + // not type-arrays, and the value side stays a plain object, no envelope). SEP-2106 + // clients (2026-07-28+) see the natural type-array intact per the SEP's // any-JSON-Schema-2020-12 allowance. ConfigureServerWithTools(serverProtocolVersion); await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion }); @@ -152,7 +146,6 @@ public async Task ListTools_NullableObjectTool_NormalizesTypeArrayForLegacyClien [Theory] [InlineData(LegacyProtocolVersion, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task ListTools_DuplicateTypeRefsTool_RewritesRefsWhenWrapped(string serverProtocolVersion, bool expectWrapped) { @@ -169,7 +162,6 @@ public async Task ListTools_DuplicateTypeRefsTool_RewritesRefsWhenWrapped(string [Theory] [InlineData(LegacyProtocolVersion, true)] - [InlineData(DraftSep2106ProtocolVersion, false)] [InlineData(Sep2106ProtocolVersion, false)] public async Task ListTools_RecursiveTypeRefsTool_RewritesRefsWhenWrapped(string serverProtocolVersion, bool expectWrapped) { diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs index a57c07d55..d0dd2a796 100644 --- a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -14,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an /// in-memory stream transport (the stdio-shaped path exercised by ). -/// Validates that a draft client receives only the change notifications it subscribed to, each tagged +/// Validates that a client on the 2026-07-28 protocol receives only the change notifications it subscribed to, each tagged /// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. /// public class SubscriptionsListenTests : ClientServerTestBase @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task Draft_ToolsListChangedSubscription_DeliversTaggedNotification_AndWithholdsUnsubscribed() + public async Task July2026Protocol_ToolsListChangedSubscription_DeliversTaggedNotification_AndWithholdsUnsubscribed() { await using McpClient client = await CreateMcpClientForServer(); @@ -80,7 +80,7 @@ public async Task Draft_ToolsListChangedSubscription_DeliversTaggedNotification_ } [Fact] - public async Task Draft_WithoutSubscription_DoesNotBroadcastListChanged() + public async Task July2026Protocol_WithoutSubscription_DoesNotBroadcastListChanged() { await using McpClient client = await CreateMcpClientForServer(); @@ -91,7 +91,7 @@ public async Task Draft_WithoutSubscription_DoesNotBroadcastListChanged() var serverOptions = ServiceProvider.GetRequiredService>().Value; serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); - // The change notification must not be broadcast to a draft client that never opened a + // The change notification must not be broadcast to a client on the 2026-07-28 protocol that never opened a // subscriptions/listen stream. The list-changed handler runs synchronously during Add (before // the ListTools round-trip below completes), so any erroneous broadcast would already be // buffered once the round-trip returns. @@ -108,7 +108,7 @@ public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() { await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpSession.LatestProtocolVersion, + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, }); var toolsChannel = Channel.CreateUnbounded(); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs similarity index 87% rename from tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs rename to tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 97dba0fce..7f9790526 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskDraftGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -11,18 +11,18 @@ namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that the SEP-2663 Tasks extension is gated to the draft protocol revision on both the +/// Verifies that the SEP-2663 Tasks extension is gated to the 2026-07-28 protocol revision on both the /// client and the server. Explicit task operations throw on a legacy session; best-effort task /// augmentation silently downgrades to a direct result so that legacy peers never see a task. /// -public class TaskDraftGatingTests : ClientServerTestBase +public class TaskProtocolGatingTests : ClientServerTestBase { private const string LatestStableVersion = "2025-11-25"; private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; private const string ExtensionsKey = "extensions"; - public TaskDraftGatingTests(ITestOutputHelper outputHelper) + public TaskProtocolGatingTests(ITestOutputHelper outputHelper) : base(outputHelper) { #if !NET @@ -82,7 +82,7 @@ public async Task LegacyClient_GetTaskAsync_ThrowsInvalidOperationException() var ex = await Assert.ThrowsAsync(async () => await client.GetTaskAsync("some-task-id", ct)); - Assert.Contains("draft protocol revision", ex.Message); + Assert.Contains("newer protocol revision that supports tasks", ex.Message); } [Fact] @@ -94,7 +94,7 @@ public async Task LegacyClient_UpdateTaskAsync_ThrowsInvalidOperationException() var ex = await Assert.ThrowsAsync(async () => await client.UpdateTaskAsync(new UpdateTaskRequestParams { TaskId = "some-task-id" }, ct)); - Assert.Contains("draft protocol revision", ex.Message); + Assert.Contains("newer protocol revision that supports tasks", ex.Message); } [Fact] @@ -106,7 +106,7 @@ public async Task LegacyClient_CancelTaskAsync_ThrowsInvalidOperationException() var ex = await Assert.ThrowsAsync(async () => await client.CancelTaskAsync("some-task-id", ct)); - Assert.Contains("draft protocol revision", ex.Message); + Assert.Contains("newer protocol revision that supports tasks", ex.Message); } [Fact] @@ -134,7 +134,7 @@ public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDire // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy // request. The server must still refuse to create a task because the per-request protocol - // version is not the draft revision. + // version is not the 2026-07-28 protocol. var result = await client.CallToolRawAsync( new CallToolRequestParams { @@ -154,7 +154,7 @@ public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() var ct = TestContext.Current.CancellationToken; // Bypass the typed GetTaskAsync client guard by sending a raw tasks/get request. The server - // gates tasks/* to the draft revision and must reject this legacy request with MethodNotFound. + // gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound. var request = new JsonRpcRequest { Method = RequestMethods.TasksGet, @@ -170,9 +170,9 @@ public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() } [Fact] - public async Task DraftClient_CallToolRaw_CreatesTask() + public async Task July2026ProtocolClient_CallToolRaw_CreatesTask() { - // Sanity: the default client negotiates the draft revision, so the task flow still works. + // Sanity: the default client negotiates the 2026-07-28 protocol, so the task flow still works. await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; @@ -180,7 +180,7 @@ public async Task DraftClient_CallToolRaw_CreatesTask() new CallToolRequestParams { Name = "test-tool", - Arguments = CreateArguments("input", "draft"), + Arguments = CreateArguments("input", "july2026"), }, ct); Assert.True(result.IsTask); From 8e7f28c23766c76a361fc74d59d4d65d651190bc Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:55:50 -0700 Subject: [PATCH 40/50] Document caching behavior and warn on non-conformant draft cacheable results (SEP-2549 follow-up) (#1669) Co-authored-by: Tarek Mahmoud Sayed --- .../Client/McpClient.Methods.cs | 65 +++- .../Client/McpClient.cs | 14 + .../Client/McpClientImpl.cs | 29 ++ .../Protocol/CacheableResultWarningTests.cs | 330 ++++++++++++++++++ 4 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index bfe81d19b..9ecfe1c12 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -173,10 +173,18 @@ public ValueTask PingAsync( /// A list of all available tools as instances. /// The request failed or the server returned an error response. /// + /// /// This overload aggregates every page into a single list and does not surface the per-result caching hints /// ( and ). To read those hints, /// use the overload, which returns the /// raw for each page. + /// + /// + /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server. + /// If you want to cache listing results, do so in your own code using the lower-level + /// overload, which exposes the per-page + /// caching hints and lets you manage pagination so each page can be cached and expired independently. + /// /// public async ValueTask> ListToolsAsync( RequestOptions? options = null, @@ -247,12 +255,25 @@ public ValueTask ListToolsAsync( { Throw.IfNull(requestParams); - return SendRequestAsync( + return ValidateCacheableResultAsync(RequestMethods.ToolsList, SendRequestAsync( RequestMethods.ToolsList, requestParams, McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken)); + } + + /// + /// Awaits a cacheable result and gives derived clients a chance to emit diagnostics (for example, a + /// SEP-2549 conformance warning) before returning it. Preserves the synchronous argument validation + /// performed by the callers before the request is issued. + /// + private async ValueTask ValidateCacheableResultAsync(string method, ValueTask resultTask) + where TResult : ICacheableResult + { + var result = await resultTask.ConfigureAwait(false); + ValidateCacheableResult(method, result); + return result; } /// @@ -263,10 +284,18 @@ public ValueTask ListToolsAsync( /// A list of all available prompts as instances. /// The request failed or the server returned an error response. /// + /// /// This overload aggregates every page into a single list and does not surface the per-result caching hints /// ( and ). To read those hints, /// use the overload, which returns the /// raw for each page. + /// + /// + /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server. + /// If you want to cache listing results, do so in your own code using the lower-level + /// overload, which exposes the per-page + /// caching hints and lets you manage pagination so each page can be cached and expired independently. + /// /// public async ValueTask> ListPromptsAsync( RequestOptions? options = null, @@ -309,12 +338,12 @@ public ValueTask ListPromptsAsync( { Throw.IfNull(requestParams); - return SendRequestAsync( + return ValidateCacheableResultAsync(RequestMethods.PromptsList, SendRequestAsync( RequestMethods.PromptsList, requestParams, McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams, McpJsonUtilities.JsonContext.Default.ListPromptsResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken)); } /// @@ -379,10 +408,18 @@ public ValueTask GetPromptAsync( /// A list of all available resource templates as instances. /// The request failed or the server returned an error response. /// + /// /// This overload aggregates every page into a single list and does not surface the per-result caching hints /// ( and ). To read those hints, /// use the overload, which returns the /// raw for each page. + /// + /// + /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server. + /// If you want to cache listing results, do so in your own code using the lower-level + /// overload, which exposes the per-page + /// caching hints and lets you manage pagination so each page can be cached and expired independently. + /// /// public async ValueTask> ListResourceTemplatesAsync( RequestOptions? options = null, @@ -425,12 +462,12 @@ public ValueTask ListResourceTemplatesAsync( { Throw.IfNull(requestParams); - return SendRequestAsync( + return ValidateCacheableResultAsync(RequestMethods.ResourcesTemplatesList, SendRequestAsync( RequestMethods.ResourcesTemplatesList, requestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken)); } /// @@ -441,10 +478,18 @@ public ValueTask ListResourceTemplatesAsync( /// A list of all available resources as instances. /// The request failed or the server returned an error response. /// + /// /// This overload aggregates every page into a single list and does not surface the per-result caching hints /// ( and ). To read those hints, /// use the overload, which returns the /// raw for each page. + /// + /// + /// The SDK does not perform any internal caching of listing results; every call re-fetches all pages from the server. + /// If you want to cache listing results, do so in your own code using the lower-level + /// overload, which exposes the per-page + /// caching hints and lets you manage pagination so each page can be cached and expired independently. + /// /// public async ValueTask> ListResourcesAsync( RequestOptions? options = null, @@ -487,12 +532,12 @@ public ValueTask ListResourcesAsync( { Throw.IfNull(requestParams); - return SendRequestAsync( + return ValidateCacheableResultAsync(RequestMethods.ResourcesList, SendRequestAsync( RequestMethods.ResourcesList, requestParams, McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken)); } /// @@ -571,12 +616,12 @@ public ValueTask ReadResourceAsync( { Throw.IfNull(requestParams); - return SendRequestAsync( + return ValidateCacheableResultAsync(RequestMethods.ResourcesRead, SendRequestAsync( RequestMethods.ResourcesRead, requestParams, McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, McpJsonUtilities.JsonContext.Default.ReadResourceResult, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken)); } /// diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index b238c59c3..da85e9792 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -92,6 +92,20 @@ private protected abstract ValueTask> Resolve /// private protected abstract int MaxConsecutiveStuckPolls { get; } + /// + /// Inspects a received cacheable result (tools/list, prompts/list, resources/list, + /// resources/templates/list, or resources/read) so derived clients can emit diagnostics. + /// + /// The request method that produced the result. + /// The cacheable result returned by the server. + /// + /// This is used to warn (never throw) when a server that negotiated a protocol version requiring the + /// SEP-2549 ttlMs/cacheScope fields omits them. The default implementation does nothing. + /// + private protected virtual void ValidateCacheableResult(string method, ICacheableResult result) + { + } + /// /// Registers one or more tool definitions in the client's tool cache, enabling the transport /// to send Mcp-Param-* headers for those tools without requiring a prior call. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index eb2fedc85..39f6579e3 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -25,6 +25,7 @@ internal sealed partial class McpClientImpl : McpClient private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _registeredToolNames = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _cacheableConformanceWarnedMethods = new(StringComparer.Ordinal); private ServerCapabilities? _serverCapabilities; private Implementation? _serverInfo; @@ -763,6 +764,34 @@ private void WarnIfInputRequiredResultOnNonMrtrSession(string method) } } + /// + /// Logs a warning (never throws) when a server that negotiated the 2026-07-28 (or later) protocol version + /// omits the SEP-2549 ttlMs/cacheScope fields, which are required on cacheable results for + /// those versions. The warning is emitted at most once per method per session so that paginated listings do + /// not produce one warning per page. + /// + private protected override void ValidateCacheableResult(string method, ICacheableResult result) + { + if (!IsJuly2026OrLaterProtocol()) + { + return; + } + + bool missingTtl = result.TimeToLive is null; + bool missingScope = result.CacheScope is null; + if ((missingTtl || missingScope) && _cacheableConformanceWarnedMethods.TryAdd(method, 0)) + { + string missingFields = + missingTtl && missingScope ? "ttlMs, cacheScope" : + missingTtl ? "ttlMs" : + "cacheScope"; + LogCacheableResultMissingRequiredFields(_endpointName, method, missingFields, _negotiatedProtocolVersion); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received '{Method}' result missing required SEP-2549 field(s) '{MissingFields}' from a server that negotiated protocol version '{ProtocolVersion}'. The server may not be spec-compliant.")] + private partial void LogCacheableResultMissingRequiredFields(string endpointName, string method, string missingFields, string? protocolVersion); + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use InputRequiredResult instead of sending direct requests.")] private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method); diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs new file mode 100644 index 000000000..14f0d497e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -0,0 +1,330 @@ +// Excluded on .NET Framework: the in-memory server helper uses Stream/StreamReader async overloads +// that take a CancellationToken (e.g. StreamReader.ReadLineAsync(CancellationToken) and +// Stream.WriteAsync(ReadOnlyMemory, CancellationToken)) which are not available on net472. +#if !NET472 +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Tests for the client-side SEP-2549 conformance warning: when a server that negotiated the 2026-07-28 +/// (or later) protocol version returns a cacheable result (tools/list, prompts/list, resources/list, +/// resources/templates/list, resources/read) without the now-required ttlMs/cacheScope +/// fields, the client logs a warning but never throws. +/// +public class CacheableResultWarningTests : LoggedTest +{ + private const string July2026ProtocolVersion = "2026-07-28"; + private const string OlderProtocolVersion = "2025-11-25"; + + public CacheableResultWarningTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + public static IEnumerable CacheableMethods => + [ + [RequestMethods.ToolsList], + [RequestMethods.PromptsList], + [RequestMethods.ResourcesList], + [RequestMethods.ResourcesTemplatesList], + [RequestMethods.ResourcesRead], + ]; + + [Theory] + [MemberData(nameof(CacheableMethods))] + public async Task DraftServerOmittingBothHints_LogsWarning(string method) + { + var (call, result) = GetScenario(method, ttl: null, scope: null); + + await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, method, result, call, TestContext.Current.CancellationToken); + + var warning = Assert.Single(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && m.Message.Contains(method) && m.Message.Contains("SEP-2549")); + Assert.Contains("ttlMs", warning.Message); + Assert.Contains("cacheScope", warning.Message); + } + + [Fact] + public async Task DraftServerOmittingOnlyCacheScope_WarnsAboutCacheScope() + { + var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: null); + + await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + + var warning = Assert.Single(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); + Assert.Contains("cacheScope", warning.Message); + Assert.DoesNotContain("ttlMs", warning.Message); + } + + [Fact] + public async Task DraftServerProvidingBothHints_DoesNotWarn() + { + var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: CacheScope.Public); + + await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); + } + + [Fact] + public async Task OlderServerOmittingHints_DoesNotWarn() + { + // A server on an older protocol version may legitimately omit the fields; no warning should fire. + var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: null, scope: null); + + await RunScenarioAsync(OlderProtocolVersion, useModernLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); + } + + [Fact] + public async Task AutoPaginatingOverload_DraftServerOmittingHints_LogsWarning() + { + // The auto-paginating convenience overload calls the raw overload per page, so the warning + // fires through that path as well. + var result = JsonSerializer.SerializeToNode( + new ListToolsResult { Tools = [new Tool { Name = "echo" }] }, + McpJsonUtilities.DefaultOptions)!; + + await RunScenarioAsync( + July2026ProtocolVersion, + useModernLifecycle: true, + RequestMethods.ToolsList, + result, + (c, ct) => c.ListToolsAsync(cancellationToken: ct).AsTask(), + TestContext.Current.CancellationToken); + + Assert.Single(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && m.Message.Contains(RequestMethods.ToolsList) && m.Message.Contains("SEP-2549")); + } + + [Fact] + public async Task AutoPaginatingOverload_MultiplePages_WarnsOnlyOncePerMethod() + { + // A non-conformant draft server omits the hints on every page. The warning must be emitted at + // most once per method per session so that long paginated listings do not flood the log. + const int pageCount = 4; + + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + new McpClientOptions { ProtocolVersion = July2026ProtocolVersion }, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, useModernLifecycle: true, TestContext.Current.CancellationToken); + + await using var client = await clientTask; + + // Respond to each tools/list page omitting the hints, advancing the cursor until the last page. + var serverLoop = Task.Run(async () => + { + int page = 0; + while (true) + { + var line = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + if (line is null) + { + return; + } + + if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions) is JsonRpcRequest request && + request.Method == RequestMethods.ToolsList) + { + page++; + var result = new ListToolsResult + { + Tools = [new Tool { Name = $"tool{page}" }], + NextCursor = page < pageCount ? $"page{page}" : null, + }; + + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions), + }, TestContext.Current.CancellationToken); + + if (page >= pageCount) + { + return; + } + } + } + }, TestContext.Current.CancellationToken); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + await serverLoop; + + Assert.Equal(pageCount, tools.Count); + Assert.Single(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && m.Message.Contains(RequestMethods.ToolsList) && m.Message.Contains("SEP-2549")); + + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + private static (Func Call, JsonNode Result) GetScenario( + string method, TimeSpan? ttl, CacheScope? scope) + { + var options = McpJsonUtilities.DefaultOptions; + return method switch + { + RequestMethods.ToolsList => ( + (c, ct) => c.ListToolsAsync(new ListToolsRequestParams(), ct).AsTask(), + JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [], TimeToLive = ttl, CacheScope = scope }, options)!), + RequestMethods.PromptsList => ( + (c, ct) => c.ListPromptsAsync(new ListPromptsRequestParams(), ct).AsTask(), + JsonSerializer.SerializeToNode(new ListPromptsResult { Prompts = [], TimeToLive = ttl, CacheScope = scope }, options)!), + RequestMethods.ResourcesList => ( + (c, ct) => c.ListResourcesAsync(new ListResourcesRequestParams(), ct).AsTask(), + JsonSerializer.SerializeToNode(new ListResourcesResult { Resources = [], TimeToLive = ttl, CacheScope = scope }, options)!), + RequestMethods.ResourcesTemplatesList => ( + (c, ct) => c.ListResourceTemplatesAsync(new ListResourceTemplatesRequestParams(), ct).AsTask(), + JsonSerializer.SerializeToNode(new ListResourceTemplatesResult { ResourceTemplates = [], TimeToLive = ttl, CacheScope = scope }, options)!), + RequestMethods.ResourcesRead => ( + (c, ct) => c.ReadResourceAsync(new ReadResourceRequestParams { Uri = "test://resource" }, ct).AsTask(), + JsonSerializer.SerializeToNode(new ReadResourceResult { Contents = [], TimeToLive = ttl, CacheScope = scope }, options)!), + _ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unhandled method."), + }; + } + + private async Task RunScenarioAsync( + string serverProtocolVersion, + bool useModernLifecycle, + string method, + JsonNode resultNode, + Func clientCall, + CancellationToken cancellationToken) + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + // Pin the protocol version so the client deterministically takes the modern (server/discover) + // lifecycle for 2026-07-28 and the legacy (initialize) lifecycle for older versions. + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + new McpClientOptions { ProtocolVersion = serverProtocolVersion }, + loggerFactory: LoggerFactory, + cancellationToken: cancellationToken); + + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, useModernLifecycle, cancellationToken); + + await using var client = await clientTask; + Assert.Equal(serverProtocolVersion, client.NegotiatedProtocolVersion); + + // Background server loop: respond to the target request with the crafted result. + var serverLoop = Task.Run(async () => + { + while (true) + { + var line = await serverReader.ReadLineAsync(cancellationToken); + if (line is null) + { + return; + } + + if (JsonSerializer.Deserialize(line, McpJsonUtilities.DefaultOptions) is JsonRpcRequest request && + request.Method == method) + { + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse + { + Id = request.Id, + Result = resultNode, + }, cancellationToken); + return; + } + } + }, cancellationToken); + + await clientCall(client, cancellationToken); + await serverLoop; + + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + private static async Task PerformHandshakeAsync( + StreamReader serverReader, + Stream serverWriter, + string serverProtocolVersion, + bool useModernLifecycle, + CancellationToken cancellationToken) + { + var requestLine = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(requestLine); + var request = JsonSerializer.Deserialize(requestLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(request); + + if (useModernLifecycle) + { + // Modern 2026-07-28 lifecycle (SEP-2575): no initialize handshake. The client probes + // server/discover to learn capabilities, then sends normal RPCs carrying per-request _meta. + Assert.Equal(RequestMethods.ServerDiscover, request.Method); + + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [serverProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }, cancellationToken); + } + else + { + // Legacy initialize handshake for older protocol versions. + Assert.Equal(RequestMethods.Initialize, request.Method); + + await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = serverProtocolVersion, + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "MockServer", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions), + }, cancellationToken); + + // Consume the initialized notification. + var initializedLine = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(initializedLine); + } + } + + private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message, CancellationToken cancellationToken) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions); + await writer.WriteAsync(bytes, cancellationToken); + await writer.WriteAsync("\n"u8.ToArray(), cancellationToken); + await writer.FlushAsync(cancellationToken); + } +} + +#endif From 1193b7bf2e4a3a3644366f5d357d555eff4db87f Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Tue, 30 Jun 2026 18:17:26 -0700 Subject: [PATCH 41/50] Fix request-scoped draft client capabilities Route client capability resolution through request-scoped DestinationBoundMcpServer state for 2026-07-28+ requests, stop persisting per-request capabilities into McpServerImpl global state, and add a concurrency regression test that verifies overlapping requests observe their own declared capabilities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Server/DestinationBoundMcpServer.cs | 39 ++++++++- .../Server/McpServerImpl.cs | 30 ++----- .../Client/McpClientMetaTests.cs | 82 +++++++++++++++++++ 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b8f96237a..5e085b139 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,18 +5,47 @@ namespace ModelContextProtocol.Server; #pragma warning disable MCPEXP002 -internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer +internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcRequest? jsonRpcRequest = null) : McpServer #pragma warning restore MCPEXP002 { + private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(jsonRpcRequest, server.NegotiatedProtocolVersion); + private readonly ClientCapabilities? _requestClientCapabilities = jsonRpcRequest?.Context?.ClientCapabilities; + private readonly Implementation? _requestClientInfo = jsonRpcRequest?.Context?.ClientInfo; + public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; - public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; - public override Implementation? ClientInfo => server.ClientInfo; + public override Implementation? ClientInfo => _requestClientInfo ?? server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public override LoggingLevel? LoggingLevel => server.LoggingLevel; + public override ClientCapabilities? ClientCapabilities + { + get + { + // In stateless transport mode, a single request does not have a persistent bidirectional channel. + // Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the + // capability gates rely on a null ClientCapabilities value to report that unsupported-state path. + if (!server.HasStatefulTransport()) + { + return null; + } + + // On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request) + // and must not be inferred from prior requests. Missing per-request capabilities therefore means + // "no declared capabilities for this request", represented by an empty object. + if (_isJuly2026OrLaterRequest) + { + return _requestClientCapabilities ?? new ClientCapabilities(); + } + + // Legacy protocol behavior uses session-scoped capabilities established during initialize (or + // pre-populated migration data), so ignore per-request values and return the server session state. + return server.ClientCapabilities; + } + } + /// /// Gets or sets the MRTR context for the current request, if any. /// Set by when an MRTR-aware handler invocation is in progress. @@ -90,4 +119,8 @@ private static async Task SendRequestViaMrtrAsync( Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), }; } + + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request, string? negotiatedProtocolVersion) + => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( + request?.Context?.ProtocolVersion ?? negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..05a4e676c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -152,15 +152,16 @@ void Register(McpServerPrimitiveCollection? collection, /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// synchronizes server-side state (, - /// , ) from the per-request _meta - /// values projected onto and validates the per-request protocol - /// version, before delegating to the user-supplied incoming filters. + /// synchronizes server-side state (, ) + /// from the per-request _meta values projected onto and + /// validates the per-request protocol version, before delegating to the user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values - /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in - /// filter is a no-op (the values were captured during the initialize handler). + /// MUST be populated per-request. Per-request client capabilities are consumed request-scoped by + /// and are not persisted to server-wide state. For legacy 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) { @@ -185,19 +186,6 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner SetNegotiatedProtocolVersion(protocolVersion); } - if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) - { - // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL - // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate - // makes any legacy per-request envelope a no-op (legacy capabilities stay as the - // initialize handshake established them); the HasStatefulTransport() gate keeps - // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } - // (where the same server instance handles every request, so persisting per-request - // capability state would both leak across requests and break the StatelessServerTests - // invariant that surfaces the "X is not supported in stateless mode" errors). - _clientCapabilities = clientCapabilities; - } - if (context.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) @@ -1627,7 +1615,7 @@ async ValueTask InvokeScopedAsync( private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { @@ -1740,7 +1728,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList { // Ensure message has a Context so Items can be shared through the pipeline message.Context ??= new(); - var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message); + var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message as JsonRpcRequest), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e1e9a08db..35a575ea1 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -15,6 +16,8 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); + private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) { @@ -116,6 +119,85 @@ public async Task ToolCallWithMetaFields() Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() + { + var withSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var withoutSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowSamplingChecks = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + async (string requestId, RequestContext context, CancellationToken cancellationToken) => + { + if (requestId == "with") + { + withSamplingReady.TrySetResult(true); + } + else if (requestId == "without") + { + withoutSamplingReady.TrySetResult(true); + } + else + { + throw new ArgumentException($"Unexpected request id '{requestId}'."); + } + + await allowSamplingChecks.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + + return context.Server.ClientCapabilities?.Sampling is null ? + $"{requestId}:sampling-absent" : + $"{requestId}:sampling-present"; + }, + new() { Name = "meta_sampling_tool" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var withSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"with\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }; + + var withoutSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + new ClientCapabilities(), + McpJsonUtilities.DefaultOptions), + }, + }; + + Task withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + Task withoutSamplingTask = client.CallToolAsync(withoutSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + + await Task.WhenAll(withSamplingReady.Task, withoutSamplingReady.Task).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + allowSamplingChecks.TrySetResult(true); + + CallToolResult withSamplingResult = await withSamplingTask; + CallToolResult withoutSamplingResult = await withoutSamplingTask; + + var withSamplingText = Assert.IsType(Assert.Single(withSamplingResult.Content)).Text; + var withoutSamplingText = Assert.IsType(Assert.Single(withoutSamplingResult.Content)).Text; + + Assert.Equal("with:sampling-present", withSamplingText); + Assert.Equal("without:sampling-absent", withoutSamplingText); + } + [Fact] public async Task ResourceReadWithMetaFields() { From 6b5e5ae01c7362d052b487900c145985fd7b81f4 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Tue, 30 Jun 2026 18:42:10 -0700 Subject: [PATCH 42/50] Make request-scoped ClientInfo consistent with ClientCapabilities Gate DestinationBoundMcpServer.ClientInfo on the 2026-07-28+ protocol like ClientCapabilities so request handlers observe this request's declared client info rather than falling back to shared session state, which under a stateful transport could belong to a different concurrent request. Add a regression test verifying a tool observes the per-request client info. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Server/DestinationBoundMcpServer.cs | 19 ++++++++++++- .../Client/McpClientMetaTests.cs | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 5e085b139..f75f547e8 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -14,7 +14,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; - public override Implementation? ClientInfo => _requestClientInfo ?? server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] @@ -46,6 +45,24 @@ public override ClientCapabilities? ClientCapabilities } } + public override Implementation? ClientInfo + { + get + { + // On protocol revision 2026-07-28+, client info is request-scoped (carried in each request's _meta), + // mirroring how ClientCapabilities is resolved above. Return only this request's declared value and + // do not fall back to shared session state, which under a stateful transport could belong to a + // different concurrent request. + if (_isJuly2026OrLaterRequest) + { + return _requestClientInfo; + } + + // Legacy protocol behavior uses session-scoped client info established during initialize. + return server.ClientInfo; + } + } + /// /// Gets or sets the MRTR context for the current request, if any. /// Set by when an MRTR-aware handler invocation is in progress. diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index 35a575ea1..af2043d0e 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -198,6 +198,34 @@ public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseReques Assert.Equal("without:sampling-absent", withoutSamplingText); } + [Fact] + public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + var clientInfo = context.Server.ClientInfo; + return clientInfo is null ? + "client-info-absent" : + $"{clientInfo.Name}:{clientInfo.Version}"; + }, + new() { Name = "client_info_tool" })); + + // The 2026-07-28+ client stamps its ClientInfo onto every request's _meta, so the tool must observe + // the per-request value resolved by DestinationBoundMcpServer rather than server-only session state. + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "request-scoped-client", Version = "9.9.9" }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("client_info_tool", cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("request-scoped-client:9.9.9", text); + } + [Fact] public async Task ResourceReadWithMetaFields() { From 5d00eedebbf9630b4262adda73dc7f26c8307dbb Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 1 Jul 2026 22:44:19 +0300 Subject: [PATCH 43/50] Fully stabilize now-stable protocol properties (#1686) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Protocol/ClientCapabilities.cs | 12 +- .../Protocol/RequestParams.cs | 22 +--- .../Protocol/ServerCapabilities.cs | 12 +- .../ExperimentalPropertySerializationTests.cs | 120 ------------------ 4 files changed, 4 insertions(+), 162 deletions(-) delete mode 100644 tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index 22245f335..2828602fc 100644 --- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Text.Json.Serialization; using ModelContextProtocol.Client; using ModelContextProtocol.Server; @@ -83,15 +82,6 @@ public sealed class ClientCapabilities /// interoperability. Clients advertise extension support via this field during the initialization handshake. /// /// - [JsonIgnore] - public IDictionary? Extensions - { - get => ExtensionsCore; - set => ExtensionsCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] [JsonPropertyName("extensions")] - internal IDictionary? ExtensionsCore { get; set; } + public IDictionary? Extensions { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 67a8f00ba..37872ec5b 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -35,17 +35,8 @@ private protected RequestParams() /// the value is the client's response to that input request. /// /// - [JsonIgnore] - public IDictionary? InputResponses - { - get => InputResponsesCore; - set => InputResponsesCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] [JsonPropertyName("inputResponses")] - internal IDictionary? InputResponsesCore { get; set; } + public IDictionary? InputResponses { get; set; } /// /// Gets or sets opaque request state echoed back from a previous . @@ -57,17 +48,8 @@ public IDictionary? InputResponses /// exact value without modification. /// /// - [JsonIgnore] - public string? RequestState - { - get => RequestStateCore; - set => RequestStateCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] [JsonPropertyName("requestState")] - internal string? RequestStateCore { get; set; } + public string? RequestState { get; set; } /// /// Gets the opaque token that will be attached to any subsequent progress notifications. diff --git a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs index bfc6318d6..95cfa078d 100644 --- a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Text.Json.Serialization; using ModelContextProtocol.Server; @@ -81,15 +80,6 @@ public sealed class ServerCapabilities /// interoperability. Servers advertise extension support via this field during the initialization handshake. /// /// - [JsonIgnore] - public IDictionary? Extensions - { - get => ExtensionsCore; - set => ExtensionsCore = value; - } - - // See ExperimentalInternalPropertyTests.cs before modifying this property. - [JsonInclude] [JsonPropertyName("extensions")] - internal IDictionary? ExtensionsCore { get; set; } + public IDictionary? Extensions { get; set; } } diff --git a/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs deleted file mode 100644 index 866a59c61..000000000 --- a/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using ModelContextProtocol.Protocol; - -namespace ModelContextProtocol.Tests; - -/// -/// Validates that the internal property pattern used for experimental properties -/// produces the expected serialization behavior for SDK consumers using source generators. -/// -/// -/// -/// Experimental properties (e.g. , ) -/// use an internal *Core property for serialization. A consumer's source-generated -/// cannot see internal members, so experimental data is -/// silently dropped unless the consumer chains the SDK's resolver into their options. -/// -/// -/// These tests depend on and -/// being experimental. When those APIs stabilize, update these tests to reference whatever -/// experimental properties exist at that time, or remove them entirely if no experimental -/// APIs remain. -/// -/// -public class ExperimentalPropertySerializationTests -{ - [Fact] - public void ExperimentalProperties_Dropped_WithConsumerContextOnly() - { - var options = new JsonSerializerOptions - { - TypeInfoResolverChain = { ConsumerJsonContext.Default } - }; - - var capabilities = new ServerCapabilities - { - Tools = new ToolsCapability(), - Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } - }; - - string json = JsonSerializer.Serialize(capabilities, options); - Assert.DoesNotContain("\"extensions\"", json); - Assert.Contains("\"tools\"", json); - } - - [Fact] - public void ExperimentalProperties_IgnoredOnDeserialize_WithConsumerContextOnly() - { - string json = JsonSerializer.Serialize( - new ServerCapabilities - { - Tools = new ToolsCapability(), - Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } - }, - McpJsonUtilities.DefaultOptions); - Assert.Contains("\"extensions\"", json); - - var options = new JsonSerializerOptions - { - TypeInfoResolverChain = { ConsumerJsonContext.Default } - }; - var deserialized = JsonSerializer.Deserialize(json, options)!; - Assert.NotNull(deserialized.Tools); - Assert.Null(deserialized.Extensions); - } - - [Fact] - public void ExperimentalProperties_RoundTrip_WhenSdkResolverIsChained() - { - var options = new JsonSerializerOptions - { - TypeInfoResolverChain = - { - McpJsonUtilities.DefaultOptions.TypeInfoResolver!, - ConsumerJsonContext.Default, - } - }; - - var capabilities = new ServerCapabilities - { - Tools = new ToolsCapability(), - Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } - }; - - string json = JsonSerializer.Serialize(capabilities, options); - Assert.Contains("\"extensions\"", json); - Assert.Contains("\"tools\"", json); - - var deserialized = JsonSerializer.Deserialize(json, options)!; - Assert.NotNull(deserialized.Tools); - Assert.NotNull(deserialized.Extensions); - Assert.True(deserialized.Extensions.ContainsKey("io.test")); - } - - [Fact] - public void ExperimentalProperties_RoundTrip_WithDefaultOptions() - { - var capabilities = new ClientCapabilities - { - Extensions = new Dictionary { ["io.test"] = new JsonObject { ["enabled"] = true } } - }; - - string json = JsonSerializer.Serialize(capabilities, McpJsonUtilities.DefaultOptions); - Assert.Contains("\"extensions\"", json); - - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; - Assert.NotNull(deserialized.Extensions); - Assert.True(deserialized.Extensions.ContainsKey("io.test")); - } -} - -[JsonSerializable(typeof(Tool))] -[JsonSerializable(typeof(ServerCapabilities))] -[JsonSerializable(typeof(ClientCapabilities))] -[JsonSerializable(typeof(CallToolResult))] -[JsonSerializable(typeof(CallToolRequestParams))] -[JsonSerializable(typeof(CreateMessageRequestParams))] -[JsonSerializable(typeof(ElicitRequestParams))] -internal partial class ConsumerJsonContext : JsonSerializerContext; From e23be1d75ef4dc01e0814de38930614a3e78d533 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:09:05 -0700 Subject: [PATCH 44/50] Echo request id in post-parse Streamable HTTP error responses (#1687) Co-authored-by: Tarek Mahmoud Sayed --- .../StreamableHttpHandler.cs | 40 +++++++++++-------- .../RawHttpConformanceTests.cs | 29 +++++++++++++- .../StreamableHttpServerConformanceTests.cs | 32 ++++++++++++++- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 3224ceda8..0ecf7ab37 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -106,13 +106,18 @@ await WriteJsonRpcErrorAsync(context, return; } + // Once the body has been parsed into a request with a readable id, every JSON-RPC error response + // for this request MUST echo that id (base protocol responses section; SEP-2243 error format). + // Notifications carry no id, so this stays default (null), which is correct. + var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default; + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { - await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch); + await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId); return; } - var session = await GetOrCreateSessionAsync(context, message); + var session = await GetOrCreateSessionAsync(context, message, requestId); if (session is null) { return; @@ -286,7 +291,7 @@ await WriteJsonRpcErrorAsync(context, } } - private async ValueTask GetSessionAsync(HttpContext context, string sessionId) + private async ValueTask GetSessionAsync(HttpContext context, string sessionId, RequestId requestId = default) { if (string.IsNullOrEmpty(sessionId)) { @@ -294,7 +299,7 @@ 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. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, requestId: requestId); return null; } @@ -309,7 +314,7 @@ await WriteJsonRpcErrorAsync(context, // One of the few other usages I found was from some Ethereum JSON-RPC documentation and this // JSON-RPC library from Microsoft called StreamJsonRpc where it's called JsonRpcErrorCode.NoMarshaledObjectFound // https://learn.microsoft.com/dotnet/api/streamjsonrpc.protocol.jsonrpcerrorcode?view=streamjsonrpc-2.9#fields - await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001); + await WriteJsonRpcErrorAsync(context, "Session not found", StatusCodes.Status404NotFound, -32001, requestId); return null; } } @@ -318,7 +323,7 @@ await WriteJsonRpcErrorAsync(context, { await WriteJsonRpcErrorAsync(context, "Forbidden: The currently authenticated user does not match the user who initiated the session.", - StatusCodes.Status403Forbidden); + StatusCodes.Status403Forbidden, requestId: requestId); return null; } @@ -367,7 +372,7 @@ await WriteJsonRpcErrorAsync(context, } } - private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message) + private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default) { var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); @@ -380,7 +385,7 @@ await WriteJsonRpcErrorAsync(context, // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). await WriteJsonRpcErrorAsync(context, "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, requestId: requestId); return null; } @@ -389,7 +394,7 @@ await WriteJsonRpcErrorAsync(context, // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 // revision cannot provide. Refuse it so a dual-era client falls back to the legacy // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). - await WriteUnsupportedProtocolVersionErrorAsync(context); + await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); return null; } @@ -408,7 +413,7 @@ 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. " + "See https://csharp.sdk.modelcontextprotocol.io/concepts/stateless/stateless.html for more details.", - StatusCodes.Status400BadRequest); + StatusCodes.Status400BadRequest, requestId: requestId); return null; } @@ -418,12 +423,12 @@ await WriteJsonRpcErrorAsync(context, { // In stateless mode, we should not be getting existing sessions via sessionId // This path should not be reached in stateless mode - await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest); + await WriteJsonRpcErrorAsync(context, "Bad Request: The Mcp-Session-Id header is not supported in stateless mode", StatusCodes.Status400BadRequest, requestId: requestId); return null; } else { - return await GetSessionAsync(context, sessionId); + return await GetSessionAsync(context, sessionId, requestId); } } @@ -568,10 +573,11 @@ await WriteJsonRpcErrorAsync(context, return eventStreamReader; } - private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000) + private static Task WriteJsonRpcErrorAsync(HttpContext context, string errorMessage, int statusCode, int errorCode = -32000, RequestId requestId = default) { var jsonRpcError = new JsonRpcError { + Id = requestId, Error = new() { Code = errorCode, @@ -691,7 +697,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW /// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake /// (SEP-2575). /// - private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context) + private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) { var requestedProtocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); var errorDetail = new JsonRpcErrorDetail @@ -708,7 +714,7 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex GetRequiredJsonTypeInfo()), }; - return WriteJsonRpcErrorDetailAsync(context, errorDetail, StatusCodes.Status400BadRequest); + return WriteJsonRpcErrorDetailAsync(context, errorDetail, StatusCodes.Status400BadRequest, requestId); } /// @@ -1150,9 +1156,9 @@ private static SafeIntegerParse ParseSafeInteger(string text, out long value) return SafeIntegerParse.NotNumeric; } - private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode) + private static Task WriteJsonRpcErrorDetailAsync(HttpContext context, JsonRpcErrorDetail detail, int statusCode, RequestId requestId = default) { - var jsonRpcError = new JsonRpcError { Error = detail }; + var jsonRpcError = new JsonRpcError { Id = requestId, Error = detail }; return Results.Json(jsonRpcError, s_errorTypeInfo, statusCode: statusCode).ExecuteAsync(context); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 050bd428b..366e4276d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -174,7 +174,7 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi // checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a // legacy one and falling back to the initialize handshake. var body = - @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + + @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; @@ -184,6 +184,33 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + + // The body parsed successfully, so per the base protocol responses section (and SEP-2243's error + // response format) this error MUST echo the request id rather than emitting id=null (see #1677). + Assert.Equal(4242, json["id"]!.GetValue()); + } + + [Fact] + public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_EchoesRequestId() + { + await StartAsync(); + + // A well-formed tools/call whose body parses, but the required Mcp-Name header is absent. The server + // rejects it with -32020 HeaderMismatch, and because the request id was readable the JSON-RPC error + // MUST carry that same id (regression guard for #1677). + var body = + @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""hi""}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Equal(4242, json["id"]!.GetValue()); } [Fact] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 1ff58f978..04ebb6492 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -224,7 +224,7 @@ public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId() using var request = new HttpRequestMessage(HttpMethod.Post, "") { - Content = JsonContent(EchoRequest), + Content = JsonContent("""{"jsonrpc":"2.0","id":4242,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}"""), Headers = { { "mcp-session-id", "fakeSession" }, @@ -232,6 +232,11 @@ public async Task PostRequest_IsNotFound_WithUnrecognizedSessionId() }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + // The request body parsed successfully, so the JSON-RPC error MUST echo its id rather than + // emitting id=null (base protocol responses section; see #1677). + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(4242, doc.RootElement.GetProperty("id").GetInt64()); } [Fact] @@ -245,6 +250,31 @@ public async Task PostWithoutSessionId_NonInitializeRequest_Returns400() var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Contains("Mcp-Session-Id", body); Assert.Contains("Stateless", body); + + // The request body parsed successfully, so the JSON-RPC error MUST echo its id (see #1677). + using var doc = JsonDocument.Parse(body); + Assert.Equal(1, doc.RootElement.GetProperty("id").GetInt64()); + } + + [Fact] + public async Task StatelessPostWithSessionId_Returns400_EchoesRequestId() + { + await StartAsync(stateless: true); + + using var request = new HttpRequestMessage(HttpMethod.Post, "") + { + Content = JsonContent("""{"jsonrpc":"2.0","id":4242,"method":"tools/list","params":{}}"""), + Headers = + { + { "mcp-session-id", "someSession" }, + }, + }; + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + // The request body parsed successfully, so the stateless-mode rejection MUST echo its id (see #1677). + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(4242, doc.RootElement.GetProperty("id").GetInt64()); } [Fact] From 1214556707e0477ea3bb5829ff7948045b552dc7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:13:32 -0400 Subject: [PATCH 45/50] Update package READMEs as part of the release process (#1683) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .github/release-process.md | 4 +- .github/skills/prepare-release/SKILL.md | 20 +++- .../references/readme-content.md | 94 +++++++++++++++++++ .../references/readme-snippets.md | 18 ++-- .github/skills/publish-release/SKILL.md | 22 +++-- README.md | 4 +- ...odelContextProtocol.Extensions.Apps.csproj | 2 +- src/PACKAGE.md | 14 ++- 8 files changed, 152 insertions(+), 26 deletions(-) create mode 100644 .github/skills/prepare-release/references/readme-content.md diff --git a/.github/release-process.md b/.github/release-process.md index 41f772759..5c973fca7 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -12,11 +12,13 @@ The following process is used when publishing new releases to NuGet.org. From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. +As part of Step 9 (documentation review), the skill also updates the shared embedded NuGet README (`src/PACKAGE.md`) -- adding any newly introduced packages to the package-list closure, applying the correct badge style (`nuget/vpre` for a prerelease series or `nuget/v` for a stable release), adding a release-notes link pointing to the tag being created, and syncing the same closure changes to the root `README.md`. + Review the PR, request changes if needed, and merge when ready. ## 3. Publish the release -After the prepare-release PR is merged, invoke the `publish-release` skill. The skill checks for any late-arriving PRs that could affect the release, refreshes the release notes, and creates a **draft** GitHub release. +After the prepare-release PR is merged, invoke the `publish-release` skill. The skill checks for any late-arriving PRs that could affect the release, refreshes the release notes, re-runs the README content checklist (confirming package closure, badge style, and release-notes link), and creates a **draft** GitHub release. Review the draft release on GitHub, check 'Set as a pre-release' if appropriate, and click 'Publish release'. diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index f881332a1..29bc58394 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -130,13 +130,25 @@ Generate a human-readable diff of the public API surface between the previous re Review repository documentation for changes needed to compensate for or adapt to this release: -1. **NuGet package READMEs** — Validate that code samples in `README.md` and `src/PACKAGE.md` compile against the current SDK. Follow [references/readme-snippets.md](references/readme-snippets.md) for the validation procedure. Propose fixes for any API mismatches. -2. **Conceptual documentation** — Review `docs/` for content affected by the changes in this release. Update references to changed APIs, new features, or removed functionality. -3. **Versioning documentation** — If the release introduces new versioning-relevant policies (new experimental APIs, obsoletion changes), verify `docs/versioning.md` reflects them. -4. **Changelogs** — If the repository contains changelog files (e.g., `CHANGELOG.md`), update them with the release information. If no changelogs exist, skip this sub-step and note it in the summary. +1. **NuGet package READMEs** -- Run the README content checklist from [references/readme-content.md](references/readme-content.md) and validate code samples: + a. **Content checklist** -- Open `src/PACKAGE.md` and verify each item in the checklist: + - **Package-list closure**: every shipping SDK package is listed. If a new package was introduced in this release, add it now. Use non-counting phrasing -- do not say "N main packages". + - **Badge strategy**: all package badges use `nuget/vpre` for a prerelease series or `nuget/v` for a stable release. Switch all badges together if the release type has changed. + - **Release-notes link**: add or update the link to `https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}` for the confirmed release version. The tag does not yet exist at prepare time; the link is forward-referencing and resolves when the GitHub release is published. + - **Root README.md sync**: mirror any package-list closure changes in the root `README.md`. + - **Other salient content**: descriptions, getting-started links, version-specific notes. + b. **Snippet validation** -- Validate that `csharp`-fenced code blocks in `src/PACKAGE.md` and `README.md` compile against the current SDK. Follow [references/readme-snippets.md](references/readme-snippets.md) for the full procedure. Propose fixes for any API mismatches. +2. **Conceptual documentation** -- Review `docs/` for content affected by the changes in this release. Update references to changed APIs, new features, or removed functionality. +3. **Versioning documentation** -- If the release introduces new versioning-relevant policies (new experimental APIs, obsoletion changes), verify `docs/versioning.md` reflects them. +4. **Changelogs** -- If the repository contains changelog files (e.g., `CHANGELOG.md`), update them with the release information. If no changelogs exist, skip this sub-step and note it in the summary. Stage all documentation changes for inclusion in the release commit. +**Edge Cases for README updates:** +- **New package introduced** -- Add it to the package-list closure in `src/PACKAGE.md` and `README.md`. Use the package's `` from its `.csproj` as the short description. +- **Release type changes (prerelease to stable or vice versa)** -- Switch all package badges between `nuget/vpre` and `nuget/v` together. +- **Release tag does not yet exist at prepare time** -- The release-notes link is forward-referencing; it is verified to resolve during the publish-release step. + ### Step 10: Draft Release Notes Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created. diff --git a/.github/skills/prepare-release/references/readme-content.md b/.github/skills/prepare-release/references/readme-content.md new file mode 100644 index 000000000..e7fa16aed --- /dev/null +++ b/.github/skills/prepare-release/references/readme-content.md @@ -0,0 +1,94 @@ +# README Content Checklist + +This reference describes what to review and update in the shared embedded NuGet README +(`src/PACKAGE.md`) and the root repository README (`README.md`) as part of every release. + +## The Shared Embedded README + +All SDK packages embed the **same** README file: `src/PACKAGE.md`. + +Each project packs it identically: + +```xml + +README.md +``` + +Updating `src/PACKAGE.md` updates every package's nuget.org README at once. +There are no per-package README files; `src/ModelContextProtocol.Core/README.md` and +similar paths do not exist. + +## Checklist + +### 1. Package-list closure + +Every shipping SDK package must be listed in the packages section of `src/PACKAGE.md`, +including packages introduced after the initial SDK launch and including the package +being viewed in its own embedded README on nuget.org. + +Current packages to list: +- `ModelContextProtocol.Core` +- `ModelContextProtocol` +- `ModelContextProtocol.AspNetCore` +- `ModelContextProtocol.Extensions.Apps` + +Avoid counting phrases such as "three main packages" -- they become stale when packages +are added. Use a non-counting closure such as "The SDK packages are:" instead. + +When a new package is introduced, add it to the list in both `src/PACKAGE.md` and the +root `README.md` (see section below). + +### 2. Badge strategy + +Each package entry carries a nuget.org version badge. The correct badge endpoint depends +on the release type: + +| Release type | Badge endpoint | Example | +|---|---|---| +| Prerelease series (e.g., `2.0.0-preview.*`) | `nuget/vpre/{package}` | `https://img.shields.io/nuget/vpre/ModelContextProtocol.svg` | +| Stable release | `nuget/v/{package}` | `https://img.shields.io/nuget/v/ModelContextProtocol.svg` | + +`nuget/v` renders only the latest stable version and shows nothing (or a placeholder) +during a prerelease-only series. `nuget/vpre` renders the latest version including +prereleases. Switch all package badges together when the release type changes. + +Verify every badge in `src/PACKAGE.md` uses the correct endpoint for this release. + +### 3. Release-notes link + +`src/PACKAGE.md` must contain one statement linking to the release notes for the +current version: + +```markdown +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}) +for what's new in this version. +``` + +Replace `{version}` with the exact version being released, including any prerelease +suffix (e.g., `2.0.0-preview.2`). + +At prepare time the tag does not yet exist; the link is forward-referencing. The link +resolves once the GitHub release is published during the publish-release step. + +Update this link for every release -- it must point to the tag being created, not a +prior release. + +### 4. Root README.md sync + +The root `README.md` (the GitHub repo readme, NOT packed into packages) has its own +package-list section. Keep it aligned with `src/PACKAGE.md`: +- Same set of packages listed +- Same non-counting closure phrasing +- Badge strategy in `README.md` may also be updated for consistency, but the root + README is visible on GitHub (not nuget.org) so the badge choice is less critical + +## Salient content to review + +Beyond the structural checks above, read the current `src/PACKAGE.md` for any content +that has become stale due to changes in this release: + +- Package descriptions (are they still accurate?) +- Getting-started links (do they resolve and describe the current API?) +- Code samples, if any (do they compile against the current SDK? see + [readme-snippets.md](readme-snippets.md)) +- Any version-specific notes from a prior release that should be removed or updated diff --git a/.github/skills/prepare-release/references/readme-snippets.md b/.github/skills/prepare-release/references/readme-snippets.md index 7d2cd4563..a91efeeff 100644 --- a/.github/skills/prepare-release/references/readme-snippets.md +++ b/.github/skills/prepare-release/references/readme-snippets.md @@ -4,19 +4,21 @@ This reference describes how to validate that C# code samples in README files co ## Which READMEs to Validate -Validate code samples from the **package README** files — these are shipped with NuGet packages and are the primary documentation users see: +Validate code samples from the **package README** and the root repository README: -| README | Package | -|--------|---------| -| `README.md` (root) | ModelContextProtocol | -| `src/ModelContextProtocol.Core/README.md` | ModelContextProtocol.Core | -| `src/ModelContextProtocol.AspNetCore/README.md` | ModelContextProtocol.AspNetCore | +| README | Notes | +|--------|-------| +| `src/PACKAGE.md` | The single shared embedded README packed into every SDK package. This is the primary documentation users see on nuget.org. | +| `README.md` (root) | The GitHub repository readme. Not packed into packages, but visible to developers browsing the repo. | -Sample README files (`samples/*/README.md`) are excluded — the samples themselves are buildable projects and are validated by CI. +All SDK packages embed `src/PACKAGE.md` via their `.csproj` files. There are no per-package +README files; paths such as `src/ModelContextProtocol.Core/README.md` do not exist. + +Sample README files (`samples/*/README.md`) are excluded -- the samples themselves are buildable projects and are validated by CI. ## What to Extract -Extract only fenced code blocks tagged as `csharp` (` ```csharp `). Skip blocks tagged as plain ` ``` ` (shell commands, install instructions) or any other language. +Extract only fenced code blocks tagged as `csharp` (` ```csharp `) from `src/PACKAGE.md` and `README.md`. Skip blocks tagged as plain ` ``` ` (shell commands, install instructions) or any other language. ### Handling Incomplete Snippets diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index 0706cd361..173bb2064 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -69,14 +69,24 @@ Re-categorize all PRs in the commit range (including any new ones from Step 3). 3. **Re-attribute** co-authors for any new PRs by harvesting `Co-authored-by` trailers from all commits in each PR. 4. **Update acknowledgements** to include contributors from new PRs. -### Step 5: Validate README Code Samples +### Step 5: Review README and Validate Code Samples -Verify that all C# code samples in the package README files compile against the current SDK at the merge commit. Follow the [README validation guide](../prepare-release/references/readme-snippets.md) for the full procedure. +Re-run the README content checklist from [../prepare-release/references/readme-content.md](../prepare-release/references/readme-content.md) and validate code samples against the current SDK at the merge commit. Produce final suggestions before the release is created. -1. Extract `csharp`-fenced code blocks from `README.md` and `src/PACKAGE.md` -2. Create a temporary test project at `tests/ReadmeSnippetValidation/` -3. Build and report results -4. Delete the temporary project +1. **Content checklist** -- Open `src/PACKAGE.md` and verify: + - **Package-list closure**: every shipping SDK package is listed. If a new package was introduced after prepare-release ran, it may be missing. + - **Badge strategy**: all badges use `nuget/vpre` for a prerelease or `nuget/v` for a stable release. Verify the badge style is correct for this release type. + - **Release-notes link**: the link points to `https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}` for the tag being created. The tag is about to exist -- verify the URL is correct. + - **Root README.md sync**: confirm the root `README.md` package list is aligned. +2. **Snippet validation** -- Extract `csharp`-fenced code blocks from `src/PACKAGE.md` and `README.md`, build the temporary test project, and report results. Follow [../prepare-release/references/readme-snippets.md](../prepare-release/references/readme-snippets.md) for the full procedure. +3. **Delete** the temporary project after validation. + +If issues are found, present them to the user with proposed fixes. Any fixes must be applied as a separate commit before the draft release is created. + +**Edge Cases:** +- **Stale package closure** -- A package introduced between prepare-release and now may not be listed. Add it to `src/PACKAGE.md` and `README.md`. +- **Wrong badge style for the release type** -- Switch all badges together from `nuget/vpre` to `nuget/v` (or vice versa) if the prepare-release step used the wrong style. +- **Missing or incorrect release-notes link** -- Correct the link to target the exact tag being created, including any prerelease suffix. ### Step 6: Review Sections diff --git a/README.md b/README.md index f0ab4a0ac..fd2ed2ae2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotoco ## Packages -This SDK consists of three main packages: +The SDK packages are: - **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. @@ -14,6 +14,8 @@ This SDK consists of three main packages: - **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. + ## Getting Started To get started, see the [Getting Started](https://csharp.sdk.modelcontextprotocol.io/concepts/getting-started.html) guide in the conceptual documentation for installation instructions, package-selection guidance, and complete examples for both clients and servers. diff --git a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj index 1d7d0dd8a..bd14370d5 100644 --- a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj +++ b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj @@ -5,7 +5,7 @@ true true ModelContextProtocol.Extensions.Apps - MCP Apps extension for the .NET Model Context Protocol (MCP) SDK + MCP Apps extension for building interactive UI applications that render inside MCP hosts. README.md $(NoWarn);MCPEXP001;MCPEXP003 diff --git a/src/PACKAGE.md b/src/PACKAGE.md index d849a8f83..1495cd1fb 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -1,18 +1,22 @@ # MCP C# SDK -[![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) +[![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-preview.1) for what's new in this version. + ## Packages -This SDK consists of three main packages: +The SDK packages are: + +- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. -- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. +- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. -- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. +- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. -- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. ## Getting Started From 84eb6942eb0c713bc8ae5ed2e2b40f71f18106fe Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:15:45 -0400 Subject: [PATCH 46/50] Add DeferChangedEvents() to McpServerPrimitiveCollection for batched change notifications (#1689) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> Co-authored-by: Jeff Handley Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 88 ++- .../McpServerBuilderExtensionsPromptsTests.cs | 44 ++ ...cpServerBuilderExtensionsResourcesTests.cs | 44 ++ .../McpServerBuilderExtensionsToolsTests.cs | 44 ++ .../McpServerPrimitiveCollectionTests.cs | 595 ++++++++++++++++++ 5 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index e126fb13d..aa281989b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,6 +12,15 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; + /// Lock protecting and . + private readonly object _deferralLock = new(); + + /// Depth counter for active scopes. Positive means notifications are deferred. + private int _activeDeferralScopes; + + /// Whether a change occurred while notifications were deferred. + private bool _hasDeferredChangeEvents; + /// /// Initializes a new instance of the class. /// @@ -33,8 +42,85 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// Gets a value that indicates whether there are any primitives in the collection. public bool IsEmpty => _primitives.IsEmpty; + /// + /// Begins a deferred-change scope. notifications are suppressed + /// until the returned scope is disposed, at which point a single notification is raised + /// if any mutation occurred during the scope. Multiple scopes may be active simultaneously; + /// the notification fires once all active scopes have been disposed. + /// + /// An that ends the deferral scope when disposed. + /// + /// The scope is exception-safe: even if an exception is thrown inside a using block, + /// the deferral is ended on dispose. If any mutation occurred before the exception, a single + /// notification is raised. + /// + /// Mutations from any thread during an open scope are coalesced. A single + /// notification fires on the thread that disposes the last active scope, only if at least one + /// mutation occurred. All deferral state transitions are guarded by an internal lock, so + /// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope + /// instance more than once is safe and has no additional effect. + /// + /// + public IDisposable DeferChangedEvents() + { + lock (_deferralLock) + { + _activeDeferralScopes++; + } + return new ChangeDeferralScope(this); + } + /// Raises if there are registered handlers. - protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); + /// + /// If a scope is active, the notification is deferred until all + /// active scopes are disposed. Derived types that override mutation methods and call + /// will automatically participate in deferral. + /// + protected void RaiseChanged() + { + lock (_deferralLock) + { + if (_activeDeferralScopes > 0) + { + _hasDeferredChangeEvents = true; + return; + } + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + private void EndDeferral() + { + bool raise; + lock (_deferralLock) + { + raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents; + if (raise) + { + _hasDeferredChangeEvents = false; + } + } + + if (raise) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private sealed class ChangeDeferralScope : IDisposable + { + private McpServerPrimitiveCollection? _collection; + + public ChangeDeferralScope(McpServerPrimitiveCollection collection) => + _collection = collection; + + public void Dispose() + { + McpServerPrimitiveCollection? collection = Interlocked.Exchange(ref _collection, null); + collection?.EndDeferral(); + } + } /// Gets the with the specified from the collection. /// The name of the primitive to retrieve. diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 4182957cf..03234f6e3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -173,6 +173,50 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt"); } + [Fact] + public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverPrompts = serverOptions.PromptCollection; + Assert.NotNull(serverPrompts); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverPrompts.DeferChangedEvents()) + { + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1")); + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2")); + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(prompts, t => t.Name == "BatchPrompt1"); + Assert.Contains(prompts, t => t.Name == "BatchPrompt2"); + Assert.Contains(prompts, t => t.Name == "BatchPrompt3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task AttributeProperties_Propagated() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d8fd0a231..663c06a7f 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -207,6 +207,50 @@ public async Task Can_Be_Notified_Of_Resource_Changes() Assert.DoesNotContain(resources, t => t.Name == "NewResource"); } + [Fact] + public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverResources = serverOptions.ResourceCollection; + Assert.NotNull(serverResources); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverResources.DeferChangedEvents()) + { + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1")); + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2")); + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(resources, t => t.Name == "BatchResource1"); + Assert.Contains(resources, t => t.Name == "BatchResource2"); + Assert.Contains(resources, t => t.Name == "BatchResource3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task AttributeProperties_Propagated() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 5359ec73c..154c40f94 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -232,6 +232,50 @@ public async Task Can_Be_Notified_Of_Tool_Changes() Assert.DoesNotContain(tools, t => t.Name == "NewTool"); } + [Fact] + public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverTools = serverOptions.ToolCollection; + Assert.NotNull(serverTools); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverTools.DeferChangedEvents()) + { + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1")); + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2")); + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == "BatchTool1"); + Assert.Contains(tools, t => t.Name == "BatchTool2"); + Assert.Contains(tools, t => t.Name == "BatchTool3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task Can_Call_Registered_Tool() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs new file mode 100644 index 000000000..7acac660e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -0,0 +1,595 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerPrimitiveCollectionTests +{ + private static McpServerTool CreateTool(string name) => + McpServerTool.Create(() => name, new() { Name = name }); + + private static McpServerPrompt CreatePrompt(string name) => + McpServerPrompt.Create(() => new ChatMessage(ChatRole.User, name), new() { Name = name }); + + // ------------------------------------------------------------------------- + // Changed event without DeferChangedEvents + // ------------------------------------------------------------------------- + + [Fact] + public void TryAdd_NewTool_ReturnsTrue_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool added = collection.TryAdd(CreateTool("tool1")); + + Assert.True(added); + Assert.Equal(1, changeCount); + } + + [Fact] + public void TryAdd_DuplicateName_ReturnsFalse_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool added = collection.TryAdd(CreateTool("tool1")); + + Assert.False(added); + Assert.Equal(0, changeCount); + } + + [Fact] + public void TryAdd_SameTool_TwiceInSequence_FiresOnlyOnFirst() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool first = collection.TryAdd(CreateTool("tool1")); + bool second = collection.TryAdd(CreateTool("tool1")); + + Assert.True(first); + Assert.False(second); + Assert.Equal(1, changeCount); + } + + [Fact] + public void Remove_ExistingTool_ReturnsTrue_FiresChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool removed = collection.Remove(tool); + + Assert.True(removed); + Assert.Equal(1, changeCount); + } + + [Fact] + public void Remove_NonExistentTool_ReturnsFalse_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool removed = collection.Remove(CreateTool("tool1")); + + Assert.False(removed); + Assert.Equal(0, changeCount); + } + + [Fact] + public void Clear_NonEmptyCollection_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void Clear_EmptyCollection_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- basic deferral behavior + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_NoMutation_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + // no mutations + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_SingleMutation_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(0, changeCount); // not fired yet + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_MixedAddAndRemove_FiresOneChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool2")); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_AddThenRemoveSameTool_FiresOneChanged() + { + // Net effect is no change in contents, but a Changed notification still fires + // because mutations occurred during the scope. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + var tool = CreateTool("tool1"); + collection.TryAdd(tool); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void DeferChangedEvents_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() + { + // The first TryAdd succeeds (mutation), the second fails (no mutation). + // Exactly one Changed fires on dispose. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); // succeeds + collection.TryAdd(CreateTool("tool1")); // fails -- duplicate name + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OnlyFailedTryAdds_DoesNotFireChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); // fails -- already present + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_WithClear_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.Clear(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_Nested_FiresOnceWhenAllScopesDisposed() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); // inner scope disposed, but outer still active + collection.TryAdd(CreateTool("tool3")); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OutOfOrderDisposal_FiresOnceWhenAllScopesDisposed() + { + // Scopes created in order 1, 2 but disposed in reverse order 2, 1. + // Changed should NOT fire when scope 2 is disposed (scope 1 still active). + // Changed SHOULD fire when scope 1 is disposed (last active scope gone). + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + Assert.Equal(0, changeCount); + scope2.Dispose(); // out-of-order dispose; scope1 still active + Assert.Equal(0, changeCount); + + scope1.Dispose(); // last scope disposed; Changed fires now + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_DoubleDisposeSingleScope_DoesNotDecrementCountTwice() + { + // Double-disposing scope1 must not decrement _activeDeferralScopes more than once, + // which would cause Changed to fire while scope2 is still active. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + scope1.Dispose(); + Assert.Equal(0, changeCount); // scope2 still active + + scope1.Dispose(); // second dispose of scope1 -- must be a no-op + Assert.Equal(0, changeCount); // scope2 is still active; Changed must NOT fire yet + + scope2.Dispose(); // now all scopes are disposed; Changed fires + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OutOfOrderDisposalNoMutation_DoesNotFireChanged() + { + // Same out-of-order pattern but with no mutations -- verifies no spurious Changed. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + + scope2.Dispose(); + scope1.Dispose(); + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_AfterScope_ResumesImmediateNotifications() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Equal(1, changeCount); + + // After the scope, each mutation fires immediately + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + [Fact] + public void DeferChangedEvents_DisposeIdempotent_DoesNotFireTwice() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + scope.Dispose(); + Assert.Equal(1, changeCount); + + scope.Dispose(); // second dispose should be a no-op + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_ScopeWithNoHandlers_DoesNotThrow() + { + var collection = new McpServerPrimitiveCollection(); + // no Changed handler registered + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Single(collection); + } + + [Fact] + public void WithoutDeferChangedEvents_EachMutationFiresImmediately() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(1, changeCount); + + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- concurrency + // ------------------------------------------------------------------------- + + [Fact] + public async Task DeferChangedEvents_ConcurrentMutations_FiresExactlyOneChanged() + { + const int threadCount = 10; + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + using (collection.DeferChangedEvents()) + { + await Task.WhenAll(Enumerable.Range(0, threadCount).Select(i => + Task.Run(() => collection.TryAdd(CreateTool($"tool{i}")), TestContext.Current.CancellationToken))); + } + + Assert.Equal(1, changeCount); + Assert.Equal(threadCount, collection.Count); + } + + [Fact] + public async Task DeferChangedEvents_MutationRacingWithDispose_NotificationNotLost() + { + // Run many iterations to reliably exercise the race between a mutation + // and disposal of the outermost scope. With the lock-free implementation + // the race could cause the notification to be lost; the lock-based + // implementation must always fire exactly one notification. + for (int iteration = 0; iteration < 200; iteration++) + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + var scope = collection.DeferChangedEvents(); + + // Run the mutation and the dispose concurrently. + var addTask = Task.Run(() => collection.TryAdd(CreateTool("tool1")), TestContext.Current.CancellationToken); + var disposeTask = Task.Run(() => scope.Dispose(), TestContext.Current.CancellationToken); + + await Task.WhenAll(addTask, disposeTask); + + // Regardless of ordering: exactly one notification must have fired. + // - If TryAdd runs before Dispose: the mutation marks _pendingChange; + // Dispose sees depth -> 0 with a pending change and fires. + // - If Dispose runs before TryAdd: depth is already 0 when TryAdd + // calls RaiseChanged, so it fires immediately. + // The lock prevents the third (buggy) interleaving where Dispose + // sees no pending change and TryAdd sees depth > 0, dropping the event. + Assert.Equal(1, changeCount); + } + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- derived-type coalescing + // ------------------------------------------------------------------------- + + private sealed class TrackingCollection : McpServerPrimitiveCollection + { + public void RaiseChangedDirectly() => RaiseChanged(); + } + + [Fact] + public void DeferChangedEvents_DerivedTypeCallsRaiseChanged_Coalesces() + { + // Verify that derived types calling RaiseChanged() directly (the path + // McpServerResourceCollection and other subclasses rely on) are gated + // by the same deferral check. + var collection = new TrackingCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.RaiseChangedDirectly(); + collection.RaiseChangedDirectly(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately() + { + var collection = new TrackingCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.RaiseChangedDirectly(); + Assert.Equal(1, changeCount); + + collection.RaiseChangedDirectly(); + Assert.Equal(2, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- exception safety + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_ExceptionDuringScope_StillFiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_ExceptionDuringScope_ResumesImmediateNotificationsAfterward() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + + // Deferral must be fully reset: mutations outside the scope fire immediately. + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- prompt collection coverage + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_PromptCollection_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreatePrompt("prompt1")); + collection.TryAdd(CreatePrompt("prompt2")); + collection.TryAdd(CreatePrompt("prompt3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Equal(3, collection.Count); + } +} From 3e7ca321304461f566319b70ad47297317b4c172 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 9 Jul 2026 13:39:38 -0400 Subject: [PATCH 47/50] Release v2.0.0-preview.2 (#1695) 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 5758c4936..5e1e4951e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/modelcontextprotocol/csharp-sdk git 2.0.0 - preview.1 + preview.2 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 1495cd1fb..749a21f47 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-preview.1) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-preview.2) for what's new in this version. ## Packages From b2a40128fc9ae419f5cd525f2c5b719e7f0d311c Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:49:24 -0700 Subject: [PATCH 48/50] Re-enable http-custom-headers conformance scenario (#1655) (#1691) Co-authored-by: Tarek Mahmoud Sayed --- package-lock.json | 8 +- package.json | 2 +- tests/Common/Utils/NodeHelpers.cs | 146 +++++++++++++++++- .../ClientConformanceTests.cs | 22 ++- 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc400cc42..6cc1c80aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,9 +23,9 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.5.tgz", - "integrity": "sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==", + "version": "0.2.0-alpha.8", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.8.tgz", + "integrity": "sha512-ktbvq6ftvs23TjZ/WVmTKZHue8dqoUF5bBG3Qd5pbawfTyuveZw93o07uQij8tslBlccpPVS5jE212G+SnLo2A==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 3dbb9fba1..c1ad1b2b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 45ecdcde2..aad326ba4 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -191,6 +191,19 @@ public static bool IsNodeInstalled() public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + /// + /// Checks whether the installed conformance package contains a spec-conformant + /// http-custom-headers scenario. Prereleases 0.2.0-alpha.5 through 0.2.0-alpha.7 + /// annotated a number-typed parameter with x-mcp-header, which SEP-2243 + /// forbids; a conformant client excludes that tool, so every positive check in the + /// scenario fails. Conformance PR #371 fixed the scenario and shipped it in 0.2.0-alpha.8, + /// so this gate requires at least that version. Unlike , + /// this comparison honors the semver prerelease so older 0.2.0 prereleases are skipped + /// rather than failing spuriously. + /// + public static bool HasConformantCustomHeadersScenario() + => IsInstalledConformanceVersionAtLeast("0.2.0-alpha.8"); + /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance /// PR #275) is available, by reading the installed conformance package version @@ -256,7 +269,138 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion } /// - /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// Returns when the conformance package installed in node_modules + /// has a semver precedence greater than or equal to , + /// honoring the prerelease component (e.g. "0.2.0-alpha.8"). Returns + /// when no version can be determined. + /// + private static bool IsInstalledConformanceVersionAtLeast(string minimumVersion) + { + var installed = GetInstalledConformanceVersionString(); + return installed is not null && CompareSemVer(installed, minimumVersion) >= 0; + } + + /// + /// Reads the raw version string of the conformance package installed in node_modules, + /// preserving any prerelease/build suffix. Returns if it cannot be + /// determined. + /// + private static string? GetInstalledConformanceVersionString() + { + try + { + var repoRoot = FindRepoRoot(); + var packageJsonPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + + if (!File.Exists(packageJsonPath)) + { + return null; + } + + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("version", out var versionElement)) + { + return versionElement.GetString(); + } + + return null; + } + catch + { + return null; + } + } + + /// + /// Compares two semantic version strings by precedence, honoring the prerelease component + /// per the SemVer 2.0.0 rules used here (numeric identifiers compare numerically, a version + /// with a prerelease has lower precedence than the same version without one, and a shorter + /// set of prerelease identifiers has lower precedence when all preceding ones are equal). + /// Build metadata (after '+') is ignored. Returns a negative value when + /// precedes , zero when equal, and a positive value otherwise. + /// + private static int CompareSemVer(string a, string b) + { + var (coreA, preA) = SplitSemVer(a); + var (coreB, preB) = SplitSemVer(b); + + var coreCompare = coreA.CompareTo(coreB); + if (coreCompare != 0) + { + return coreCompare; + } + + // A version without a prerelease outranks one with a prerelease. + if (preA.Length == 0 && preB.Length == 0) + { + return 0; + } + if (preA.Length == 0) + { + return 1; + } + if (preB.Length == 0) + { + return -1; + } + + var count = Math.Min(preA.Length, preB.Length); + for (var i = 0; i < count; i++) + { + var idA = preA[i]; + var idB = preB[i]; + var numA = int.TryParse(idA, out var na); + var numB = int.TryParse(idB, out var nb); + + int cmp; + if (numA && numB) + { + cmp = na.CompareTo(nb); + } + else if (numA) + { + // Numeric identifiers always have lower precedence than alphanumeric ones. + cmp = -1; + } + else if (numB) + { + cmp = 1; + } + else + { + cmp = string.CompareOrdinal(idA, idB); + } + + if (cmp != 0) + { + return cmp; + } + } + + return preA.Length.CompareTo(preB.Length); + } + + /// + /// Splits a semver string into its numeric core (major.minor.patch) and its prerelease + /// identifiers, ignoring any build metadata after '+'. Missing core components default to 0. + /// + private static (Version Core, string[] Prerelease) SplitSemVer(string version) + { + var withoutBuild = version.Split(new[] { '+' }, 2)[0]; + var parts = withoutBuild.Split(new[] { '-' }, 2); + var prerelease = parts.Length > 1 && parts[1].Length > 0 + ? parts[1].Split('.') + : Array.Empty(); + + var coreParts = parts[0].Split('.'); + int Part(int index) => index < coreParts.Length && int.TryParse(coreParts[index], out var v) ? v : 0; + var core = new Version(Part(0), Part(1), Part(2)); + + return (core, prerelease); + } + + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) /// shared by the server-side conformance tests. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 2990b3d83..e21bf4e67 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -17,6 +17,7 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); + public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario(); public ClientConformanceTests(ITestOutputHelper output) { @@ -66,10 +67,6 @@ public async Task RunConformanceTest(string scenario) [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-invalid-tool-headers")] - // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, - // which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers, - // failing every positive check. Re-enable once a conformant conformance package ships (#1655). - // [InlineData("http-custom-headers")] public async Task RunConformanceTest_Sep2243(string scenario) { // Run the conformance test suite @@ -80,6 +77,23 @@ public async Task RunConformanceTest_Sep2243(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // The http-custom-headers scenario needs a tighter gate than the other SEP-2243 scenarios: + // conformance 0.2.0-alpha.5 through 0.2.0-alpha.7 shipped it with an x-mcp-header on a + // number-typed parameter (forbidden by SEP-2243), which a conformant client excludes, + // failing every positive check. It was fixed upstream in 0.2.0-alpha.8 (conformance #371), + // so require at least that version to avoid spurious failures on older 0.2.0 prereleases. + [Theory(Skip = "Conformant http-custom-headers scenario not available (requires conformance package >= 0.2.0-alpha.8).", SkipUnless = nameof(HasConformantCustomHeadersScenario))] + [InlineData("http-custom-headers")] + public async Task RunConformanceTest_Sep2243_CustomHeaders(string scenario) + { + // Run the conformance test suite + var result = await RunClientConformanceScenario(scenario); + + // Report the results + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario) { // Construct an absolute path to the conformance client executable From 878e00290fc082f77137c15f56177f1e472c9389 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 10 Jul 2026 16:45:49 -0700 Subject: [PATCH 49/50] Resolve request-scoped client capabilities in outgoing filters and add coverage Source per-request client capabilities/info from JsonRpcMessageContext so outgoing message filters (built from responses) observe them, not just request handlers. Document that the shared client-info sync is best-effort for endpoint-name logging only, and clarify request-scoped semantics on McpServer.ClientCapabilities/ClientInfo. Pin UrlElicitationTests to the 2025-11-25 handshake revision for root-server capability assertions and add draft tests for request-scoped and outgoing-filter observation. --- .../Server/DestinationBoundMcpServer.cs | 12 +-- .../Server/McpServer.cs | 25 +++++- .../Server/McpServerImpl.cs | 19 ++-- .../Client/McpClientMetaTests.cs | 90 ++++++++++++++++++- .../Protocol/UrlElicitationTests.cs | 30 ++++--- 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index f75f547e8..c54ccbb50 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,12 +5,12 @@ namespace ModelContextProtocol.Server; #pragma warning disable MCPEXP002 -internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcRequest? jsonRpcRequest = null) : McpServer +internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer #pragma warning restore MCPEXP002 { - private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(jsonRpcRequest, server.NegotiatedProtocolVersion); - private readonly ClientCapabilities? _requestClientCapabilities = jsonRpcRequest?.Context?.ClientCapabilities; - private readonly Implementation? _requestClientInfo = jsonRpcRequest?.Context?.ClientInfo; + private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(requestContext, server.NegotiatedProtocolVersion); + private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities; + private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo; public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; @@ -137,7 +137,7 @@ private static async Task SendRequestViaMrtrAsync( }; } - private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request, string? negotiatedProtocolVersion) + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext, string? negotiatedProtocolVersion) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - request?.Context?.ProtocolVersion ?? negotiatedProtocolVersion); + requestContext?.ProtocolVersion ?? negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 5f8ebf69a..a51010dc0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -21,9 +21,19 @@ protected McpServer() /// /// /// - /// These capabilities are established during the initialization handshake and indicate - /// which features the client supports, such as sampling, roots, and other - /// protocol-specific functionality. + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier), these + /// capabilities are established once during initialization and are session-scoped: they are available both + /// on the root and on the server exposed to request handlers. + /// + /// + /// On the 2026-07-28 revision and later (SEP-2575) there is no initialize handshake; the client + /// declares its capabilities per-request in _meta, and the server MUST NOT infer them from previous + /// requests. In that mode this property is only meaningful on the request-scoped server accessed via + /// the Server property of the passed to a handler; on the + /// root (for example one constructed manually over a + /// ) it is . + /// It is also in stateless transport mode, where server-to-client requests are + /// unsupported. /// /// /// Server implementations can check these capabilities to determine which features @@ -38,7 +48,14 @@ protected McpServer() /// /// /// This property contains identification information about the client that has connected to this server, - /// including its name and version. This information is provided by the client during initialization. + /// including its name and version. + /// + /// + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier) this + /// information is provided once during initialization and is session-scoped. On the 2026-07-28 + /// revision and later it is carried per-request in _meta, so read it from the request-scoped server + /// accessed via the Server property of the passed to a handler + /// rather than from the root . /// /// /// Server implementations can use this information for logging, tracking client versions, diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 05a4e676c..8576a59c9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -158,10 +158,11 @@ void Register(McpServerPrimitiveCollection? collection, /// /// /// 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 are consumed request-scoped by - /// and are not persisted to server-wide state. For legacy clients - /// the per-request values are absent and the built-in filter is a no-op (the values were captured during - /// the initialize handler). + /// 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 legacy 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) { @@ -190,6 +191,12 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner (_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; } @@ -1615,7 +1622,7 @@ async ValueTask InvokeScopedAsync( private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest); + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest.Context); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { @@ -1728,7 +1735,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList { // Ensure message has a Context so Items can be shared through the pipeline message.Context ??= new(); - var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message as JsonRpcRequest), message); + var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message.Context), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index af2043d0e..3afbb52eb 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -16,7 +16,8 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); - private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; + private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) @@ -42,6 +43,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // Capture the _meta the server receives on the initialize request so tests can // assert that McpClientOptions.InitializeMeta is threaded through the handshake. mcpServerBuilder.WithMessageFilters(filters => + { filters.AddIncomingFilter(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request) @@ -50,7 +52,23 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } await next(context, cancellationToken); - })); + }); + + // Capture the request-scoped client info/capabilities observed while an outgoing response flows + // through the outgoing filter pipeline. Gated on a unique client name so only the dedicated test + // triggers it. This exercises that DestinationBoundMcpServer resolves per-request _meta for + // responses (whose Context is the originating request's Context), not just requests. + filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse && + context.Server.ClientInfo is { Name: "outgoing-filter-client" } info) + { + _outgoingFilterObserved.TrySetResult((info, context.Server.ClientCapabilities)); + } + + await next(context, cancellationToken); + }); + }); } [Fact] @@ -161,7 +179,7 @@ public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseReques }, Meta = new JsonObject { - [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( new ClientCapabilities { Sampling = new SamplingCapability() }, McpJsonUtilities.DefaultOptions), }, @@ -176,7 +194,7 @@ public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseReques }, Meta = new JsonObject { - [ClientCapabilitiesMetaKey] = JsonSerializer.SerializeToNode( + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( new ClientCapabilities(), McpJsonUtilities.DefaultOptions), }, @@ -226,6 +244,70 @@ public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo Assert.Equal("request-scoped-client:9.9.9", text); } + [Fact] + public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHandlerObservesThem() + { + ClientCapabilities? handlerObservedCapabilities = null; + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + handlerObservedCapabilities = context.Server.ClientCapabilities; + return "ok"; + }, + new() { Name = "capability_probe_tool" })); + + var clientOptions = new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + // Under the 2026-07-28 revision capabilities are request-scoped, so the root server (outside any + // request) never exposes them, whereas a request handler observes the per-request _meta values. + Assert.Null(Server.ClientCapabilities); + + await client.CallToolAsync("capability_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(handlerObservedCapabilities); + Assert.NotNull(handlerObservedCapabilities!.Elicitation); + Assert.Null(Server.ClientCapabilities); + } + + [Fact] + public async Task OutgoingMessageFilter_UnderJuly2026Protocol_ObservesRequestScopedClientInfoAndCapabilities() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + () => "ok", + new() { Name = "outgoing_probe_tool" })); + + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "outgoing-filter-client", Version = "3.2.1" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("outgoing_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + var (info, capabilities) = await _outgoingFilterObserved.Task + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.NotNull(info); + Assert.Equal("outgoing-filter-client", info!.Name); + Assert.Equal("3.2.1", info.Version); + Assert.NotNull(capabilities); + Assert.NotNull(capabilities!.Elicitation); + } + [Fact] public async Task ResourceReadWithMetaFields() { diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs index bf4c67d21..4963a0a10 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -190,6 +190,16 @@ await request.Server.ElicitAsync(new() }); } + // These tests assert on the root server's ClientCapabilities (see AssertServerElicitationCapability), + // which is only session-scoped under the initialize-handshake revisions. Pin to the latest such revision + // so the capabilities negotiated during initialize are observable on the root McpServer. Request-scoped + // capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests. + private Task CreateLegacyClientForServer(McpClientOptions clientOptions) + { + clientOptions.ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + return CreateMcpClientForServer(clientOptions); + } + [Fact] public async Task Can_Elicit_OutOfBand_With_Url() { @@ -198,7 +208,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() string? capturedMessage = null; var completionNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -283,7 +293,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() [Fact] public async Task UrlElicitation_User_Can_Decline() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -322,7 +332,7 @@ public async Task UrlElicitation_User_Can_Decline() [Fact] public async Task UrlElicitation_User_Can_Cancel() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -360,7 +370,7 @@ public async Task UrlElicitation_User_Can_Cancel() [Fact] public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -385,7 +395,7 @@ public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() [Fact] public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -406,7 +416,7 @@ public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() [Fact] public async Task UrlElicitation_BlankCapability_Allows_Only_Form() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -435,7 +445,7 @@ public async Task UrlElicitation_BlankCapability_Allows_Only_Form() [Fact] public async Task FormElicitation_UrlOnlyCapability_NotSupported() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -474,7 +484,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -504,7 +514,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() [Fact] public async Task UrlElicitationRequired_Exception_Propagates_To_Client() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -532,7 +542,7 @@ public async Task FormElicitation_Requires_RequestedSchema() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { From c0e58e86c06d619df8b822b25f967523c257dc8d Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Fri, 10 Jul 2026 17:03:28 -0700 Subject: [PATCH 50/50] Consolidate July2026 protocol helper and document empty-capabilities fallback Remove the duplicate IsJuly2026OrLaterProtocolRequest in DestinationBoundMcpServer and route through a single internal overload on McpServerImpl. Clarify why the request-scoped ClientCapabilities fallback returns a fresh instance rather than a shared mutable singleton. --- .../Server/DestinationBoundMcpServer.cs | 10 ++++------ src/ModelContextProtocol.Core/Server/McpServerImpl.cs | 6 +++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index c54ccbb50..7aab34826 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Server; internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer #pragma warning restore MCPEXP002 { - private readonly bool _isJuly2026OrLaterRequest = IsJuly2026OrLaterProtocolRequest(requestContext, server.NegotiatedProtocolVersion); + private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext); private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities; private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo; @@ -33,7 +33,9 @@ public override ClientCapabilities? ClientCapabilities // 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. + // "no declared capabilities for this request", represented by an empty object. A fresh instance is + // returned deliberately: ClientCapabilities is a mutable DTO handed to user handlers, so a shared + // static empty instance could be mutated and leak across requests. if (_isJuly2026OrLaterRequest) { return _requestClientCapabilities ?? new ClientCapabilities(); @@ -136,8 +138,4 @@ private static async Task SendRequestViaMrtrAsync( Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), }; } - - private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext, string? negotiatedProtocolVersion) - => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - requestContext?.ProtocolVersion ?? negotiatedProtocolVersion); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 8576a59c9..d98a6c7c8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1790,8 +1790,12 @@ internal bool HasStatefulTransport() => /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + IsJuly2026OrLaterProtocolRequest(request?.Context); + + /// + internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); + requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport();