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:
Mike Kistler 2026-05-14 17:39:37 -05:00 • committed by GitHub
commit b8c4d951ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2854 additions and 28 deletions

View file

@ -52,7 +52,7 @@ jobs:
- name: 🔧 Set up Node.js - name: 🔧 Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '20' node-version: '22'
- name: 📦 Install pinned npm dependencies for tests - name: 📦 Install pinned npm dependencies for tests
run: npm ci run: npm ci

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
# Cake tools # Cake tools
/[Tt]ools/ /[Tt]ools/
# Language server cache
*.lscache
# Build output # Build output
[Bb]uildArtifacts/ [Bb]uildArtifacts/
# Build results # Build results

View file

@ -315,3 +315,28 @@ public static string Search(
// Schema will include descriptions and default value for maxResults // 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
View file

@ -5,7 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@modelcontextprotocol/conformance": "0.1.13", "@modelcontextprotocol/conformance": "0.1.16",
"@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26" "@modelcontextprotocol/server-memory": "2026.1.26"
} }
@ -23,28 +23,38 @@
} }
}, },
"node_modules/@modelcontextprotocol/conformance": { "node_modules/@modelcontextprotocol/conformance": {
"version": "0.1.13", "version": "0.1.16",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.13.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.16.tgz",
"integrity": "sha512-J9cC7Dsi7hTn7sxorvxDZ+wC8OlHAkOmVxVm1/IBQ8HEpYJu8r0eAUEw68vTmjBPP5WcU8Jld4RZ3cOFp/Ih1g==", "integrity": "sha512-GI7qiN0r39/MH2srVUR3AXaEN0YLCro20lIBbnvc1frBhszenxvUifBuTzxeVQVagILfBzCIcnungUOma8OrgA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.27.1",
"@octokit/rest": "^22.0.0",
"commander": "^14.0.2", "commander": "^14.0.2",
"eventsource-parser": "^3.0.6", "eventsource-parser": "^3.0.6",
"express": "^5.1.0", "express": "^5.1.0",
"jose": "^6.1.2", "jose": "^6.1.2",
"undici": "^7.19.0", "undici": "^7.19.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"zod": "^3.25.76" "zod": "^4.3.6"
}, },
"bin": { "bin": {
"conformance": "dist/index.js" "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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.9",
@ -110,6 +120,175 @@
"mcp-server-memory": "dist/index.js" "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": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "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": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@ -486,6 +671,22 @@
"express": ">= 4.11" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -493,9 +694,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -743,6 +944,12 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause" "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": { "node_modules/jszip": {
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@ -1229,6 +1436,12 @@
"node": ">=20.18.1" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View file

@ -2,7 +2,7 @@
"private": true, "private": true,
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": { "dependencies": {
"@modelcontextprotocol/conformance": "0.1.13", "@modelcontextprotocol/conformance": "0.1.16",
"@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26" "@modelcontextprotocol/server-memory": "2026.1.26"
} }

View 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!);
}

View file

@ -23,6 +23,7 @@
<ItemGroup> <ItemGroup>
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" /> <Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" /> <Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http; using System.Buffers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -8,6 +9,7 @@ using Microsoft.Net.Http.Headers;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
@ -23,9 +25,9 @@ internal sealed class StreamableHttpHandler(
IServiceProvider applicationServices, IServiceProvider applicationServices,
ILoggerFactory loggerFactory) ILoggerFactory loggerFactory)
{ {
private const string McpSessionIdHeaderName = "Mcp-Session-Id"; private const string McpSessionIdHeaderName = McpHttpHeaders.SessionId;
private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version"; private const string McpProtocolVersionHeaderName = McpHttpHeaders.ProtocolVersion;
private const string LastEventIdHeaderName = "Last-Event-ID"; private const string LastEventIdHeaderName = McpHttpHeaders.LastEventId;
/// <summary> /// <summary>
/// All protocol versions supported by this implementation. /// All protocol versions supported by this implementation.
@ -37,6 +39,7 @@ internal sealed class StreamableHttpHandler(
"2025-03-26", "2025-03-26",
"2025-06-18", "2025-06-18",
"2025-11-25", "2025-11-25",
"DRAFT-2026-v1",
]; ];
private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>(); private static readonly JsonTypeInfo<JsonRpcMessage> s_messageTypeInfo = GetRequiredJsonTypeInfo<JsonRpcMessage>();
@ -79,6 +82,12 @@ internal sealed class StreamableHttpHandler(
return; 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); var session = await GetOrCreateSessionAsync(context, message);
if (session is null) if (session is null)
{ {
@ -540,6 +549,289 @@ internal sealed class StreamableHttpHandler(
return true; 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) private static bool MatchesApplicationJsonMediaType(MediaTypeHeaderValue acceptHeaderValue)
=> acceptHeaderValue.MatchesMediaType("application/json"); => acceptHeaderValue.MatchesMediaType("application/json");

View file

@ -176,6 +176,8 @@ public abstract partial class McpClient : McpSession
RequestOptions? options = null, RequestOptions? options = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ToolCacheClearing?.Invoke();
List<McpClientTool>? tools = null; List<McpClientTool>? tools = null;
ListToolsRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() }; ListToolsRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() };
do do
@ -184,6 +186,15 @@ public abstract partial class McpClient : McpSession
tools ??= new(toolResults.Tools.Count); tools ??= new(toolResults.Tools.Count);
foreach (var tool in toolResults.Tools) 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)); tools.Add(new(this, tool, options?.JsonSerializerOptions));
} }
@ -194,6 +205,21 @@ public abstract partial class McpClient : McpSession
return tools; 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> /// <summary>
/// Retrieves a list of available tools from the server. /// Retrieves a list of available tools from the server.
/// </summary> /// </summary>

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
namespace ModelContextProtocol.Client; namespace ModelContextProtocol.Client;
@ -22,6 +23,7 @@ internal sealed partial class McpClientImpl : McpClient
private readonly McpSessionHandler _sessionHandler; private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
private ServerCapabilities? _serverCapabilities; private ServerCapabilities? _serverCapabilities;
private Implementation? _serverInfo; private Implementation? _serverInfo;
@ -67,6 +69,10 @@ internal sealed partial class McpClientImpl : McpClient
incomingMessageFilter: null, incomingMessageFilter: null,
outgoingMessageFilter: null, outgoingMessageFilter: null,
_logger); _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) private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers)
@ -633,7 +639,22 @@ internal sealed partial class McpClientImpl : McpClient
/// <inheritdoc/> /// <inheritdoc/>
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) 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/> /// <inheritdoc/>
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) 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.")] [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
private partial void LogClientSessionResumed(string endpointName); 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);
} }

View 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;
}
}

View file

@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.ServerSentEvents; using System.Net.ServerSentEvents;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using System.Threading.Channels; using System.Threading.Channels;
using System.Net; using System.Net;
@ -91,6 +92,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion); CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
AddMcpRequestHeaders(httpRequestMessage.Headers, message);
var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false); 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. // 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) if (sessionId is not null)
{ {
headers.Add("Mcp-Session-Id", sessionId); headers.Add(McpHttpHeaders.SessionId, sessionId);
} }
if (protocolVersion is not null) if (protocolVersion is not null)
{ {
headers.Add("MCP-Protocol-Version", protocolVersion); headers.Add(McpHttpHeaders.ProtocolVersion, protocolVersion);
} }
if (lastEventId is not null) if (lastEventId is not null)
{ {
headers.Add("Last-Event-ID", lastEventId); headers.Add(McpHttpHeaders.LastEventId, lastEventId);
} }
if (additionalHeaders is null) 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> /// <summary>
/// Tracks state across SSE stream connections. /// Tracks state across SSE stream connections.
/// </summary> /// </summary>

View file

@ -5,6 +5,26 @@ namespace ModelContextProtocol;
/// </summary> /// </summary>
public enum McpErrorCode 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> /// <summary>
/// Indicates that the requested resource could not be found. /// Indicates that the requested resource could not be found.
/// </summary> /// </summary>

View file

@ -41,6 +41,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
"2025-03-26", "2025-03-26",
"2025-06-18", "2025-06-18",
LatestProtocolVersion, LatestProtocolVersion,
"DRAFT-2026-v1",
]; ];
/// <summary> /// <summary>
@ -159,7 +160,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// completes its channel with a <see cref="ClientTransportClosedException"/>, the wrapped /// completes its channel with a <see cref="ClientTransportClosedException"/>, the wrapped
/// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned. /// <see cref="ClientCompletionDetails"/> is unwrapped. Otherwise, a default instance is returned.
/// </summary> /// </summary>
internal Task<ClientCompletionDetails> CompletionTask => internal Task<ClientCompletionDetails> CompletionTask =>
field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion); field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion);
/// <summary> /// <summary>

View file

@ -26,6 +26,7 @@
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" /> <Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" /> <Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\Common\EncodingUtilities.cs" Link="EncodingUtilities.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\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" /> <Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
</ItemGroup> </ItemGroup>

View 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}";
}
}

View file

@ -124,6 +124,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
Icons = options?.Icons, 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 is not null)
{ {
if (options.Title is not null || if (options.Title is not null ||
@ -600,4 +606,85 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
IsError = allErrorContent && hasAny 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);
}
} }

View 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));
}
}
}
}

View file

@ -49,9 +49,12 @@ public static class NodeHelpers
var repoRoot = FindRepoRoot(); var repoRoot = FindRepoRoot();
var nodeModulesPath = Path.Combine(repoRoot, "node_modules"); 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. // Run 'npm ci' if node_modules doesn't exist or is outdated
if (!Directory.Exists(nodeModulesPath)) // (package-lock.json is newer than node_modules).
if (!Directory.Exists(nodeModulesPath) ||
File.GetLastWriteTimeUtc(lockFilePath) > Directory.GetLastWriteTimeUtc(nodeModulesPath))
{ {
var startInfo = NpmStartInfo("ci", repoRoot); var startInfo = NpmStartInfo("ci", repoRoot);
using var process = Process.Start(startInfo) using var process = Process.Start(startInfo)
@ -80,6 +83,13 @@ public static class NodeHelpers
{ {
EnsureNpmDependenciesInstalled(); 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 repoRoot = FindRepoRoot();
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance"); 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) private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))

View file

@ -16,6 +16,7 @@ public class ClientConformanceTests
// Public static property required for SkipUnless attribute // Public static property required for SkipUnless attribute
public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios();
public ClientConformanceTests(ITestOutputHelper output) public ClientConformanceTests(ITestOutputHelper output)
{ {
@ -61,6 +62,21 @@ public class ClientConformanceTests
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); $"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) private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario)
{ {
// Construct an absolute path to the conformance client executable // 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