Implementation for SEP-2243 Http Standardization (#1553)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com> Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3118fcd6cb
commit
b8c4d951ec
33 changed files with 2854 additions and 28 deletions
2
.github/workflows/ci-build-test.yml
vendored
2
.github/workflows/ci-build-test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,6 +1,9 @@
|
|||
# Cake tools
|
||||
/[Tt]ools/
|
||||
|
||||
# Language server cache
|
||||
*.lscache
|
||||
|
||||
# Build output
|
||||
[Bb]uildArtifacts/
|
||||
# Build results
|
||||
|
|
|
|||
|
|
@ -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 <xref:ModelContextProtocol.Server.McpHeaderAttribute> 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: <region value>
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
|
|
|
|||
237
package-lock.json
generated
237
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
78
src/Common/McpHttpHeaders.cs
Normal file
78
src/Common/McpHttpHeaders.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for MCP-specific HTTP header names used in the Streamable HTTP transport.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per RFC 9110, HTTP header names are case-insensitive. Clients and servers must
|
||||
/// use case-insensitive comparisons when processing these headers.
|
||||
/// </remarks>
|
||||
internal static class McpHttpHeaders
|
||||
{
|
||||
/// <summary>
|
||||
/// The minimum protocol version that requires standard MCP request headers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Servers enforce missing <c>Mcp-Method</c> and <c>Mcp-Name</c> headers as errors only when
|
||||
/// the client's <c>MCP-Protocol-Version</c> header indicates this version or later.
|
||||
/// Clients using older versions are not required to send these headers.
|
||||
/// </remarks>
|
||||
public static readonly string MinVersionForStandardHeaders = "DRAFT-2026-v1";
|
||||
|
||||
/// <summary>The session identifier header.</summary>
|
||||
public const string SessionId = "Mcp-Session-Id";
|
||||
|
||||
/// <summary>The negotiated protocol version header.</summary>
|
||||
public const string ProtocolVersion = "MCP-Protocol-Version";
|
||||
|
||||
/// <summary>The last event ID for SSE stream resumption.</summary>
|
||||
public const string LastEventId = "Last-Event-ID";
|
||||
|
||||
/// <summary>
|
||||
/// The JSON-RPC method being invoked (e.g., "tools/call", "resources/read").
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Required on all Streamable HTTP POST requests. The value must match the <c>method</c>
|
||||
/// field in the JSON-RPC request body.
|
||||
/// </remarks>
|
||||
public const string Method = "Mcp-Method";
|
||||
|
||||
/// <summary>
|
||||
/// The name or URI of the target resource for the request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Required for <c>tools/call</c>, <c>resources/read</c>, and <c>prompts/get</c> requests.
|
||||
/// For <c>tools/call</c> and <c>prompts/get</c>, the value is taken from <c>params.name</c>.
|
||||
/// For <c>resources/read</c>, the value is taken from <c>params.uri</c>.
|
||||
/// </remarks>
|
||||
public const string Name = "Mcp-Name";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix for custom parameter headers (<c>Mcp-Param-{Name}</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When a tool's <c>inputSchema</c> includes properties annotated with <c>x-mcp-header</c>,
|
||||
/// clients mirror those parameter values into HTTP headers using this prefix.
|
||||
/// </remarks>
|
||||
public const string ParamPrefix = "Mcp-Param-";
|
||||
|
||||
/// <summary>
|
||||
/// Key used in <see cref="JsonRpcMessageContext.Items"/> to store the tool
|
||||
/// definition for the current request, enabling the transport to add custom parameter headers.
|
||||
/// </summary>
|
||||
internal const string ToolContextKey = "Mcp.Tool";
|
||||
|
||||
/// <summary>
|
||||
/// Protocol versions that require standard MCP request headers (Mcp-Method, Mcp-Name).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> s_versionsWithStandardHeaders = new(StringComparer.Ordinal)
|
||||
{
|
||||
MinVersionForStandardHeaders,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if the given protocol version requires standard MCP request headers.
|
||||
/// </summary>
|
||||
public static bool SupportsStandardHeaders(string? protocolVersion)
|
||||
=> !string.IsNullOrEmpty(protocolVersion) && s_versionsWithStandardHeaders.Contains(protocolVersion!);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
<ItemGroup>
|
||||
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
|
||||
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
|
||||
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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 Microsoft.Net.Http.Headers;
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
|
||||
|
|
@ -79,6 +82,12 @@ internal sealed class StreamableHttpHandler(
|
|||
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 @@ internal sealed class StreamableHttpHandler(
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context containing the request headers.</param>
|
||||
/// <param name="message">The JSON-RPC message to validate against.</param>
|
||||
/// <param name="toolCollection">The tool collection to look up tool schemas for parameter header validation.</param>
|
||||
/// <param name="errorMessage">Set to the error message if validation fails; null otherwise.</param>
|
||||
/// <returns>True if validation passes; false otherwise.</returns>
|
||||
internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage message, McpServerPrimitiveCollection<McpServerTool>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all parameters annotated with <c>x-mcp-header</c> in the tool's input schema
|
||||
/// have corresponding <c>Mcp-Param-*</c> headers present in the request, and that any present
|
||||
/// <c>Mcp-Param-*</c> headers have valid encoding.
|
||||
/// </summary>
|
||||
private static bool ValidateCustomParamHeaders(
|
||||
HttpContext context,
|
||||
JsonRpcMessage message,
|
||||
McpServerPrimitiveCollection<McpServerTool>? 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<string>();
|
||||
}
|
||||
|
||||
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<char> s_validHeaderValueChars =
|
||||
SearchValues.Create("\t !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~");
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private static bool IsValidHeaderValue(string value) =>
|
||||
value.AsSpan().IndexOfAnyExcept(s_validHeaderValueChars) < 0;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two decoded header values, using numeric comparison for number-typed
|
||||
/// parameters to handle cross-SDK representation differences (e.g., "42" vs "42.0").
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,8 @@ public abstract partial class McpClient : McpSession
|
|||
RequestOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ToolCacheClearing?.Invoke();
|
||||
|
||||
List<McpClientTool>? tools = null;
|
||||
ListToolsRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() };
|
||||
do
|
||||
|
|
@ -184,6 +186,15 @@ public abstract partial class McpClient : McpSession
|
|||
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 abstract partial class McpClient : McpSession
|
|||
return tools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when a tool definition is discovered from a <c>tools/list</c> response.
|
||||
/// </summary>
|
||||
internal Action<Tool>? ToolDiscovered;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when a tool definition is rejected due to invalid <c>x-mcp-header</c> annotations.
|
||||
/// </summary>
|
||||
internal Action<Tool, string>? ToolRejected;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked before enumerating tools to clear any previously cached tool definitions.
|
||||
/// </summary>
|
||||
internal Action? ToolCacheClearing;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of available tools from the server.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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<string, Tool> _toolCache = new(StringComparer.Ordinal);
|
||||
|
||||
private ServerCapabilities? _serverCapabilities;
|
||||
private Implementation? _serverInfo;
|
||||
|
|
@ -67,6 +69,10 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
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 sealed partial class McpClientImpl : McpClient
|
|||
|
||||
/// <inheritdoc/>
|
||||
public override Task<JsonRpcResponse> 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<string>() is { } toolName &&
|
||||
_toolCache.TryGetValue(toolName, out var tool))
|
||||
{
|
||||
request.Context ??= new();
|
||||
request.Context.Items ??= new Dictionary<string, object?>();
|
||||
request.Context.Items[McpHttpHeaders.ToolContextKey] = tool;
|
||||
}
|
||||
|
||||
return _sessionHandler.SendRequestAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||
|
|
@ -686,4 +707,7 @@ internal sealed partial class McpClientImpl : McpClient
|
|||
[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);
|
||||
|
||||
}
|
||||
|
|
|
|||
147
src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
Normal file
147
src/ModelContextProtocol.Core/Client/McpHeaderExtractor.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts parameter values from tool call arguments and adds them as HTTP headers
|
||||
/// based on <c>x-mcp-header</c> schema extensions.
|
||||
/// </summary>
|
||||
internal static class McpHeaderExtractor
|
||||
{
|
||||
private const string XMcpHeaderProperty = "x-mcp-header";
|
||||
|
||||
/// <summary>
|
||||
/// Adds custom parameter headers to an HTTP request based on a tool's schema extensions.
|
||||
/// </summary>
|
||||
/// <param name="headers">The HTTP request headers to add to.</param>
|
||||
/// <param name="tool">The tool definition containing the input schema with x-mcp-header annotations.</param>
|
||||
/// <param name="arguments">The arguments being passed to the tool call.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a tool's <c>inputSchema</c> for valid <c>x-mcp-header</c> annotations.
|
||||
/// Returns <see langword="true"/> if the tool is valid; <see langword="false"/> with a reason if it should be rejected.
|
||||
/// </summary>
|
||||
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<string>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using System.Diagnostics;
|
|||
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 sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
|
||||
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 sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
{
|
||||
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 sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string property from the JSON-RPC params object.
|
||||
/// </summary>
|
||||
private static string? GetParamsStringProperty(JsonNode? paramsNode, string propertyName)
|
||||
{
|
||||
if (paramsNode is JsonObject obj && obj.TryGetPropertyValue(propertyName, out var value))
|
||||
{
|
||||
return value?.GetValue<string>();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the arguments property from a tools/call params object as a JsonElement.
|
||||
/// </summary>
|
||||
private static JsonElement? GetParamsArguments(JsonNode? paramsNode)
|
||||
{
|
||||
if (paramsNode is JsonObject obj && obj.TryGetPropertyValue("arguments", out var argsNode) && argsNode is not null)
|
||||
{
|
||||
return JsonSerializer.Deserialize<JsonElement>(argsNode, McpJsonUtilities.JsonContext.Default.JsonElement);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks state across SSE stream connections.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,26 @@ namespace ModelContextProtocol;
|
|||
/// </summary>
|
||||
public enum McpErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that HTTP headers do not match the corresponding values in the request body,
|
||||
/// or that required headers are missing or malformed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This error is returned when a Streamable HTTP request fails header validation. Validation failures include:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A required standard header (<c>Mcp-Method</c>, <c>Mcp-Name</c>) is missing.</description></item>
|
||||
/// <item><description>A header value does not match the corresponding request body value.</description></item>
|
||||
/// <item><description>A Base64-encoded header value cannot be decoded.</description></item>
|
||||
/// <item><description>A header value contains invalid characters.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// This error code is in the JSON-RPC implementation-defined server error range (<c>-32000</c> to <c>-32099</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
HeaderMismatch = -32001,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the requested resource could not be found.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
"2025-03-26",
|
||||
"2025-06-18",
|
||||
LatestProtocolVersion,
|
||||
"DRAFT-2026-v1",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -159,7 +160,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
|
|||
/// completes its channel with a <see cref="ClientTransportClosedException"/>, the wrapped
|
||||
/// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned.
|
||||
/// </summary>
|
||||
internal Task<ClientCompletionDetails> CompletionTask =>
|
||||
internal Task<ClientCompletionDetails> CompletionTask =>
|
||||
field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion);
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
|
||||
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
|
||||
<Compile Include="..\Common\EncodingUtilities.cs" Link="EncodingUtilities.cs" />
|
||||
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
|
||||
<Compile Include="..\Common\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
|
||||
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
243
src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
Normal file
243
src/ModelContextProtocol.Core/Protocol/McpHeaderEncoder.cs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes parameter values for use in MCP HTTP headers according to the
|
||||
/// HTTP Standardization SEP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Encoding rules:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Plain ASCII values (0x20-0x7E): sent as-is</description></item>
|
||||
/// <item><description>Values with leading/trailing whitespace: Base64 encoded with <c>=?base64?{value}?=</c> wrapper</description></item>
|
||||
/// <item><description>Non-ASCII characters: Base64 encoded</description></item>
|
||||
/// <item><description>Control characters: Base64 encoded</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class McpHeaderEncoder
|
||||
{
|
||||
private const string Base64Prefix = "=?base64?";
|
||||
private const string Base64Suffix = "?=";
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a string parameter value for use in an HTTP header.
|
||||
/// </summary>
|
||||
/// <param name="value">The string value to encode.</param>
|
||||
/// <returns>
|
||||
/// The encoded header value, or <see langword="null"/> if <paramref name="value"/> is <see langword="null"/>.
|
||||
/// </returns>
|
||||
public static string? EncodeValue(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (RequiresBase64Encoding(value))
|
||||
{
|
||||
return EncodeAsBase64(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a boolean parameter value for use in an HTTP header.
|
||||
/// </summary>
|
||||
/// <param name="value">The boolean value to encode.</param>
|
||||
/// <returns>The encoded header value (<c>"true"</c> or <c>"false"</c>).</returns>
|
||||
public static string EncodeValue(bool value) => value ? "true" : "false";
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a numeric parameter value for use in an HTTP header.
|
||||
/// </summary>
|
||||
/// <param name="value">The numeric value to encode.</param>
|
||||
/// <returns>The decimal string representation of the value.</returns>
|
||||
public static string EncodeValue(long value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a numeric parameter value for use in an HTTP header.
|
||||
/// </summary>
|
||||
/// <param name="value">The numeric value to encode.</param>
|
||||
/// <returns>The decimal string representation of the value.</returns>
|
||||
public static string EncodeValue(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a parameter value for use in an HTTP header.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to encode. Supported types are string, numeric types, and boolean.</param>
|
||||
/// <returns>
|
||||
/// The encoded header value, or <see langword="null"/> if the value is <see langword="null"/>
|
||||
/// or is not a supported type (string, numeric, or boolean).
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a header value that may be Base64-encoded according to SEP rules.
|
||||
/// </summary>
|
||||
/// <param name="headerValue">The header value to decode.</param>
|
||||
/// <returns>
|
||||
/// The decoded string value, or <see langword="null"/> if decoding fails.
|
||||
/// If the value is not Base64-encoded, returns the original value.
|
||||
/// </returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="JsonElement"/> value to an encoded header value string.
|
||||
/// </summary>
|
||||
/// <param name="element">The JSON element to convert.</param>
|
||||
/// <returns>The encoded header value, or <see langword="null"/> if the element is not a supported primitive type.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="JsonNode"/> value to an encoded header value string.
|
||||
/// </summary>
|
||||
/// <param name="node">The JSON node to convert.</param>
|
||||
/// <returns>The encoded header value, or <see langword="null"/> if the node is not a <see cref="JsonValue"/> or is not a supported primitive type.</returns>
|
||||
public static string? ConvertToHeaderValue(JsonNode node)
|
||||
{
|
||||
if (node is not JsonValue jsonValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
object? value = jsonValue.GetValueKind() switch
|
||||
{
|
||||
JsonValueKind.String => jsonValue.GetValue<string>(),
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
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 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
IsError = allErrorContent && hasAny
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Post-processes the input schema to add <c>x-mcp-header</c> extensions based on
|
||||
/// <see cref="McpHeaderAttribute"/> on method parameters.
|
||||
/// </summary>
|
||||
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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var param in method.GetParameters())
|
||||
{
|
||||
var attr = param.GetCustomAttribute<McpHeaderAttribute>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
81
src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
Normal file
81
src/ModelContextProtocol.Core/Server/McpHeaderAttribute.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that a tool parameter should be mirrored as an HTTP header in client requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When applied to a parameter, the SDK will include an <c>x-mcp-header</c> extension property
|
||||
/// in the parameter's JSON schema. Clients will then mirror this parameter's value into an
|
||||
/// HTTP header named <c>Mcp-Param-{Name}</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// [McpServerTool]
|
||||
/// public static string ExecuteSql(
|
||||
/// [McpHeader("Region")] string region,
|
||||
/// string query)
|
||||
/// {
|
||||
/// // The client will add header: Mcp-Param-Region: {region value}
|
||||
/// }
|
||||
/// </code>
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
|
||||
public sealed class McpHeaderAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpHeaderAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">
|
||||
/// The name portion of the header. The full header name will be <c>Mcp-Param-{name}</c>.
|
||||
/// Must contain only ASCII characters (0x21-0x7E, excluding space and colon).
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The name is null, empty, or contains invalid characters.
|
||||
/// </exception>
|
||||
public McpHeaderAttribute(string name)
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(name);
|
||||
ValidateHeaderName(name);
|
||||
Name = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name portion of the header.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The full header name sent by clients will be <c>Mcp-Param-{Name}</c>.
|
||||
/// </remarks>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a header name contains only valid characters.
|
||||
/// </summary>
|
||||
/// <param name="name">The header name to validate.</param>
|
||||
/// <exception cref="ArgumentException">The name contains invalid characters.</exception>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,9 +49,12 @@ public static class NodeHelpers
|
|||
|
||||
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 class NodeHelpers
|
|||
{
|
||||
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 class NodeHelpers
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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 class ClientConformanceTests
|
|||
$"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
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue