diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml index 993fd3d3ff0..a6a2edfc28c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml @@ -22,13 +22,6 @@ lib/net462/Microsoft.Extensions.AI.Abstractions.dll true - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - lib/net462/Microsoft.Extensions.AI.Abstractions.dll - true - CP0002 M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url @@ -64,13 +57,6 @@ lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll true - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll - true - CP0002 M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url @@ -106,13 +92,6 @@ lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll true - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll - true - CP0002 M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url @@ -148,13 +127,6 @@ lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll true - - CP0002 - M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll - true - CP0002 M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url @@ -169,4 +141,4 @@ lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll true - \ No newline at end of file + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index fbc80fe4d59..95d2739fd9f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -14,9 +14,15 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class HostedMcpServerTool : AITool { + /// The name of the Authorization header. + private const string AuthorizationHeaderName = "Authorization"; + /// Any additional properties associated with the tool. private IReadOnlyDictionary? _additionalProperties; + /// Lazily-initialized collection of headers to include when calling the remote MCP server. + private Dictionary? _headers; + /// /// Initializes a new instance of the class. /// @@ -103,7 +109,35 @@ private static string ValidateUrl(Uri serverUrl) /// /// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server. /// - public string? AuthorizationToken { get; set; } + /// + /// When set, this value is automatically added to the dictionary with the key "Authorization" + /// and the value "Bearer {token}". Setting this property will overwrite any existing "Authorization" header in . + /// Setting this property to will remove the "Authorization" header from . + /// + public string? AuthorizationToken + { + get + { + if (_headers?.TryGetValue(AuthorizationHeaderName, out string? value) is true && + value?.StartsWith("Bearer ", StringComparison.Ordinal) is true) + { + return value.Substring("Bearer ".Length); + } + + return null; + } + set + { + if (value is not null) + { + Headers[AuthorizationHeaderName] = $"Bearer {value}"; + } + else if (_headers is not null) + { + _ = _headers.Remove(AuthorizationHeaderName); + } + } + } /// /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. @@ -134,4 +168,14 @@ private static string ValidateUrl(Uri serverUrl) /// /// public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; } + + /// + /// Gets a mutable dictionary of HTTP headers to include when calling the remote MCP server. + /// + /// + /// + /// The underlying provider is not guaranteed to support or honor the headers. + /// + /// + public IDictionary Headers => _headers ??= new Dictionary(); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index e6359cbdd7a..b9aca3161a0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -589,12 +589,26 @@ void IDisposable.Dispose() }; case HostedMcpServerTool mcpTool: - McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? serverAddressUrl) ? - new McpTool(mcpTool.ServerName, serverAddressUrl) : + bool isUrl = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? serverAddressUrl); + McpTool responsesMcpTool = isUrl ? + new McpTool(mcpTool.ServerName, serverAddressUrl!) : new McpTool(mcpTool.ServerName, new McpToolConnectorId(mcpTool.ServerAddress)); responsesMcpTool.ServerDescription = mcpTool.ServerDescription; - responsesMcpTool.AuthorizationToken = mcpTool.AuthorizationToken; + + if (isUrl) + { + // For http: favor headers over authorization token. + if (mcpTool.Headers.Count > 0) + { + responsesMcpTool.Headers = mcpTool.Headers; + } + } + else + { + // For connectors: Only set AuthorizationToken, do not include headers. + responsesMcpTool.AuthorizationToken = mcpTool.AuthorizationToken; + } if (mcpTool.AllowedTools is not null) { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs index aa23cfd3ff4..56c04ce1dfa 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -24,6 +24,8 @@ public void Constructor_PropsDefault() Assert.Null(tool.ServerDescription); Assert.Null(tool.AllowedTools); Assert.Null(tool.ApprovalMode); + Assert.NotNull(tool.Headers); + Assert.Empty(tool.Headers); } [Fact] @@ -95,6 +97,91 @@ public void Constructor_Roundtrips() var customApprovalMode = new HostedMcpServerToolRequireSpecificApprovalMode(["tool1"], ["tool2"]); tool.ApprovalMode = customApprovalMode; Assert.Same(customApprovalMode, tool.ApprovalMode); + + Assert.NotNull(tool.Headers); + Assert.Single(tool.Headers); + tool.Headers["X-Custom-Header"] = "value1"; + Assert.True(tool.Headers.Count == 2); + Assert.Equal("value1", tool.Headers["X-Custom-Header"]); + } + + [Fact] + public void Constructor_WithHeaders_Uri_Roundtrips() + { + var headers = new Dictionary + { + ["Authorization"] = "Bearer token456", + ["X-Custom"] = "value2" + }; + HostedMcpServerTool tool = new("serverName", new Uri("https://localhost/")); + foreach (KeyValuePair keyValuePair in headers) + { + tool.Headers[keyValuePair.Key] = keyValuePair.Value; + } + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("https://localhost/", tool.ServerAddress); + Assert.Equal(2, tool.Headers.Count); + Assert.Equal("Bearer token456", tool.Headers["Authorization"]); + Assert.Equal("token456", tool.AuthorizationToken); + Assert.Equal("value2", tool.Headers["X-Custom"]); + } + + [Fact] + public void Constructor_WithNullHeaders_CreatesEmptyDictionary() + { + HostedMcpServerTool tool1 = new("serverName", "connector_id"); + Assert.NotNull(tool1.Headers); + Assert.Empty(tool1.Headers); + + HostedMcpServerTool tool2 = new("serverName", new Uri("https://localhost/")); + Assert.NotNull(tool2.Headers); + Assert.Empty(tool2.Headers); + } + + [Fact] + public void AuthorizationToken_And_Headers_NoOrderingIssues() + { + // Verify that setting AuthorizationToken followed by adding to Headers works + var tool1 = new HostedMcpServerTool("server", "https://localhost/") + { + AuthorizationToken = "token123" + }; + tool1.Headers["X-Custom"] = "value1"; + + Assert.Equal(2, tool1.Headers.Count); + Assert.Equal("Bearer token123", tool1.Headers["Authorization"]); + Assert.Equal("token123", tool1.AuthorizationToken); + Assert.Equal("value1", tool1.Headers["X-Custom"]); + + // Verify that adding to Headers followed by setting AuthorizationToken works the same + var tool2 = new HostedMcpServerTool("server", "https://localhost/"); + tool2.Headers["X-Custom"] = "value1"; + tool2.AuthorizationToken = "token123"; + + Assert.Equal(2, tool2.Headers.Count); + Assert.Equal("Bearer token123", tool2.Headers["Authorization"]); + Assert.Equal("token123", tool2.AuthorizationToken); + Assert.Equal("value1", tool2.Headers["X-Custom"]); + + // Verify setting AuthorizationToken to null removes only Authorization header + tool2.AuthorizationToken = null; + Assert.Single(tool2.Headers); + Assert.False(tool2.Headers.ContainsKey("Authorization")); + Assert.Null(tool2.AuthorizationToken); + Assert.Equal("value1", tool2.Headers["X-Custom"]); + } + + [Fact] + public void Headers_WithNullAuthorization() + { + var tool = new HostedMcpServerTool("server", "https://localhost/"); + tool.Headers["Authorization"] = null!; + tool.Headers["X-Custom"] = "value1"; + Assert.Equal(2, tool.Headers.Count); + Assert.Null(tool.Headers["Authorization"]); + Assert.Null(tool.AuthorizationToken); + Assert.Equal("value1", tool.Headers["X-Custom"]); } [Fact] @@ -111,3 +198,4 @@ public void Constructor_Throws() Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", (Uri)null!)); } } + diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 1a711b7417c..1aa7e1e4d0f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -393,7 +393,30 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthToken_ProducesVa Assert.NotNull(result); var tool = Assert.IsType(result); - Assert.Equal("test-token", tool.AuthorizationToken); + Assert.Null(tool.AuthorizationToken); + Assert.NotNull(tool.Headers); + Assert.Single(tool.Headers); + Assert.Equal("Bearer test-token", tool.Headers["Authorization"]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthTokenAndCustomHeaders_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AuthorizationToken = "test-token" + }; + mcpTool.Headers["X-Custom-Header"] = "custom-value"; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Null(tool.AuthorizationToken); + Assert.NotNull(tool.Headers); + Assert.Equal(2, tool.Headers.Count); + Assert.Equal("Bearer test-token", tool.Headers["Authorization"]); + Assert.Equal("custom-value", tool.Headers["X-Custom-Header"]); } [Fact] @@ -490,6 +513,24 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolWithRequireSpecificAppro Assert.Contains("tool3", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); } + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolConnector_OnlySetsAuthToken() + { + var mcpTool = new HostedMcpServerTool("calendar", "connector_googlecalendar") + { + AuthorizationToken = "connector-token" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("connector-token", tool.AuthorizationToken); + + // For connectors, headers should not be set even though AuthorizationToken adds to Headers internally + Assert.Empty(tool.Headers); + } + [Fact] public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 1e19466ee7f..c84eba42fa1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -2130,6 +2130,88 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() Assert.Equal(1569, response.Usage.TotalTokenCount); } + [Fact] + public async Task McpToolCall_WithAuthorizationTokenAndCustomHeaders_IncludesInRequest() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "headers": { + "Authorization": "Bearer test-auth-token-12345", + "X-Custom-Header": "custom-value" + }, + "require_approval": "never" + } + ], + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_auth01", + "object": "response", + "created_at": 1757299043, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_auth01", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hi!" + } + ] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 2, + "total_tokens": 12 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var mcpTool = new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + AuthorizationToken = "test-auth-token-12345" + }; + + mcpTool.Headers!["X-Custom-Header"] = "custom-value"; + + var response = await client.GetResponseAsync("hello", new ChatOptions { Tools = [mcpTool] }); + + Assert.NotNull(response); + Assert.Equal("resp_auth01", response.ResponseId); + var message = Assert.Single(response.Messages); + Assert.Equal("Hi!", message.Text); + } + [Fact] public async Task GetResponseAsync_BackgroundResponses_FirstCall() {