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
15 changed files with 2854 additions and 28 deletions

View file

@ -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))

View file

@ -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

View file

@ -0,0 +1,478 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Net;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
namespace ModelContextProtocol.AspNetCore.Tests;
/// <summary>
/// Tests for SEP-2243 HTTP header standardization features:
/// - Custom Mcp-Param-* header validation
/// - Tab/control character encoding
/// - Numeric precision in header values
/// - Empty string header validation
/// - Invalid header character rejection
/// </summary>
public class HttpHeaderConformanceTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
{
private WebApplication? _app;
private async Task StartAsync()
{
Builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
{
Name = nameof(HttpHeaderConformanceTests),
Version = "1.0",
};
}).WithTools(Tools).WithHttpTransport();
_app = Builder.Build();
_app.MapMcp();
await _app.StartAsync(TestContext.Current.CancellationToken);
HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
base.Dispose();
}
// Create a tool with x-mcp-header annotations in the schema.
// We set InputSchema directly because TransformSchemaNode doesn't provide
// property-level path context for lambda-based tool creation.
private static McpServerTool[] Tools { get; } = [CreateHeaderTestTool()];
private static readonly JsonSerializerOptions s_reflectionOptions = new()
{
TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver()
};
private static McpServerTool CreateHeaderTestTool()
{
var tool = McpServerTool.Create(
[McpServerTool(Name = "header_test")]
static (string region, int priority, bool verbose, string emptyVal) =>
$"region={region},priority={priority},verbose={verbose},empty={emptyVal}",
new McpServerToolCreateOptions { SerializerOptions = s_reflectionOptions });
using var doc = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" },
"priority": { "type": "integer", "x-mcp-header": "Priority" },
"verbose": { "type": "boolean", "x-mcp-header": "Verbose" },
"emptyVal": { "type": "string", "x-mcp-header": "EmptyVal" }
},
"required": ["region", "priority", "verbose", "emptyVal"]
}
""");
tool.ProtocolTool.InputSchema = doc.RootElement.Clone();
return tool;
}
#region Server-side validation tests
[Fact]
public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Per SEP-2243: servers MUST accept extra whitespace around header values
// and compare the trimmed value to the request body.
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call");
request.Headers.TryAddWithoutValidation("Mcp-Name", " header_test ");
request.Headers.Add("Mcp-Param-Region", "us-west1");
request.Headers.Add("Mcp-Param-Priority", "42");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Per SEP-2243: servers MUST accept extra whitespace around header values
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.TryAddWithoutValidation("Mcp-Method", " tools/call ");
request.Headers.TryAddWithoutValidation("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "us-west1");
request.Headers.Add("Mcp-Param-Priority", "42");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Send a tools/call with an empty string param that has an x-mcp-header.
// The header should be present with an empty value, matching the body's empty string.
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "us-west1");
request.Headers.Add("Mcp-Param-Priority", "42");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Send a tools/call where the body has a non-empty value but the header is empty
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "us-west1");
request.Headers.Add("Mcp-Param-Priority", "42");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Server_AcceptsBase64EncodedHeaderWithControlChars()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Encode a value with a newline control character using Base64
var valueWithNewline = "line1\nline2";
var encodedValue = McpHeaderEncoder.EncodeValue(valueWithNewline);
var callJson = CallTool("header_test", $$"""{"region":"{{valueWithNewline.Replace("\n", "\\n")}}","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", encodedValue!);
request.Headers.Add("Mcp-Param-Priority", "42");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_AcceptsLargeIntegerWithFullPrecision()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Use a large integer that would lose precision if converted through double
// 2^53 + 1 = 9007199254740993 (cannot be represented exactly as double)
const long largeInt = 9007199254740993L;
var callJson = CallTool("header_test", $$"""{"region":"test","priority":{{largeInt}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", largeInt.ToString());
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Theory]
[InlineData("42", 42)] // "42" header vs 42 body → exact integer match
[InlineData("42.0", 42)] // "42.0" header vs 42 body → numeric equivalence
[InlineData("42", 42.0)] // "42" header vs 42.0 body → numeric equivalence
public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, double bodyValue)
{
await StartAsync();
await InitializeWithDraftVersionAsync();
var callJson = CallTool("header_test", $$$"""{"region":"test","priority":{{{bodyValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", headerValue);
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_RejectsNonNumericMismatch_ForIntegerParam()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Header says "99" but body says priority:42 — must reject even with numeric comparison
var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "test");
request.Headers.Add("Mcp-Param-Priority", "99");
request.Headers.Add("Mcp-Param-Verbose", "false");
request.Headers.Add("Mcp-Param-EmptyVal", "");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Server_SkipsHeaderValidation_ForNonDraftVersion()
{
await StartAsync();
await InitializeWithNonDraftVersionAsync();
// With non-draft version, Mcp-Param-* headers are NOT validated even if mismatched
var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
// Send the WRONG header value — this should still succeed because version is non-draft
request.Headers.Add("MCP-Protocol-Version", "2025-11-25");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "header_test");
request.Headers.Add("Mcp-Param-Region", "WRONG-VALUE");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Server_RejectsInvalidUtf8EncodedHeaderValue()
{
await StartAsync();
await InitializeWithDraftVersionAsync();
// Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers
// instead of properly base64-encoding non-ASCII values.
var handler = new SocketsHttpHandler
{
ConnectCallback = SocketsHttpHandler.ConnectCallback,
RequestHeaderEncodingSelector = (headerName, _) =>
headerName.StartsWith("Mcp-", StringComparison.OrdinalIgnoreCase)
? Encoding.UTF8
: null
};
using var utf8Client = new HttpClient(handler);
ConfigureHttpClient(utf8Client);
utf8Client.DefaultRequestHeaders.Accept.Add(new("application/json"));
utf8Client.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
// Send a tools/call with raw UTF-8 non-ASCII in the Mcp-Name header.
// Kestrel reads header bytes as Latin-1, so the UTF-8 bytes for "café☕"
// will be garbled and won't match the body value, causing rejection.
var callJson = CallTool("café☕", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = new StringContent(callJson, Encoding.UTF8, "application/json");
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.TryAddWithoutValidation("Mcp-Method", "tools/call");
// Raw UTF-8 non-ASCII value in Mcp-Name — server must reject this
request.Headers.TryAddWithoutValidation("Mcp-Name", "café☕");
request.Headers.TryAddWithoutValidation("Mcp-Param-Region", "us-west1");
request.Headers.TryAddWithoutValidation("Mcp-Param-Priority", "42");
request.Headers.TryAddWithoutValidation("Mcp-Param-Verbose", "false");
request.Headers.TryAddWithoutValidation("Mcp-Param-EmptyVal", "");
using var response = await utf8Client.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
#endregion
#region Client-side encoding tests (unit tests for McpHeaderEncoder)
[Theory]
[InlineData("hello\tworld")]
[InlineData("col1\tcol2\tcol3")]
public void Client_TabInValue_IsBase64Encoded(string value)
{
var encoded = McpHeaderEncoder.EncodeValue(value);
Assert.NotNull(encoded);
Assert.StartsWith("=?base64?", encoded);
Assert.EndsWith("?=", encoded);
// Verify round-trip
var decoded = McpHeaderEncoder.DecodeValue(encoded);
Assert.Equal(value, decoded);
}
[Theory]
[InlineData("simple-text", false)]
[InlineData("with space", false)]
[InlineData("Hello, 世界", true)]
[InlineData("line1\nline2", true)]
[InlineData("\ttab-start", true)]
[InlineData("mid\ttab", true)]
[InlineData("control\x01char", true)]
public void Client_EncodeValue_Base64OnlyWhenNeeded(string value, bool expectBase64)
{
var encoded = McpHeaderEncoder.EncodeValue(value);
Assert.NotNull(encoded);
if (expectBase64)
{
Assert.StartsWith("=?base64?", encoded);
}
else
{
Assert.DoesNotContain("=?base64?", encoded);
}
// All values must round-trip
var decoded = McpHeaderEncoder.DecodeValue(encoded);
Assert.Equal(value, decoded);
}
[Fact]
public void Client_EncodeValue_LargeInteger_PreservesFullPrecision()
{
// 2^53 + 1 cannot be represented exactly as a double
var encoded = McpHeaderEncoder.EncodeValue(9007199254740993L);
Assert.Equal("9007199254740993", encoded);
}
[Fact]
public void Client_EncodeValue_Boolean_EncodesCorrectly()
{
Assert.Equal("true", McpHeaderEncoder.EncodeValue(true));
Assert.Equal("false", McpHeaderEncoder.EncodeValue(false));
}
#endregion
#region Version gating tests
[Theory]
[InlineData("DRAFT-2026-v1", true)]
[InlineData("2025-11-25", false)]
[InlineData("2025-06-18", false)]
[InlineData("2024-11-05", false)]
[InlineData(null, false)]
[InlineData("", false)]
public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected)
{
Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version));
}
#endregion
#region Helpers
private async Task InitializeWithDraftVersionAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(InitializeRequestDraft);
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "initialize");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
}
private async Task InitializeWithNonDraftVersionAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId);
}
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
private long _lastRequestId = 1;
private string CallTool(string toolName, string arguments = "{}")
{
var id = Interlocked.Increment(ref _lastRequestId);
return $$$"""
{"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}}
""";
}
private static string InitializeRequest => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
""";
private static string InitializeRequestDraft => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}}
""";
#endregion
}

View file

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

View file

@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
@ -134,6 +135,30 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
[Fact]
public async Task RunConformanceTest_HttpHeaderValidation()
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available.");
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-header-validation");
Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
[Fact]
public async Task RunConformanceTest_HttpCustomHeaderServerValidation()
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
Assert.SkipWhen(!NodeHelpers.HasSep2243Scenarios(), "SEP-2243 conformance scenarios not yet available.");
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario http-custom-header-server-validation");
Assert.True(result.Success,
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
{
var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments);
@ -190,10 +215,40 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
process.OutputDataReceived -= outputHandler;
process.ErrorDataReceived -= errorHandler;
var stdoutText = outputBuilder.ToString();
var stderrText = errorBuilder.ToString();
// The Node.js conformance runner can crash during cleanup on Windows with a libuv
// assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit
// code even though every conformance check passed. When that happens, fall back to
// parsing the "Test Results:" summary in stdout to decide success.
bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText);
return (
Success: process.ExitCode == 0,
Output: outputBuilder.ToString(),
Error: errorBuilder.ToString()
Success: success,
Output: stdoutText,
Error: stderrText
);
}
/// <summary>
/// Parses the conformance runner output for a "Test Results:" line such as
/// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed
/// and none failed.
/// </summary>
private static bool ConformanceOutputIndicatesSuccess(string output)
{
// Match lines like "Passed: 3/3, 0 failed, 0 warnings"
var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed");
if (!match.Success)
{
return false;
}
int passed = int.Parse(match.Groups[1].Value);
int total = int.Parse(match.Groups[2].Value);
int failed = int.Parse(match.Groups[3].Value);
return passed == total && failed == 0 && total > 0;
}
}

View file

@ -549,6 +549,229 @@ public class StreamableHttpClientConformanceTests(ITestOutputHelper outputHelper
return message;
}
#region SEP-2243 Client Header Tests
[Fact]
public async Task ListTools_FiltersToolsWithInvalidHeaderAnnotations()
{
// Start a mock server that returns tools with both valid and invalid x-mcp-header annotations
await StartHeaderToolServer();
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// The server returns 3 tools: valid_tool, invalid_space_tool, invalid_duplicate_tool
// The client should filter out tools with invalid x-mcp-header annotations
var toolNames = tools.Select(t => t.Name).ToList();
Assert.Contains("valid_tool", toolNames);
Assert.DoesNotContain("invalid_space_tool", toolNames);
Assert.DoesNotContain("invalid_duplicate_tool", toolNames);
}
[Fact]
public async Task Client_SendsCorrectHeaders_EndToEnd()
{
// Start a server that captures request headers for verification
var capturedHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await StartHeaderCapturingServer(capturedHeaders);
await using var transport = new HttpClientTransport(new()
{
Endpoint = new("http://localhost:5000/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}, HttpClient, LoggerFactory);
await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
var tool = Assert.Single(tools);
Assert.Equal("header_tool", tool.Name);
// Call the tool — client should send Mcp-Param-* headers automatically
capturedHeaders.Clear();
await tool.CallAsync(new Dictionary<string, object?> { ["region"] = "us-west-2" }, cancellationToken: TestContext.Current.CancellationToken);
// Verify the client sent the correct headers
Assert.True(capturedHeaders.ContainsKey("Mcp-Method"), "Expected Mcp-Method header");
Assert.Equal("tools/call", capturedHeaders["Mcp-Method"]);
Assert.True(capturedHeaders.ContainsKey("Mcp-Name"), "Expected Mcp-Name header");
Assert.Equal("header_tool", capturedHeaders["Mcp-Name"]);
Assert.True(capturedHeaders.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region header");
Assert.Equal("us-west-2", capturedHeaders["Mcp-Param-Region"]);
}
private async Task StartHeaderToolServer()
{
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
_app.MapPost("/mcp", (JsonRpcMessage message) =>
{
if (message is not JsonRpcRequest request)
{
return Results.Accepted();
}
if (request.Method == "initialize")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "DRAFT-2026-v1",
Capabilities = new() { Tools = new() },
ServerInfo = new Implementation { Name = "header-test-server", Version = "1.0" },
}, McpJsonUtilities.DefaultOptions)
});
}
if (request.Method == "tools/list")
{
// Return tools with various x-mcp-header annotations — some valid, some invalid
var toolsJson = JsonSerializer.SerializeToNode(new ListToolsResult
{
Tools =
[
CreateToolWithSchema("valid_tool", """
{
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" }
}
}
"""),
CreateToolWithSchema("invalid_space_tool", """
{
"type": "object",
"properties": {
"value": { "type": "string", "x-mcp-header": "Invalid Name" }
}
}
"""),
CreateToolWithSchema("invalid_duplicate_tool", """
{
"type": "object",
"properties": {
"a": { "type": "string", "x-mcp-header": "Same" },
"b": { "type": "string", "x-mcp-header": "Same" }
}
}
"""),
]
}, McpJsonUtilities.DefaultOptions);
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = toolsJson,
});
}
return Results.Accepted();
});
await _app.StartAsync(TestContext.Current.CancellationToken);
}
private async Task StartHeaderCapturingServer(Dictionary<string, string> capturedHeaders)
{
Builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
});
_app = Builder.Build();
_app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) =>
{
if (message is not JsonRpcRequest request)
{
return Results.Accepted();
}
if (request.Method == "initialize")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "DRAFT-2026-v1",
Capabilities = new() { Tools = new() },
ServerInfo = new Implementation { Name = "header-capture", Version = "1.0" },
}, McpJsonUtilities.DefaultOptions)
});
}
if (request.Method == "tools/list")
{
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new ListToolsResult
{
Tools = [CreateToolWithSchema("header_tool", """
{
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" }
},
"required": ["region"]
}
""")]
}, McpJsonUtilities.DefaultOptions),
});
}
if (request.Method == "tools/call")
{
// Capture all MCP headers for verification
foreach (var header in context.Request.Headers)
{
if (header.Key.StartsWith("Mcp-", StringComparison.OrdinalIgnoreCase))
{
capturedHeaders[header.Key] = header.Value.ToString();
}
}
var parameters = JsonSerializer.Deserialize(request.Params, GetJsonTypeInfo<CallToolRequestParams>());
return Results.Json(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new CallToolResult
{
Content = [new TextContentBlock { Text = "ok" }],
}, McpJsonUtilities.DefaultOptions),
});
}
return Results.Accepted();
});
await _app.StartAsync(TestContext.Current.CancellationToken);
}
private static Tool CreateToolWithSchema(string name, string schemaJson)
{
using var doc = JsonDocument.Parse(schemaJson);
return new Tool
{
Name = name,
InputSchema = doc.RootElement.Clone(),
};
}
#endregion
private sealed class ResumeTestServer
{
private static readonly Tool ResumeTool = new()

View file

@ -730,6 +730,98 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
Assert.Equal(NotificationMethods.ResourceUpdatedNotification, notification.Method);
}
#region SEP-2243 Header Validation Tests
[Fact]
public async Task DraftVersion_RejectsMissingMcpMethodHeader()
{
await StartAsync();
// Initialize with draft version to enable header validation
await CallInitializeWithDraftVersionAndValidateAsync();
// Send a tools/call request without Mcp-Method header — should be rejected
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(CallTool("echo", """{"message":"test"}"""));
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
// Deliberately omit Mcp-Method header
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DraftVersion_RejectsMismatchedMcpMethodHeader()
{
await StartAsync();
await CallInitializeWithDraftVersionAndValidateAsync();
// Send a tools/call request but set Mcp-Method to wrong value
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(CallTool("echo", """{"message":"test"}"""));
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DraftVersion_AcceptsCorrectMcpMethodHeader()
{
await StartAsync();
await CallInitializeWithDraftVersionAndValidateAsync();
// Send a tools/call request with correct Mcp-Method and Mcp-Name headers
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "tools/call");
request.Headers.Add("Mcp-Name", "echo");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task NonDraftVersion_DoesNotRequireMcpMethodHeader()
{
await StartAsync();
await CallInitializeAndValidateAsync();
// With non-draft version, Mcp-Method header is not required
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(CallTool("echo", """{"message":"hello"}"""));
request.Headers.Add("MCP-Protocol-Version", "2025-03-26");
// No Mcp-Method header — should still work
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private async Task CallInitializeWithDraftVersionAndValidateAsync()
{
HttpClient.DefaultRequestHeaders.Remove("mcp-session-id");
using var request = new HttpRequestMessage(HttpMethod.Post, "");
request.Content = JsonContent(InitializeRequestDraft);
request.Headers.Add("MCP-Protocol-Version", "DRAFT-2026-v1");
request.Headers.Add("Mcp-Method", "initialize");
using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
var rpcResponse = await AssertSingleSseResponseAsync(response);
AssertServerInfo(rpcResponse);
var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id"));
SetSessionId(sessionId);
}
private static string InitializeRequestDraft => """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}}
""";
#endregion
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));

View file

@ -179,6 +179,136 @@ try
}
break;
}
case "http-standard-headers":
{
// List and call tools to test Mcp-Method and Mcp-Name headers
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
var tool = tools.FirstOrDefault(t => t.Name == "test_headers");
if (tool is not null)
{
Console.WriteLine("Calling tool: test_headers");
var result = await mcpClient.CallToolAsync(toolName: "test_headers", arguments: new Dictionary<string, object?>());
success &= !(result.IsError == true);
}
// List and get prompts to test Mcp-Method and Mcp-Name headers
var prompts = await mcpClient.ListPromptsAsync();
Console.WriteLine($"Available prompts: {string.Join(", ", prompts.Select(p => p.Name))}");
foreach (var prompt in prompts)
{
Console.WriteLine($"Getting prompt: {prompt.Name}");
try
{
await mcpClient.GetPromptAsync(prompt.Name);
}
catch (Exception ex)
{
Console.WriteLine($"Prompt get error (expected for test): {ex.Message}");
}
}
// List and read resources to test Mcp-Name with params.uri
var resources = await mcpClient.ListResourcesAsync();
Console.WriteLine($"Available resources: {string.Join(", ", resources.Select(r => r.Uri))}");
foreach (var resource in resources)
{
Console.WriteLine($"Reading resource: {resource.Uri}");
try
{
await mcpClient.ReadResourceAsync(resource.Uri);
}
catch (Exception ex)
{
Console.WriteLine($"Resource read error (expected for test): {ex.Message}");
}
}
break;
}
case "http-custom-headers":
{
// List tools to discover x-mcp-header annotations (populates tool cache)
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
// Parse conformance context for tool calls
if (!string.IsNullOrEmpty(conformanceContext))
{
using var contextDoc = JsonDocument.Parse(conformanceContext);
// Support both "toolCalls" (array) and legacy "toolCall" (single object)
var toolCallElements = new List<JsonElement>();
if (contextDoc.RootElement.TryGetProperty("toolCalls", out var toolCallsArray) &&
toolCallsArray.ValueKind == JsonValueKind.Array)
{
foreach (var item in toolCallsArray.EnumerateArray())
{
toolCallElements.Add(item);
}
}
else if (contextDoc.RootElement.TryGetProperty("toolCall", out var toolCallEl))
{
toolCallElements.Add(toolCallEl);
}
foreach (var toolCallEl in toolCallElements)
{
var toolName = toolCallEl.TryGetProperty("name", out var nameEl)
? nameEl.GetString() ?? "test_custom_headers"
: "test_custom_headers";
Dictionary<string, object?> toolCallArgs = new();
if (toolCallEl.TryGetProperty("arguments", out var argsEl))
{
foreach (var prop in argsEl.EnumerateObject())
{
object? value = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.TryGetInt64(out var l) ? l : prop.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => prop.Value.GetRawText(),
};
toolCallArgs[prop.Name] = value;
}
}
Console.WriteLine($"Calling tool: {toolName} with {toolCallArgs.Count} arguments");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: toolCallArgs);
success &= !(result.IsError == true);
}
}
break;
}
case "http-invalid-tool-headers":
{
// List tools — the client should filter out tools with invalid x-mcp-header annotations
var tools = await mcpClient.ListToolsAsync();
Console.WriteLine($"Available tools after filtering: {string.Join(", ", tools.Select(t => t.Name))}");
// Only call valid_tool — invalid tools should have been excluded
var validTool = tools.FirstOrDefault(t => t.Name == "valid_tool");
if (validTool is not null)
{
Console.WriteLine("Calling valid_tool");
var result = await mcpClient.CallToolAsync(toolName: "valid_tool", arguments: new Dictionary<string, object?>
{
{ "region", "us-east1" }
});
success &= !(result.IsError == true);
}
else
{
Console.WriteLine("ERROR: valid_tool was not found in the filtered tool list");
success = false;
}
break;
}
default:
// No extra processing for other scenarios
break;

View file

@ -442,4 +442,13 @@ public class ConformanceTools
// and the client must reconnect to get the result.
return "Reconnection test completed successfully";
}
[McpServerTool(Name = "test_header_tool")]
[Description("A tool with x-mcp-header annotations for conformance testing")]
public static string TestHeaderTool(
[McpHeader("Region"), Description("The deployment region")] string region,
[Description("The query to execute")] string query)
{
return $"Executed in region {region}: {query}";
}
}

View file

@ -0,0 +1,67 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Client;
public class McpClientToolRejectionTests : ClientServerTestBase
{
private const string InvalidToolName = "InvalidHeaderTool";
private const string ValidToolName = "ValidTool";
public McpClientToolRejectionTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
// Register a valid tool.
mcpServerBuilder.WithTools([McpServerTool.Create(
(string input) => $"echo {input}",
new() { Name = ValidToolName })]);
// Register a tool whose InputSchema has an invalid x-mcp-header (colon in header name).
var invalidTool = McpServerTool.Create(
(string region) => $"result for {region}",
new() { Name = InvalidToolName });
// Manually inject an invalid x-mcp-header annotation into the schema.
// The header name "Invalid:Header" contains a colon, which is prohibited.
var schemaJson = """
{
"type": "object",
"properties": {
"region": {
"type": "string",
"x-mcp-header": "Invalid:Header"
}
}
}
""";
invalidTool.ProtocolTool.InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone();
mcpServerBuilder.WithTools([invalidTool]);
}
[Fact]
public async Task ListToolsAsync_ExcludesToolWithInvalidXMcpHeader_AndLogsWarning()
{
// Act
await using var client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// Assert: the valid tool is returned, the invalid one is excluded.
Assert.Contains(tools, t => t.Name == ValidToolName);
Assert.DoesNotContain(tools, t => t.Name == InvalidToolName);
// Assert: a warning was logged about the rejected tool.
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Message.Contains(InvalidToolName) &&
log.Message.Contains("excluded"));
}
}

View file

@ -0,0 +1,163 @@
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Tests.Client;
public class McpHeaderEncoderTests
{
[Theory]
[InlineData("us-west1", "us-west1")]
[InlineData("hello-world", "hello-world")]
[InlineData("my_tool_name", "my_tool_name")]
[InlineData("us west 1", "us west 1")]
[InlineData("", "")]
public void EncodeValue_PlainAscii_PassesThrough(string input, string expected)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(" us-west1", "=?base64?IHVzLXdlc3Qx?=")]
[InlineData("us-west1 ", "=?base64?dXMtd2VzdDEg?=")]
[InlineData(" us-west1 ", "=?base64?IHVzLXdlc3QxIA==?=")]
[InlineData("\tindented", "=?base64?CWluZGVudGVk?=")]
public void EncodeValue_LeadingTrailingWhitespace_Base64Encodes(string input, string expected)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(expected, result);
}
[Fact]
public void EncodeValue_NonAsciiCharacters_Base64Encodes()
{
var result = McpHeaderEncoder.EncodeValue("日本語");
Assert.Equal("=?base64?5pel5pys6Kqe?=", result);
}
[Fact]
public void EncodeValue_NewlineCharacter_Base64Encodes()
{
var result = McpHeaderEncoder.EncodeValue("line1\nline2");
Assert.Equal("=?base64?bGluZTEKbGluZTI=?=", result);
}
[Fact]
public void EncodeValue_CarriageReturnNewline_Base64Encodes()
{
var result = McpHeaderEncoder.EncodeValue("line1\r\nline2");
Assert.Equal("=?base64?bGluZTENCmxpbmUy?=", result);
}
[Theory]
[InlineData(true, "true")]
[InlineData(false, "false")]
public void EncodeValue_Boolean_ConvertsToLowercase(bool input, string expected)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(42, "42")]
[InlineData(3.14, "3.14")]
[InlineData(0, "0")]
[InlineData(-1, "-1")]
public void EncodeValue_Number_ConvertsToString(object input, string expected)
{
var result = McpHeaderEncoder.EncodeValue(input);
Assert.Equal(expected, result);
}
[Fact]
public void EncodeValue_Null_ReturnsNull()
{
var result = McpHeaderEncoder.EncodeValue(null);
Assert.Null(result);
}
[Fact]
public void EncodeValue_UnsupportedType_ReturnsNull()
{
var result = McpHeaderEncoder.EncodeValue(new object());
Assert.Null(result);
}
[Theory]
[InlineData("us-west1", "us-west1")]
[InlineData("", "")]
public void DecodeValue_PlainAscii_ReturnsAsIs(string input, string expected)
{
var result = McpHeaderEncoder.DecodeValue(input);
Assert.Equal(expected, result);
}
[Fact]
public void DecodeValue_Null_ReturnsNull()
{
var result = McpHeaderEncoder.DecodeValue(null);
Assert.Null(result);
}
[Fact]
public void DecodeValue_ValidBase64_Decodes()
{
var result = McpHeaderEncoder.DecodeValue("=?base64?SGVsbG8=?=");
Assert.Equal("Hello", result);
}
[Fact]
public void DecodeValue_CaseInsensitivePrefix_Decodes()
{
var result = McpHeaderEncoder.DecodeValue("=?BASE64?SGVsbG8=?=");
Assert.Equal("Hello", result);
}
[Fact]
public void DecodeValue_InvalidBase64_ReturnsNull()
{
var result = McpHeaderEncoder.DecodeValue("=?base64?SGVs!!!bG8=?=");
Assert.Null(result);
}
[Fact]
public void DecodeValue_MissingPrefix_ReturnsLiteralValue()
{
var result = McpHeaderEncoder.DecodeValue("SGVsbG8=");
Assert.Equal("SGVsbG8=", result);
}
[Fact]
public void DecodeValue_MissingSuffix_ReturnsLiteralValue()
{
var result = McpHeaderEncoder.DecodeValue("=?base64?SGVsbG8=");
Assert.Equal("=?base64?SGVsbG8=", result);
}
[Theory]
[InlineData("us-west1")]
[InlineData("Hello, 世界")]
[InlineData(" padded ")]
[InlineData("line1\nline2")]
[InlineData("\tindented")]
[InlineData("a\tb")]
public void RoundTrip_EncodeDecode_PreservesValue(string original)
{
var encoded = McpHeaderEncoder.EncodeValue(original);
Assert.NotNull(encoded);
var decoded = McpHeaderEncoder.DecodeValue(encoded);
Assert.Equal(original, decoded);
}
[Fact]
public void EncodeValue_EmbeddedTab_Base64Encodes()
{
var result = McpHeaderEncoder.EncodeValue("col1\tcol2");
Assert.StartsWith("=?base64?", result);
Assert.EndsWith("?=", result);
// Verify round-trip
var decoded = McpHeaderEncoder.DecodeValue(result);
Assert.Equal("col1\tcol2", decoded);
}
}

View file

@ -0,0 +1,35 @@
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Tests.Client;
public class McpRequestHeadersTests
{
[Fact]
public void McpHttpHeaders_HasCorrectValues()
{
Assert.Equal("Mcp-Session-Id", McpHttpHeaders.SessionId);
Assert.Equal("MCP-Protocol-Version", McpHttpHeaders.ProtocolVersion);
Assert.Equal("Last-Event-ID", McpHttpHeaders.LastEventId);
Assert.Equal("Mcp-Method", McpHttpHeaders.Method);
Assert.Equal("Mcp-Name", McpHttpHeaders.Name);
Assert.Equal("Mcp-Param-", McpHttpHeaders.ParamPrefix);
}
[Fact]
public void McpErrorCode_HeaderMismatch_HasCorrectValue()
{
Assert.Equal(-32001, (int)McpErrorCode.HeaderMismatch);
}
[Theory]
[InlineData("DRAFT-2026-v1", true)]
[InlineData("2025-11-25", false)]
[InlineData("2025-06-18", false)]
[InlineData("2024-11-05", false)]
[InlineData(null, false)]
[InlineData("", false)]
public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected)
{
Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version));
}
}

View file

@ -39,6 +39,7 @@
<Compile Include="..\..\src\ModelContextProtocol.Core\Server\InMemoryMcpTaskStore.cs" Link="Server\InMemoryMcpTaskStore.cs" />
<!-- Include dependencies for the linked InMemoryMcpTaskStore.cs -->
<Compile Include="..\..\src\Common\Experimentals.cs" Link="Experimentals.cs" />
<Compile Include="..\..\src\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,59 @@
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Tests.Server;
public class McpHeaderAttributeTests
{
[Theory]
[InlineData("Region")]
[InlineData("TenantId")]
[InlineData("Priority")]
[InlineData("X-Custom")]
public void Constructor_ValidHeaderName_Succeeds(string name)
{
var attr = new McpHeaderAttribute(name);
Assert.Equal(name, attr.Name);
}
[Fact]
public void Constructor_NameWithSpace_Throws()
{
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute("My Region"));
}
[Fact]
public void Constructor_NameWithColon_Throws()
{
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute("Region:Primary"));
}
[Fact]
public void Constructor_NullName_Throws()
{
Assert.ThrowsAny<ArgumentException>(() => new McpHeaderAttribute(null!));
}
[Fact]
public void Constructor_EmptyName_Throws()
{
Assert.ThrowsAny<ArgumentException>(() => new McpHeaderAttribute(""));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
Assert.ThrowsAny<ArgumentException>(() => new McpHeaderAttribute(" "));
}
[Fact]
public void Constructor_NameWithControlCharacter_Throws()
{
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute("Region\t1"));
}
[Fact]
public void Constructor_NameWithNonAscii_Throws()
{
Assert.Throws<ArgumentException>(() => new McpHeaderAttribute("Région"));
}
}

View file

@ -1177,4 +1177,136 @@ public partial class McpServerToolTests
[JsonSerializable(typeof(DateTimeOffset?))]
[JsonSerializable(typeof(Person))]
partial class JsonContext2 : JsonSerializerContext;
// ===== x-mcp-header tests =====
[Fact]
public void Create_WithMcpHeaderAttribute_AddsXMcpHeaderExtension()
{
var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithSingleHeader))!);
var schema = tool.ProtocolTool.InputSchema;
var props = schema.GetProperty("properties");
var regionProp = props.GetProperty("region");
Assert.True(regionProp.TryGetProperty("x-mcp-header", out var headerValue));
Assert.Equal("Region", headerValue.GetString());
}
[Fact]
public void Create_WithMultipleMcpHeaderAttributes_AddsAllExtensions()
{
var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithMultipleHeaders))!);
var schema = tool.ProtocolTool.InputSchema;
var props = schema.GetProperty("properties");
var regionProp = props.GetProperty("region");
Assert.True(regionProp.TryGetProperty("x-mcp-header", out var regionHeader));
Assert.Equal("Region", regionHeader.GetString());
var tenantProp = props.GetProperty("tenantId");
Assert.True(tenantProp.TryGetProperty("x-mcp-header", out var tenantHeader));
Assert.Equal("TenantId", tenantHeader.GetString());
}
[Fact]
public void Create_WithDuplicateHeaderNames_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithDuplicateHeaders))!));
}
[Fact]
public void Create_WithMcpHeaderOnNonPrimitiveType_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNonPrimitiveHeader))!));
}
[Fact]
public void Create_WithMcpHeaderOnNumericType_AddsExtension()
{
var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNumericHeader))!);
var schema = tool.ProtocolTool.InputSchema;
var props = schema.GetProperty("properties");
var countProp = props.GetProperty("count");
Assert.True(countProp.TryGetProperty("x-mcp-header", out var headerValue));
Assert.Equal("Count", headerValue.GetString());
}
[Fact]
public void Create_WithMcpHeaderOnBooleanType_AddsExtension()
{
var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithBooleanHeader))!);
var schema = tool.ProtocolTool.InputSchema;
var props = schema.GetProperty("properties");
var flagProp = props.GetProperty("flag");
Assert.True(flagProp.TryGetProperty("x-mcp-header", out var headerValue));
Assert.Equal("Flag", headerValue.GetString());
}
[Fact]
public void Create_WithMcpHeaderOnNullableType_AddsExtension()
{
var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithNullableHeader))!);
var schema = tool.ProtocolTool.InputSchema;
var props = schema.GetProperty("properties");
var countProp = props.GetProperty("count");
Assert.True(countProp.TryGetProperty("x-mcp-header", out var headerValue));
Assert.Equal("Count", headerValue.GetString());
}
[Fact]
public void Create_WithoutMcpHeaderAttribute_NoXMcpHeaderExtension()
{
var tool = McpServerTool.Create(typeof(McpHeaderToolType).GetMethod(nameof(McpHeaderToolType.ToolWithoutHeaders))!);
var schema = tool.ProtocolTool.InputSchema;
var props = schema.GetProperty("properties");
var regionProp = props.GetProperty("region");
Assert.False(regionProp.TryGetProperty("x-mcp-header", out _));
}
private static class McpHeaderToolType
{
[McpServerTool]
public static string ToolWithSingleHeader(
[McpHeader("Region")] string region,
string query)
=> "result";
[McpServerTool]
public static string ToolWithMultipleHeaders(
[McpHeader("Region")] string region,
[McpHeader("TenantId")] string tenantId,
string query)
=> "result";
[McpServerTool]
public static string ToolWithDuplicateHeaders(
[McpHeader("Region")] string region1,
[McpHeader("REGION")] string region2)
=> "result";
[McpServerTool]
public static string ToolWithNonPrimitiveHeader(
[McpHeader("Data")] object data)
=> "result";
[McpServerTool]
public static string ToolWithNumericHeader(
[McpHeader("Count")] int count)
=> "result";
[McpServerTool]
public static string ToolWithBooleanHeader(
[McpHeader("Flag")] bool flag)
=> "result";
[McpServerTool]
public static string ToolWithNullableHeader(
[McpHeader("Count")] int? count)
=> "result";
[McpServerTool]
public static string ToolWithoutHeaders(string region, string query)
=> "result";
}
}