Implementation for SEP-2243 Http Standardization (#1553)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com> Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3118fcd6cb
commit
b8c4d951ec
15 changed files with 2854 additions and 28 deletions
|
|
@ -49,9 +49,12 @@ public static class NodeHelpers
|
|||
|
||||
var repoRoot = FindRepoRoot();
|
||||
var nodeModulesPath = Path.Combine(repoRoot, "node_modules");
|
||||
var lockFilePath = Path.Combine(repoRoot, "package-lock.json");
|
||||
|
||||
// Use 'npm ci' if node_modules doesn't exist, otherwise assume it's up to date.
|
||||
if (!Directory.Exists(nodeModulesPath))
|
||||
// Run 'npm ci' if node_modules doesn't exist or is outdated
|
||||
// (package-lock.json is newer than node_modules).
|
||||
if (!Directory.Exists(nodeModulesPath) ||
|
||||
File.GetLastWriteTimeUtc(lockFilePath) > Directory.GetLastWriteTimeUtc(nodeModulesPath))
|
||||
{
|
||||
var startInfo = NpmStartInfo("ci", repoRoot);
|
||||
using var process = Process.Start(startInfo)
|
||||
|
|
@ -80,6 +83,13 @@ public static class NodeHelpers
|
|||
{
|
||||
EnsureNpmDependenciesInstalled();
|
||||
|
||||
// If MCP_CONFORMANCE_PROTOCOL_VERSION is set, pass it as --spec-version to the runner.
|
||||
var protocolVersion = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_PROTOCOL_VERSION");
|
||||
if (!string.IsNullOrEmpty(protocolVersion))
|
||||
{
|
||||
arguments += $" --spec-version {protocolVersion}";
|
||||
}
|
||||
|
||||
var repoRoot = FindRepoRoot();
|
||||
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
|
||||
|
||||
|
|
@ -157,6 +167,44 @@ public static class NodeHelpers
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the SEP-2243 conformance scenarios are available by reading
|
||||
/// the conformance package version from the repo's package.json.
|
||||
/// The http-standard-headers, http-custom-headers, http-invalid-tool-headers,
|
||||
/// http-header-validation, and http-custom-header-server-validation scenarios
|
||||
/// require a conformance package version that includes SEP-2243 support.
|
||||
/// </summary>
|
||||
public static bool HasSep2243Scenarios()
|
||||
{
|
||||
try
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var packageJsonPath = Path.Combine(repoRoot, "package.json");
|
||||
if (!File.Exists(packageJsonPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
|
||||
if (json.RootElement.TryGetProperty("dependencies", out var deps) &&
|
||||
deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement))
|
||||
{
|
||||
var versionStr = versionElement.GetString();
|
||||
if (versionStr is not null && Version.TryParse(versionStr, out var version))
|
||||
{
|
||||
// SEP-2243 scenarios are expected in conformance package >= 0.2.0
|
||||
return version >= new Version(0, 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public class ClientConformanceTests
|
|||
|
||||
// Public static property required for SkipUnless attribute
|
||||
public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
|
||||
public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios();
|
||||
|
||||
public ClientConformanceTests(ITestOutputHelper output)
|
||||
{
|
||||
|
|
@ -61,6 +62,21 @@ public class ClientConformanceTests
|
|||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
|
||||
// HTTP Standardization (SEP-2243)
|
||||
[Theory(Skip = "SEP-2243 conformance scenarios not yet available.", SkipUnless = nameof(HasSep2243Scenarios))]
|
||||
[InlineData("http-standard-headers")]
|
||||
[InlineData("http-custom-headers")]
|
||||
[InlineData("http-invalid-tool-headers")]
|
||||
public async Task RunConformanceTest_Sep2243(string scenario)
|
||||
{
|
||||
// Run the conformance test suite
|
||||
var result = await RunClientConformanceScenario(scenario);
|
||||
|
||||
// Report the results
|
||||
Assert.True(result.Success,
|
||||
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||
}
|
||||
|
||||
private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario)
|
||||
{
|
||||
// Construct an absolute path to the conformance client executable
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Common\**\*.cs" />
|
||||
<Compile Include="..\..\src\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
163
tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs
Normal file
163
tests/ModelContextProtocol.Tests/Client/McpHeaderEncoderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue