// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.AI; using ModelContextProtocol.Protocol; namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests; /// /// Unit tests for . /// public sealed class DefaultMcpToolHandlerTests { #region Constructor Tests [Fact] public async Task Constructor_WithNoParameters_ShouldCreateInstanceAsync() { // Act DefaultMcpToolHandler handler = new(); // Assert handler.Should().NotBeNull(); await handler.DisposeAsync(); } [Fact] public async Task Constructor_WithNullHttpClientProvider_ShouldCreateInstanceAsync() { // Act DefaultMcpToolHandler handler = new(httpClientProvider: null); // Assert handler.Should().NotBeNull(); await handler.DisposeAsync(); } [Fact] public async Task Constructor_WithHttpClientProvider_ShouldCreateInstanceAsync() { // Arrange static Task ProviderAsync(string url, CancellationToken ct) => Task.FromResult(new HttpClient()); // Act DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); // Assert handler.Should().NotBeNull(); await handler.DisposeAsync(); } #endregion #region DisposeAsync Tests [Fact] public async Task DisposeAsync_WhenCalled_ShouldCompleteWithoutErrorAsync() { // Arrange DefaultMcpToolHandler handler = new(); // Act Func act = async () => await handler.DisposeAsync(); // Assert await act.Should().NotThrowAsync(); } [Fact] public async Task DisposeAsync_WhenCalledMultipleTimes_ShouldHandleGracefullyAsync() { // Arrange DefaultMcpToolHandler handler = new(); // Act await handler.DisposeAsync(); Func act = async () => await handler.DisposeAsync(); // Assert - Second dispose should throw ObjectDisposedException from the semaphore await act.Should().ThrowAsync(); } #endregion #region HttpClientProvider Tests [Fact] public async Task InvokeToolAsync_WithHttpClientProvider_ShouldCallProviderAsync() { // Arrange bool providerCalled = false; string? capturedServerUrl = null; Task ProviderAsync(string url, CancellationToken ct) { providerCalled = true; capturedServerUrl = url; return Task.FromResult(null); } DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); // Act & Assert - The call will fail because there's no real MCP server, but the provider should be called try { await handler.InvokeToolAsync( serverUrl: "http://localhost:12345/mcp", serverLabel: "test", toolName: "testTool", arguments: null, headers: null, connectionName: null); } catch { // Expected to fail - no real server } finally { await handler.DisposeAsync(); } // Assert providerCalled.Should().BeTrue(); capturedServerUrl.Should().Be("http://localhost:12345/mcp"); } [Fact] public async Task InvokeToolAsync_WithHttpClientProviderReturningClient_ShouldUseProvidedClientAsync() { // Arrange bool providerCalled = false; HttpClient? providedClient = null; Task ProviderAsync(string url, CancellationToken ct) { providerCalled = true; providedClient = new HttpClient(); return Task.FromResult(providedClient); } DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); // Act & Assert - The call will fail because there's no real MCP server, but the provider should be called try { await handler.InvokeToolAsync( serverUrl: "http://localhost:12345/mcp", serverLabel: "test", toolName: "testTool", arguments: null, headers: null, connectionName: null); } catch { // Expected to fail - no real server } finally { await handler.DisposeAsync(); providedClient?.Dispose(); } // Assert providerCalled.Should().BeTrue(); } #endregion #region Caching Tests [Fact] public async Task InvokeToolAsync_SameServerUrl_ShouldCallProviderOncePerAttemptWhenConnectionFailsAsync() { // Arrange int providerCallCount = 0; Task ProviderAsync(string url, CancellationToken ct) { providerCallCount++; return Task.FromResult(null); } DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); const string ServerUrl = "http://localhost:12345/mcp"; try { // Act - Call twice with the same server URL // Since there's no real server, the McpClient.CreateAsync will fail, // so the client won't be cached and the provider will be called each time for (int i = 0; i < 2; i++) { try { await handler.InvokeToolAsync( serverUrl: ServerUrl, serverLabel: "test", toolName: "testTool", arguments: null, headers: null, connectionName: null); } catch { // Expected to fail - no real server } } // Assert - Provider is called each time because McpClient creation fails before caching providerCallCount.Should().Be(2); } finally { await handler.DisposeAsync(); } } [Fact] public async Task InvokeToolAsync_DifferentServerUrls_ShouldCreateSeparateClientsAsync() { // Arrange int providerCallCount = 0; Task ProviderAsync(string url, CancellationToken ct) { providerCallCount++; return Task.FromResult(null); } DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); try { // Act - Call with different server URLs foreach (string serverUrl in new[] { "http://localhost:12345/mcp1", "http://localhost:12345/mcp2" }) { try { await handler.InvokeToolAsync( serverUrl: serverUrl, serverLabel: "test", toolName: "testTool", arguments: null, headers: null, connectionName: null); } catch { // Expected to fail - no real server } } // Assert - Provider should be called once per unique server URL providerCallCount.Should().Be(2); } finally { await handler.DisposeAsync(); } } [Fact] public async Task InvokeToolAsync_SameUrlDifferentHeaders_ShouldCreateSeparateClientsAsync() { // Arrange int providerCallCount = 0; Task ProviderAsync(string url, CancellationToken ct) { providerCallCount++; return Task.FromResult(null); } DefaultMcpToolHandler handler = new(httpClientProvider: ProviderAsync); const string ServerUrl = "http://localhost:12345/mcp"; try { // Act - Call with same URL but different headers Dictionary[] headerSets = [ new() { ["Authorization"] = "Bearer token1" }, new() { ["Authorization"] = "Bearer token2" } ]; foreach (Dictionary headers in headerSets) { try { await handler.InvokeToolAsync( serverUrl: ServerUrl, serverLabel: "test", toolName: "testTool", arguments: null, headers: headers, connectionName: null); } catch { // Expected to fail - no real server } } // Assert - Different headers should create different cache keys providerCallCount.Should().Be(2); } finally { await handler.DisposeAsync(); } } #endregion #region ComputeHeadersHash Tests [Fact] public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString() { // Act string result = DefaultMcpToolHandler.ComputeHeadersHash(null); // Assert result.Should().BeEmpty(); } [Fact] public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString() { // Act string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary()); // Assert result.Should().BeEmpty(); } [Fact] public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash() { // Arrange Dictionary headers1 = new() { ["Authorization"] = "Bearer token123", ["X-Custom"] = "value1" }; Dictionary headers2 = new() { ["X-Custom"] = "value1", ["Authorization"] = "Bearer token123" }; // Act string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); // Assert hash1.Should().Be(hash2); } [Fact] public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash() { // Arrange — RFC 7230: header names are case-insensitive Dictionary headers1 = new() { ["Authorization"] = "Bearer token" }; Dictionary headers2 = new() { ["authorization"] = "Bearer token" }; // Act string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); // Assert hash1.Should().Be(hash2); } [Fact] public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash() { // Arrange — RFC 7235: credentials are case-sensitive Dictionary headers1 = new() { ["Authorization"] = "Bearer ABC" }; Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" }; // Act string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); // Assert hash1.Should().NotBe(hash2); } [Fact] public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash() { // Arrange Dictionary headers1 = new() { ["Authorization"] = "Bearer token1" }; Dictionary headers2 = new() { ["Authorization"] = "Bearer token2" }; // Act string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1); string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2); // Assert hash1.Should().NotBe(hash2); } #endregion #region Cache Key Discrimination Tests // These tests exercise BuildCacheKey directly because the integration path // (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior // without standing up a real MCP server — McpClient.CreateAsync fails before // _clients[key] = newClient runs, so nothing ever gets cached. // Tuple equality on the returned 4-tuple verifies that the dimensions // collectively discriminate cache entries. [Fact] public void BuildCacheKey_SameInputs_ReturnsEqualKeys() { // Arrange Dictionary headers = new() { ["Authorization"] = "Bearer token" }; // Act var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers); var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers); // Assert key1.Should().Be(key2); } [Fact] public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys() { // Act var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null); var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null); // Assert key1.Should().NotBe(key2); key1.Connection.Should().Be("connection-a"); key2.Connection.Should().Be("connection-b"); } [Fact] public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys() { // Act var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null); var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null); // Assert key1.Should().NotBe(key2); key1.Label.Should().Be("label-a"); key2.Label.Should().Be("label-b"); } [Fact] public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys() { // Arrange — RFC 3986: URL path is case-sensitive // Act var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null); var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null); // Assert key1.Should().NotBe(key2); } [Fact] public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys() { // Arrange — RFC 7235: credentials are case-sensitive Dictionary headers1 = new() { ["Authorization"] = "Bearer ABC" }; Dictionary headers2 = new() { ["Authorization"] = "Bearer abc" }; // Act var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1); var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2); // Assert — header value case must propagate into the cache key key1.Should().NotBe(key2); key1.HeadersHash.Should().NotBe(key2.HeadersHash); } [Fact] public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString() { // Act var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null); // Assert — verifies null-safety contract callers rely on key.Label.Should().BeEmpty(); key.Connection.Should().BeEmpty(); key.HeadersHash.Should().BeEmpty(); } #endregion #region Reserved Tools/List Tests [Fact] public void IsListToolsToolName_WithReservedName_ShouldReturnTrue() { // Act bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName); // Assert result.Should().BeTrue(); } [Fact] public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse() { // Act bool result = DefaultMcpToolHandler.IsListToolsToolName("search"); // Assert result.Should().BeFalse(); } [Fact] public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync() { // Arrange DefaultMcpToolHandler handler = new(); try { // Act Func act = async () => await handler.InvokeToolAsync( serverUrl: "http://localhost:12345/mcp", serverLabel: "test", toolName: DefaultMcpToolHandler.ListToolsToolName, arguments: new Dictionary { ["ignored"] = true }, headers: null, connectionName: null); // Assert await act.Should().ThrowAsync() .WithMessage("*does not accept tool arguments*"); } finally { await handler.DisposeAsync(); } } [Fact] public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync() { // Arrange JsonElement inputSchema = JsonSerializer.Deserialize( """ { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ] } """); Tool tool = new() { Name = "search", Description = "Searches documentation.", InputSchema = inputSchema }; // Act McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]); // Assert TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType().Subject; using JsonDocument document = JsonDocument.Parse(text.Text); JsonElement listedTool = document.RootElement.GetProperty("tools")[0]; listedTool.GetProperty("name").GetString().Should().Be("search"); listedTool.GetProperty("description").GetString().Should().Be("Searches documentation."); listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string"); } #endregion #region Interface Implementation Tests [Fact] public async Task DefaultMcpToolHandler_ShouldImplementIMcpToolHandlerAsync() { // Arrange & Act DefaultMcpToolHandler handler = new(); // Assert handler.Should().BeAssignableTo(); await handler.DisposeAsync(); } [Fact] public async Task DefaultMcpToolHandler_ShouldImplementIAsyncDisposableAsync() { // Arrange & Act DefaultMcpToolHandler handler = new(); // Assert handler.Should().BeAssignableTo(); await handler.DisposeAsync(); } #endregion #region ConvertContentBlock Tests [Fact] public void ConvertContentBlock_TextContentBlock_ShouldReturnTextContent() { // Arrange TextContentBlock block = new() { Text = "hello world" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert TextContent textContent = result.Should().BeOfType().Subject; textContent.Text.Should().Be("hello world"); textContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ImageContentBlock_WithEmptyData_ShouldReturnDataContentWithEmptyUri() { // Arrange ImageContentBlock block = new() { Data = ReadOnlyMemory.Empty, MimeType = "image/png" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("image/png"); dataContent.Uri.Should().Be("data:image/png;base64,"); dataContent.Data.IsEmpty.Should().BeTrue(); dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ImageContentBlock_WithBase64Payload_ShouldReturnDataContent() { // Arrange const string Base64Payload = "iVBORw0KGgo="; byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload); byte[] expectedDecoded = Convert.FromBase64String(Base64Payload); ImageContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = "image/png" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("image/png"); dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded); dataContent.Uri.Should().Be($"data:image/png;base64,{Base64Payload}"); dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_AudioContentBlock_WithEmptyData_ShouldReturnDataContentWithEmptyUri() { // Arrange AudioContentBlock block = new() { Data = ReadOnlyMemory.Empty, MimeType = "audio/wav" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("audio/wav"); dataContent.Uri.Should().Be("data:audio/wav;base64,"); dataContent.Data.IsEmpty.Should().BeTrue(); dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent() { // Arrange const string Base64Payload = "UklGRiQA"; byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload); byte[] expectedDecoded = Convert.FromBase64String(Base64Payload); AudioContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = "audio/wav" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("audio/wav"); dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded); dataContent.Uri.Should().Be($"data:audio/wav;base64,{Base64Payload}"); dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent() { // Arrange EmbeddedResourceBlock block = new() { Resource = new TextResourceContents { Text = "embedded text payload", Uri = "resource://example", MimeType = "text/plain", }, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert TextContent textContent = result.Should().BeOfType().Subject; textContent.Text.Should().Be("embedded text payload"); textContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent() { // Arrange const string Base64Payload = "UklGRiQA"; byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload); byte[] expectedDecoded = Convert.FromBase64String(Base64Payload); EmbeddedResourceBlock block = new() { Resource = new BlobResourceContents { Blob = new ReadOnlyMemory(base64Bytes), Uri = "resource://example.bin", MimeType = "application/zip", }, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("application/zip"); dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded); dataContent.Uri.Should().Be($"data:application/zip;base64,{Base64Payload}"); dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ResourceLinkBlock_WithUri_ShouldReturnUriContent() { // Arrange ResourceLinkBlock block = new() { Uri = "https://example.com/resource.bin", Name = "resource.bin", MimeType = "application/zip", }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert UriContent uriContent = result.Should().BeOfType().Subject; uriContent.Uri.ToString().Should().Be("https://example.com/resource.bin"); uriContent.MediaType.Should().Be("application/zip"); uriContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ResourceLinkBlock_WithNullMimeType_ShouldDefaultToOctetStream() { // Arrange ResourceLinkBlock block = new() { Uri = "https://example.com/resource", Name = "resource", MimeType = null, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert UriContent uriContent = result.Should().BeOfType().Subject; uriContent.Uri.ToString().Should().Be("https://example.com/resource"); uriContent.MediaType.Should().Be("application/octet-stream"); } [Fact] public void ConvertContentBlock_ResourceLinkBlock_WithMeta_ShouldPropagateToAdditionalProperties() { // Arrange ResourceLinkBlock block = new() { Uri = "https://example.com/resource.bin", Name = string.Empty, MimeType = "application/zip", Meta = new System.Text.Json.Nodes.JsonObject { ["traceId"] = "abc-123", ["priority"] = 7, }, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert UriContent uriContent = result.Should().BeOfType().Subject; uriContent.AdditionalProperties.Should().NotBeNull(); uriContent.AdditionalProperties!.Should().HaveCount(2); uriContent.AdditionalProperties["traceId"].Should().BeSameAs(block.Meta!["traceId"]); uriContent.AdditionalProperties["priority"].Should().BeSameAs(block.Meta["priority"]); } [Fact] public void ConvertContentBlock_ResourceLinkBlock_WithName_ShouldMapNameToFilenameAdditionalProperty() { // Arrange ResourceLinkBlock block = new() { Uri = "https://example.com/resource.bin", Name = "resource.bin", MimeType = "application/zip", }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert UriContent uriContent = result.Should().BeOfType().Subject; uriContent.AdditionalProperties.Should().NotBeNull(); uriContent.AdditionalProperties!["filename"].Should().Be("resource.bin"); } [Fact] public void ConvertContentBlock_ToolUseContentBlock_ShouldReturnFunctionCallContent() { // Arrange using JsonDocument input = JsonDocument.Parse("{\"city\":\"Seattle\",\"unit\":\"celsius\"}"); ToolUseContentBlock block = new() { Id = "call-1", Name = "get_weather", Input = input.RootElement.Clone(), }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert FunctionCallContent call = result.Should().BeOfType().Subject; call.CallId.Should().Be("call-1"); call.Name.Should().Be("get_weather"); call.Arguments.Should().NotBeNull(); call.Arguments!.Should().ContainKey("city"); call.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ToolResultContentBlock_NotError_ShouldReturnFunctionResultContent() { // Arrange ToolResultContentBlock block = new() { ToolUseId = "call-1", Content = [new TextContentBlock { Text = "ok" }], IsError = false, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert FunctionResultContent functionResult = result.Should().BeOfType().Subject; functionResult.CallId.Should().Be("call-1"); functionResult.Exception.Should().BeNull(); functionResult.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ToolResultContentBlock_WithIsError_ShouldSetException() { // Arrange ToolResultContentBlock block = new() { ToolUseId = "call-2", Content = [new TextContentBlock { Text = "boom" }], IsError = true, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert FunctionResultContent functionResult = result.Should().BeOfType().Subject; functionResult.CallId.Should().Be("call-2"); functionResult.Exception.Should().NotBeNull(); functionResult.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_BlockWithMeta_ShouldPropagateToAdditionalProperties() { // Arrange TextContentBlock block = new() { Text = "hello", Meta = new System.Text.Json.Nodes.JsonObject { ["traceId"] = "abc-123", ["priority"] = 7, }, }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert result.AdditionalProperties.Should().NotBeNull(); result.AdditionalProperties!.Should().ContainKey("traceId"); result.AdditionalProperties.Should().ContainKey("priority"); } #endregion #region Origin Pinning Tests [Fact] public void StripCredentialHeadersOnCrossOrigin_SameOrigin_RetainsAuthorization() { // Arrange using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com/mcp/message"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert — same origin, credential is preserved request.Headers.Contains("Authorization").Should().BeTrue(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_DifferentHost_RemovesAuthorization() { // Arrange — server-advertised endpoint on an attacker origin using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert — credential must not cross the origin boundary request.Headers.Contains("Authorization").Should().BeFalse(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_DifferentPort_RemovesAuthorization() { // Arrange — same host but a different port is a different origin using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com:8443/mcp"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert request.Headers.Contains("Authorization").Should().BeFalse(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_ExplicitDefaultPort_RetainsAuthorization() { // Arrange — pinned endpoint carries an explicit :443 while the request omits it; these are // the same origin and Uri.Compare must normalize the default port rather than strip. using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com/mcp/message"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com:443/mcp")); // Assert — explicit vs implicit default port is the same origin request.Headers.Contains("Authorization").Should().BeTrue(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_RelativeRequestUri_RetainsAuthorization() { // Arrange — a relative URI resolves against the client's base address (the pinned origin) and // therefore can never target a foreign origin. It must not throw and must retain credentials. using HttpRequestMessage request = new(HttpMethod.Post, new Uri("/mcp/message", UriKind.Relative)); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act Action act = () => OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert — does not throw and leaves the credential in place act.Should().NotThrow(); request.Headers.Contains("Authorization").Should().BeTrue(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_DifferentScheme_RemovesAuthorization() { // Arrange — downgrade to http is a different origin (and would leak over plaintext) using HttpRequestMessage request = new(HttpMethod.Post, "http://trusted.example.com/mcp"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert request.Headers.Contains("Authorization").Should().BeFalse(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_CrossOrigin_RemovesCookieAndProxyAuthorization() { // Arrange using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); request.Headers.TryAddWithoutValidation("Cookie", "session=abc"); request.Headers.TryAddWithoutValidation("Proxy-Authorization", "Bearer proxy-token"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert — all credential-bearing headers are stripped request.Headers.Contains("Authorization").Should().BeFalse(); request.Headers.Contains("Cookie").Should().BeFalse(); request.Headers.Contains("Proxy-Authorization").Should().BeFalse(); } [Fact] public void StripCredentialHeadersOnCrossOrigin_CrossOrigin_PreservesNonCredentialHeaders() { // Arrange using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); request.Headers.TryAddWithoutValidation("X-Trace-Id", "trace-123"); // Act OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, new Uri("https://trusted.example.com")); // Assert — only credential headers are removed; other headers are untouched request.Headers.Contains("Authorization").Should().BeFalse(); request.Headers.Contains("X-Trace-Id").Should().BeTrue(); } [Fact] public async Task OriginPinningHandler_CrossOriginRequest_DoesNotForwardAuthorizationAsync() { // Arrange — capture what the inner handler actually receives on the wire CapturingHandler inner = new(); using OriginPinningHandler pinning = new(new Uri("https://trusted.example.com/mcp")) { InnerHandler = inner }; using HttpMessageInvoker invoker = new(pinning); using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act using HttpResponseMessage response = await invoker.SendAsync(request, CancellationToken.None); // Assert — the credential never reached the inner handler for the foreign origin response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); inner.LastRequestHadAuthorization.Should().BeFalse(); } [Fact] public async Task OriginPinningHandler_SameOriginRequest_ForwardsAuthorizationAsync() { // Arrange CapturingHandler inner = new(); using OriginPinningHandler pinning = new(new Uri("https://trusted.example.com/mcp")) { InnerHandler = inner }; using HttpMessageInvoker invoker = new(pinning); using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com/mcp/message"); request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token"); // Act using HttpResponseMessage response = await invoker.SendAsync(request, CancellationToken.None); // Assert — same-origin credential flows through normally response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); inner.LastRequestHadAuthorization.Should().BeTrue(); } private sealed class CapturingHandler : HttpMessageHandler { public bool LastRequestHadAuthorization { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { this.LastRequestHadAuthorization = request.Headers.Contains("Authorization"); return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); } } #endregion }