From 19077d88b20adca09e65b3a5c1233d93535428dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Thu, 2 Oct 2025 10:29:02 -0500 Subject: [PATCH 1/8] Add support for Connector ID --- .../Tools/HostedMcpServerTool.cs | 37 ++++++++++- .../OpenAIResponsesChatClient.cs | 30 +++++++-- .../Tools/HostedMcpServerToolTests.cs | 66 ++++++++++++++++++- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index b5ed4938a45..c859ccc3123 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -37,6 +37,30 @@ public HostedMcpServerTool(string serverName, Uri url) { ServerName = Throw.IfNullOrWhitespace(serverName); Url = Throw.IfNull(url); + ConnectorID = null; + } + + /// + /// Initializes a new instance of the class with a connector ID. + /// + private HostedMcpServerTool(string serverName, string connectorID, Uri? url) + { + ServerName = Throw.IfNullOrWhitespace(serverName); + ConnectorID = Throw.IfNullOrWhitespace(connectorID); + Url = url; // needed for disambiguation. + } + + /// + /// Creates a new instance of the class using a connector ID. + /// + /// The name of the remote MCP server. + /// The connector ID of the known MCP server supported by the provider. + /// A new instance configured with the specified connector ID. + /// or are . + /// or are empty or composed entirely of whitespace. + public static HostedMcpServerTool CreateWithConnectorID(string serverName, string connectorID) + { + return new HostedMcpServerTool(serverName, connectorID, null); } /// @@ -47,7 +71,18 @@ public HostedMcpServerTool(string serverName, Uri url) /// /// Gets the URL of the remote MCP server. /// - public Uri Url { get; } + /// + /// This property is when the tool is configured with a instead of a URL. + /// + public Uri? Url { get; } + + /// + /// Gets the connector ID of the known MCP server supported by the provider. + /// + /// + /// This property is when the tool is configured with a instead of a connector ID. + /// + public string? ConnectorID { get; } /// /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 5da26a435ff..e59c45b5b0b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -5,6 +5,7 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -467,11 +468,8 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt break; case HostedMcpServerTool mcpTool: - McpTool responsesMcpTool = ResponseTool.CreateMcpTool( - mcpTool.ServerName, - mcpTool.Url, - serverDescription: mcpTool.ServerDescription, - headers: mcpTool.Headers); + + McpTool responsesMcpTool = ToOpenAIMcpTool(mcpTool); if (mcpTool.AllowedTools is not null) { @@ -862,6 +860,28 @@ private static List ToResponseContentParts(IList return parts; } + private static McpTool ToOpenAIMcpTool(HostedMcpServerTool meaiMcpTool) + { + Debug.Assert(meaiMcpTool.Url is null != meaiMcpTool.ConnectorID is null, "Either Url or ConnectorID must be present but never both."); + + if (meaiMcpTool.Url != null) + { + return ResponseTool.CreateMcpTool( + meaiMcpTool.ServerName, + meaiMcpTool.Url, + serverDescription: meaiMcpTool.ServerDescription, + headers: meaiMcpTool.Headers); + } + else + { + return ResponseTool.CreateMcpTool( + meaiMcpTool.ServerName, + new McpToolConnectorId(meaiMcpTool.ConnectorID), + serverDescription: meaiMcpTool.ServerDescription, + headers: meaiMcpTool.Headers); + } + } + /// Adds new for the specified into . private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) { 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 c77e59e3307..708d771a9d9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -17,7 +17,8 @@ public void Constructor_PropsDefault() Assert.Empty(tool.AdditionalProperties); Assert.Equal("serverName", tool.ServerName); - Assert.Equal("https://localhost/", tool.Url.ToString()); + Assert.Equal("https://localhost/", tool.Url!.ToString()); + Assert.Null(tool.ConnectorID); Assert.Empty(tool.Description); Assert.Null(tool.AllowedTools); @@ -34,7 +35,8 @@ public void Constructor_Roundtrips() Assert.Equal(nameof(HostedMcpServerTool), tool.Name); Assert.Equal("serverName", tool.ServerName); - Assert.Equal("https://localhost/", tool.Url.ToString()); + Assert.Equal("https://localhost/", tool.Url!.ToString()); + Assert.Null(tool.ConnectorID); Assert.Empty(tool.Description); Assert.Null(tool.ServerDescription); @@ -73,4 +75,64 @@ public void Constructor_Throws() Assert.Throws(() => new HostedMcpServerTool("name", (string)null!)); Assert.Throws(() => new HostedMcpServerTool("name", string.Empty)); } + + [Fact] + public void CreateWithConnectorID_PropsDefault() + { + HostedMcpServerTool tool = HostedMcpServerTool.CreateWithConnectorID("serverName", "connector"); + + Assert.Empty(tool.AdditionalProperties); + + Assert.Equal("serverName", tool.ServerName); + Assert.Null(tool.Url); + Assert.Equal("connector", tool.ConnectorID); + + Assert.Empty(tool.Description); + Assert.Null(tool.AllowedTools); + Assert.Null(tool.ApprovalMode); + } + + [Fact] + public void CreateWithConnectorID_Roundtrips() + { + HostedMcpServerTool tool = HostedMcpServerTool.CreateWithConnectorID("serverName", "connector"); + + Assert.Empty(tool.AdditionalProperties); + Assert.Empty(tool.Description); + Assert.Equal(nameof(HostedMcpServerTool), tool.Name); + + Assert.Equal("serverName", tool.ServerName); + Assert.Null(tool.Url); + Assert.Equal("connector", tool.ConnectorID); + Assert.Empty(tool.Description); + + Assert.Null(tool.ServerDescription); + string serverDescription = "This is a test server"; + tool.ServerDescription = serverDescription; + Assert.Equal(serverDescription, tool.ServerDescription); + + Assert.Null(tool.AllowedTools); + List allowedTools = ["tool1", "tool2"]; + tool.AllowedTools = allowedTools; + Assert.Same(allowedTools, tool.AllowedTools); + + Assert.Null(tool.ApprovalMode); + tool.ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire; + Assert.Same(HostedMcpServerToolApprovalMode.NeverRequire, tool.ApprovalMode); + + Assert.Null(tool.Headers); + Dictionary headers = []; + tool.Headers = headers; + Assert.Same(headers, tool.Headers); + } + + [Fact] + public void CreateWithConnectorID_Throws() + { + Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID(string.Empty, "connector")); + Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID(null!, "connector")); + Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID("name", string.Empty)); + Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID("name", null!)); + Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID("name", " ")); + } } From a9d0a70da75af9c50ed661f7b569a9327da2be20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Wed, 8 Oct 2025 11:50:09 -0500 Subject: [PATCH 2/8] * Convert Url to string ServerAddress capable of carrying connector ids * Add AuthorizationToken property since it is now promoted in both OpenAI and Anthropic * Relax McpServerToolCallContent ToolName and ServerName --- .../Contents/McpServerToolCallContent.cs | 18 ++-- .../Tools/HostedMcpServerTool.cs | 62 +++----------- .../OpenAIResponsesChatClient.cs | 50 +++++------ .../Contents/McpServerToolCallContentTests.cs | 23 ++--- .../Tools/HostedMcpServerToolTests.cs | 85 ++++--------------- .../OpenAIResponseClientIntegrationTests.cs | 59 +++++++++++++ 6 files changed, 125 insertions(+), 172 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs index 5ed6385789c..9042793f9cb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -22,15 +22,11 @@ public sealed class McpServerToolCallContent : AIContent /// Initializes a new instance of the class. /// /// The tool call ID. - /// The tool name. - /// The MCP server name. - /// , , or are . - /// , , or are empty or composed entirely of whitespace. - public McpServerToolCallContent(string callId, string toolName, string serverName) + /// is . + /// is empty or composed entirely of whitespace. + public McpServerToolCallContent(string callId) { CallId = Throw.IfNullOrWhitespace(callId); - ToolName = Throw.IfNullOrWhitespace(toolName); - ServerName = Throw.IfNullOrWhitespace(serverName); } /// @@ -39,14 +35,14 @@ public McpServerToolCallContent(string callId, string toolName, string serverNam public string CallId { get; } /// - /// Gets the name of the tool called. + /// Gets or sets the name of the tool called. /// - public string ToolName { get; } + public string? ToolName { get; set; } /// - /// Gets the name of the MCP server. + /// Gets or sets the name of the MCP server. /// - public string ServerName { get; } + public string? ServerName { get; set; } /// /// Gets or sets the arguments used for the tool call. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index c859ccc3123..2059d095ea0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -18,49 +18,13 @@ public class HostedMcpServerTool : AITool /// Initializes a new instance of the class. /// /// The name of the remote MCP server. - /// The URL of the remote MCP server. - /// or are . - /// is empty or composed entirely of whitespace. - public HostedMcpServerTool(string serverName, [StringSyntax(StringSyntaxAttribute.Uri)] string url) - : this(serverName, new Uri(Throw.IfNull(url))) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The name of the remote MCP server. - /// The URL of the remote MCP server. - /// or are . - /// is empty or composed entirely of whitespace. - public HostedMcpServerTool(string serverName, Uri url) + /// The address of the remote MCP server. + /// or are . + /// or are empty or composed entirely of whitespace. + public HostedMcpServerTool(string serverName, string serverAddress) { ServerName = Throw.IfNullOrWhitespace(serverName); - Url = Throw.IfNull(url); - ConnectorID = null; - } - - /// - /// Initializes a new instance of the class with a connector ID. - /// - private HostedMcpServerTool(string serverName, string connectorID, Uri? url) - { - ServerName = Throw.IfNullOrWhitespace(serverName); - ConnectorID = Throw.IfNullOrWhitespace(connectorID); - Url = url; // needed for disambiguation. - } - - /// - /// Creates a new instance of the class using a connector ID. - /// - /// The name of the remote MCP server. - /// The connector ID of the known MCP server supported by the provider. - /// A new instance configured with the specified connector ID. - /// or are . - /// or are empty or composed entirely of whitespace. - public static HostedMcpServerTool CreateWithConnectorID(string serverName, string connectorID) - { - return new HostedMcpServerTool(serverName, connectorID, null); + ServerAddress = Throw.IfNullOrWhitespace(serverAddress); } /// @@ -69,20 +33,14 @@ public static HostedMcpServerTool CreateWithConnectorID(string serverName, strin public string ServerName { get; } /// - /// Gets the URL of the remote MCP server. + /// Gets the address of the remote MCP server. /// - /// - /// This property is when the tool is configured with a instead of a URL. - /// - public Uri? Url { get; } + public string ServerAddress { get; } /// - /// Gets the connector ID of the known MCP server supported by the provider. + /// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server. /// - /// - /// This property is when the tool is configured with a instead of a connector ID. - /// - public string? ConnectorID { get; } + public string? AuthorizationToken { get; set; } /// /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. @@ -118,7 +76,7 @@ public static HostedMcpServerTool CreateWithConnectorID(string serverName, strin /// Gets or sets the HTTP headers that the AI service should use when calling the remote MCP server. /// /// - /// This property is useful for specifying the authentication header or other headers required by the MCP server. + /// This property is useful for specifying headers required by the MCP server. /// public IDictionary? Headers { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index e59c45b5b0b..76662f5df36 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -5,7 +5,6 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -182,8 +181,10 @@ internal static IEnumerable ToChatMessages(IEnumerable break; case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallApprovalRequestItem mtcari: - yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id) { + ToolName = mtcari.ToolName, + ServerName = mtcari.ServerLabel, Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, RawRepresentation = mtcari, }) @@ -468,8 +471,19 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt break; case HostedMcpServerTool mcpTool: - - McpTool responsesMcpTool = ToOpenAIMcpTool(mcpTool); + McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + url, + mcpTool.AuthorizationToken, + mcpTool.ServerDescription, + headers: mcpTool.Headers) : + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + new McpToolConnectorId(mcpTool.ServerAddress), + mcpTool.AuthorizationToken, + mcpTool.ServerDescription, + headers: mcpTool.Headers); if (mcpTool.AllowedTools is not null) { @@ -860,33 +874,13 @@ private static List ToResponseContentParts(IList return parts; } - private static McpTool ToOpenAIMcpTool(HostedMcpServerTool meaiMcpTool) - { - Debug.Assert(meaiMcpTool.Url is null != meaiMcpTool.ConnectorID is null, "Either Url or ConnectorID must be present but never both."); - - if (meaiMcpTool.Url != null) - { - return ResponseTool.CreateMcpTool( - meaiMcpTool.ServerName, - meaiMcpTool.Url, - serverDescription: meaiMcpTool.ServerDescription, - headers: meaiMcpTool.Headers); - } - else - { - return ResponseTool.CreateMcpTool( - meaiMcpTool.ServerName, - new McpToolConnectorId(meaiMcpTool.ConnectorID), - serverDescription: meaiMcpTool.ServerDescription, - headers: meaiMcpTool.Headers); - } - } - /// Adds new for the specified into . private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) { - contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName, mtci.ServerLabel) + contents.Add(new McpServerToolCallContent(mtci.Id) { + ToolName = mtci.ToolName, + ServerName = mtci.ServerLabel, Arguments = JsonSerializer.Deserialize(mtci.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, // We purposefully do not set the RawRepresentation on the McpServerToolCallContent, only on the McpServerToolResultContent, to avoid diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs index ce6516124cd..0555d72977d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -12,14 +12,14 @@ public class McpServerToolCallContentTests [Fact] public void Constructor_PropsDefault() { - McpServerToolCallContent c = new("callId1", "toolName", "serverName"); + McpServerToolCallContent c = new("callId1"); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); Assert.Equal("callId1", c.CallId); - Assert.Equal("toolName", c.ToolName); - Assert.Equal("serverName", c.ServerName); + Assert.Null(c.ToolName); + Assert.Null(c.ServerName); Assert.Null(c.Arguments); } @@ -27,7 +27,7 @@ public void Constructor_PropsDefault() [Fact] public void Constructor_PropsRoundtrip() { - McpServerToolCallContent c = new("callId1", "toolName", "serverName"); + McpServerToolCallContent c = new("callId1"); Assert.Null(c.RawRepresentation); object raw = new(); @@ -45,19 +45,20 @@ public void Constructor_PropsRoundtrip() Assert.Same(args, c.Arguments); Assert.Equal("callId1", c.CallId); + + Assert.Null(c.ToolName); + c.ToolName = "toolName"; Assert.Equal("toolName", c.ToolName); + + Assert.Null(c.ServerName); + c.ServerName = "serverName"; Assert.Equal("serverName", c.ServerName); } [Fact] public void Constructor_Throws() { - Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name", "serverName")); - Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty, "serverName")); - Assert.Throws("serverName", () => new McpServerToolCallContent("callId1", "name", string.Empty)); - - Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name", "serverName")); - Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!, "serverName")); - Assert.Throws("serverName", () => new McpServerToolCallContent("callId1", "name", null!)); + Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty)); + Assert.Throws("callId", () => new McpServerToolCallContent(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 708d771a9d9..72782979ddd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -17,28 +17,34 @@ public void Constructor_PropsDefault() Assert.Empty(tool.AdditionalProperties); Assert.Equal("serverName", tool.ServerName); - Assert.Equal("https://localhost/", tool.Url!.ToString()); - Assert.Null(tool.ConnectorID); + Assert.Equal("https://localhost/", tool.ServerAddress); Assert.Empty(tool.Description); + Assert.Null(tool.AuthorizationToken); + Assert.Null(tool.ServerDescription); Assert.Null(tool.AllowedTools); Assert.Null(tool.ApprovalMode); + Assert.Null(tool.Headers); } [Fact] public void Constructor_Roundtrips() { - HostedMcpServerTool tool = new("serverName", "https://localhost/"); + HostedMcpServerTool tool = new("serverName", "connector_id"); Assert.Empty(tool.AdditionalProperties); Assert.Empty(tool.Description); Assert.Equal(nameof(HostedMcpServerTool), tool.Name); Assert.Equal("serverName", tool.ServerName); - Assert.Equal("https://localhost/", tool.Url!.ToString()); - Assert.Null(tool.ConnectorID); + Assert.Equal("connector_id", tool.ServerAddress); Assert.Empty(tool.Description); + Assert.Null(tool.AuthorizationToken); + string authToken = "Bearer token123"; + tool.AuthorizationToken = authToken; + Assert.Equal(authToken, tool.AuthorizationToken); + Assert.Null(tool.ServerDescription); string serverDescription = "This is a test server"; tool.ServerDescription = serverDescription; @@ -69,70 +75,9 @@ public void Constructor_Roundtrips() [Fact] public void Constructor_Throws() { - Assert.Throws(() => new HostedMcpServerTool(string.Empty, new Uri("https://localhost/"))); - Assert.Throws(() => new HostedMcpServerTool(null!, new Uri("https://localhost/"))); - Assert.Throws(() => new HostedMcpServerTool("name", (Uri)null!)); - Assert.Throws(() => new HostedMcpServerTool("name", (string)null!)); - Assert.Throws(() => new HostedMcpServerTool("name", string.Empty)); - } - - [Fact] - public void CreateWithConnectorID_PropsDefault() - { - HostedMcpServerTool tool = HostedMcpServerTool.CreateWithConnectorID("serverName", "connector"); - - Assert.Empty(tool.AdditionalProperties); - - Assert.Equal("serverName", tool.ServerName); - Assert.Null(tool.Url); - Assert.Equal("connector", tool.ConnectorID); - - Assert.Empty(tool.Description); - Assert.Null(tool.AllowedTools); - Assert.Null(tool.ApprovalMode); - } - - [Fact] - public void CreateWithConnectorID_Roundtrips() - { - HostedMcpServerTool tool = HostedMcpServerTool.CreateWithConnectorID("serverName", "connector"); - - Assert.Empty(tool.AdditionalProperties); - Assert.Empty(tool.Description); - Assert.Equal(nameof(HostedMcpServerTool), tool.Name); - - Assert.Equal("serverName", tool.ServerName); - Assert.Null(tool.Url); - Assert.Equal("connector", tool.ConnectorID); - Assert.Empty(tool.Description); - - Assert.Null(tool.ServerDescription); - string serverDescription = "This is a test server"; - tool.ServerDescription = serverDescription; - Assert.Equal(serverDescription, tool.ServerDescription); - - Assert.Null(tool.AllowedTools); - List allowedTools = ["tool1", "tool2"]; - tool.AllowedTools = allowedTools; - Assert.Same(allowedTools, tool.AllowedTools); - - Assert.Null(tool.ApprovalMode); - tool.ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire; - Assert.Same(HostedMcpServerToolApprovalMode.NeverRequire, tool.ApprovalMode); - - Assert.Null(tool.Headers); - Dictionary headers = []; - tool.Headers = headers; - Assert.Same(headers, tool.Headers); - } - - [Fact] - public void CreateWithConnectorID_Throws() - { - Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID(string.Empty, "connector")); - Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID(null!, "connector")); - Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID("name", string.Empty)); - Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID("name", null!)); - Assert.Throws(() => HostedMcpServerTool.CreateWithConnectorID("name", " ")); + Assert.Throws(() => new HostedMcpServerTool(string.Empty, "https://localhost/")); + Assert.Throws(() => new HostedMcpServerTool(null!, "https://localhost/")); + Assert.Throws(() => new HostedMcpServerTool("name", string.Empty)); + Assert.Throws(() => new HostedMcpServerTool("name", null!)); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index c8bdc819ddb..d4211bd191c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -189,4 +189,63 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); } } + + [ConditionalFact] + public async Task RemoteMCP_Connector() + { + SkipIfNotEnabled(); + + if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string accessToken) + { + throw new SkipTestException( + "To run this test, set a value for RemoteMCP:ConnectorAccessToken. " + + "You can obtain one by following https://platform.openai.com/docs/guides/tools-connectors-mcp?quickstart-panels=connector#authorizing-a-connector."); + } + + await RunAsync(false, false); + await RunAsync(true, true); + + async Task RunAsync(bool streaming, bool approval) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("calendar", "connector_googlecalendar") + { + ApprovalMode = approval ? + HostedMcpServerToolApprovalMode.AlwaysRequire : + HostedMcpServerToolApprovalMode.NeverRequire, + AuthorizationToken = accessToken + } + ], + }; + + using var client = CreateChatClient()!; + + List input = [new ChatMessage(ChatRole.User, "What is on my calendar for today?")]; + + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + + if (approval) + { + input.AddRange(response.Messages); + var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("search_events", approvalRequest.ToolCall.ToolName); + input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)])); + + response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + } + + Assert.NotNull(response); + var toolCall = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("search_events", toolCall.ToolName); + + var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + var content = Assert.IsType(Assert.Single(toolResult.Output!)); + Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text); + } + } } From 4a0394d622829391da01d7fef3a70f6f48e03d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Wed, 8 Oct 2025 16:05:06 -0500 Subject: [PATCH 3/8] Create MCP approval responses also with user chat role --- .../OpenAIResponsesChatClient.cs | 9 + .../OpenAIResponseClientTests.cs | 272 ++++++++++++++++++ 2 files changed, 281 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 76662f5df36..730cfbf7a8c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -603,6 +603,15 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 193, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 23, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 216 + }, + "user": null, + "metadata": {} + } + """; + + var chatOptions = new ChatOptions + { + Tools = [new HostedMcpServerTool("deepwiki", "https://mcp.deepwiki.com/mcp")] + }; + McpServerToolApprovalRequestContent approvalRequest; + + using (VerbatimHttpHandler handler = new(input, output)) + using (HttpClient httpClient = new(handler)) + using (IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini")) + { + var response = await client.GetResponseAsync( + "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", + chatOptions); + + approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + chatOptions.ConversationId = response.ConversationId; + } + + input = $$""" + { + "previous_response_id": "resp_04e29d5bdd80bd9f0068e6b01f786081a29148febb92892aee", + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp" + } + ], + "tool_choice": "auto", + "input": [ + {{(role == "user" ? @"{""type"": ""message"",""role"": ""user"",""content"": [{""type"": ""input_text"",""text"": """"}]}," : string.Empty)}} + { + "type": "mcp_approval_response", + "approval_request_id": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", + "approve": true + } + ] + } + """; + + output = """ + { + "id": "resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", + "object": "response", + "created_at": 1759949340, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", + "type": "mcp_call", + "status": "completed", + "approval_request_id": "mcpr_06ee3b1962eeb8470068e6b192985c81a383a16059ecd8230e", + "arguments": "{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}", + "error": null, + "name": "ask_question", + "output": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` within the `dotnet/extensions` repository. This file provides an overview of the package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`. \n\n## Path to README.md\n\nThe specific path to the `README.md` file for the `Microsoft.Extensions.AI.Abstractions` project is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md`. This path is also referenced in the `AI Extensions Framework` wiki page as a relevant source file. \n\n## Notes\n\nThe `Packaging.targets` file in the `eng/MSBuild` directory indicates that `README.md` files are included in packages when `IsPackable` and `IsShipping` properties are true. This suggests that the `README.md` file located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` is intended to be part of the distributed NuGet package for `Microsoft.Extensions.AI.Abstractions`. \n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_315595bd-9b39-4f04-9fa3-42dc778fa9f3\n", + "server_label": "deepwiki" + }, + { + "id": "msg_06ee3b1962eeb8470068e6b226ab0081a39fccce9aa47aedbc", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": "resp_06ee3b1962eeb8470068e6b18e0db881a3bdfd255a60327cdc", + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "always", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 542, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 72, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 614 + }, + "user": null, + "metadata": {} + } + """; + + using (VerbatimHttpHandler handler = new(input, output)) + using (HttpClient httpClient = new(handler)) + using (IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini")) + { + var response = await client.GetResponseAsync( + new ChatMessage(new ChatRole(role), [approvalRequest.CreateResponse(true)]), chatOptions); + + Assert.NotNull(response); + + Assert.Equal("resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", response.ResponseId); + Assert.Equal("resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_759_949_340), response.CreatedAt); + Assert.Null(response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`.", response.Messages[0].Text); + + Assert.Equal(3, message.Contents.Count); + + var call = Assert.IsType(message.Contents[0]); + Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", call.CallId); + Assert.Equal("deepwiki", call.ServerName); + Assert.Equal("ask_question", call.ToolName); + Assert.NotNull(call.Arguments); + Assert.Equal(2, call.Arguments.Count); + Assert.Equal("dotnet/extensions", ((JsonElement)call.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)call.Arguments["question"]!).GetString()); + + var result = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", result.CallId); + Assert.NotNull(result.Output); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(result.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(542, response.Usage.InputTokenCount); + Assert.Equal(72, response.Usage.OutputTokenCount); + Assert.Equal(614, response.Usage.TotalTokenCount); + } + } + [Theory] [InlineData(false)] [InlineData(true)] From 1e27073a292a952264436721dc028cbdfd2eac1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Mon, 13 Oct 2025 10:34:32 -0500 Subject: [PATCH 4/8] Remove HostedMcpServerTool.Headers --- .../Tools/HostedMcpServerTool.cs | 8 -------- .../OpenAIResponsesChatClient.cs | 6 ++---- .../Tools/HostedMcpServerToolTests.cs | 6 ------ 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index f376f69ac8d..b94ca73e2a7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -71,12 +71,4 @@ public HostedMcpServerTool(string serverName, string serverAddress) /// /// public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; } - - /// - /// Gets or sets the HTTP headers that the AI service should use when calling the remote MCP server. - /// - /// - /// This property is useful for specifying headers required by the MCP server. - /// - public IDictionary? Headers { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 730cfbf7a8c..51184bf1ce3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -476,14 +476,12 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt mcpTool.ServerName, url, mcpTool.AuthorizationToken, - mcpTool.ServerDescription, - headers: mcpTool.Headers) : + mcpTool.ServerDescription) : ResponseTool.CreateMcpTool( mcpTool.ServerName, new McpToolConnectorId(mcpTool.ServerAddress), mcpTool.AuthorizationToken, - mcpTool.ServerDescription, - headers: mcpTool.Headers); + mcpTool.ServerDescription); 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 72782979ddd..94b2a5f5395 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -24,7 +24,6 @@ public void Constructor_PropsDefault() Assert.Null(tool.ServerDescription); Assert.Null(tool.AllowedTools); Assert.Null(tool.ApprovalMode); - Assert.Null(tool.Headers); } [Fact] @@ -65,11 +64,6 @@ public void Constructor_Roundtrips() var customApprovalMode = new HostedMcpServerToolRequireSpecificApprovalMode(["tool1"], ["tool2"]); tool.ApprovalMode = customApprovalMode; Assert.Same(customApprovalMode, tool.ApprovalMode); - - Assert.Null(tool.Headers); - Dictionary headers = []; - tool.Headers = headers; - Assert.Same(headers, tool.Headers); } [Fact] From 4e294635ba98973c11195091116dcbad42bef116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Mon, 13 Oct 2025 11:25:42 -0500 Subject: [PATCH 5/8] Don't return empty content if user message only contains mcp approval response --- .../OpenAIResponsesChatClient.cs | 95 +++++++++---------- .../OpenAIResponseClientTests.cs | 1 - 2 files changed, 45 insertions(+), 51 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 51184bf1ce3..ceb875d8d83 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -600,16 +600,57 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable parts = []; foreach (AIContent item in input.Contents) { - if (item is McpServerToolApprovalResponseContent mcpApprovalResponseContent) + switch (item) { - yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); + case AIContent when item.RawRepresentation is ResponseContentPart rawRep: + parts.Add(rawRep); + break; + + case TextContent textContent: + parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): + parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); + break; + + case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): + parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf")); + break; + + case HostedFileContent fileContent: + parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId)); + break; + + case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): + parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); + break; + + case McpServerToolApprovalResponseContent mcpApprovalResponseContent: + handleEmptyMessage = false; + yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); + break; } } + if (parts.Count == 0 && handleEmptyMessage) + { + parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); + } + + if (parts.Count > 0) + { + yield return ResponseItem.CreateUserMessageItem(parts); + } + continue; } @@ -835,52 +876,6 @@ private static void PopulateAnnotations(ResponseContentPart source, AIContent de } } - /// Convert a list of s to a list of . - private static List ToResponseContentParts(IList contents) - { - List parts = []; - foreach (var content in contents) - { - switch (content) - { - case AIContent when content.RawRepresentation is ResponseContentPart rawRep: - parts.Add(rawRep); - break; - - case TextContent textContent: - parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); - break; - - case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); - break; - - case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); - break; - - case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf")); - break; - - case HostedFileContent fileContent: - parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId)); - break; - - case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): - parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); - break; - } - } - - if (parts.Count == 0) - { - parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); - } - - return parts; - } - /// Adds new for the specified into . private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index ce051939f9c..2575a751c31 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -957,7 +957,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) ], "tool_choice": "auto", "input": [ - {{(role == "user" ? @"{""type"": ""message"",""role"": ""user"",""content"": [{""type"": ""input_text"",""text"": """"}]}," : string.Empty)}} { "type": "mcp_approval_response", "approval_request_id": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", From eb8df027b63ecdcd2cdec7e3c8f64db258095d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Mon, 13 Oct 2025 11:30:12 -0500 Subject: [PATCH 6/8] Update serverAddress documentation --- .../Tools/HostedMcpServerTool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index b94ca73e2a7..666d44b03a4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -18,7 +18,7 @@ public class HostedMcpServerTool : AITool /// Initializes a new instance of the class. /// /// The name of the remote MCP server. - /// The address of the remote MCP server. + /// The address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. /// or is . /// or is empty or composed entirely of whitespace. public HostedMcpServerTool(string serverName, string serverAddress) From 2a54c0370732f73e97cff5a237e68a84a8b582a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Mon, 13 Oct 2025 12:28:13 -0500 Subject: [PATCH 7/8] Only ServerName should be optional --- .../Contents/McpServerToolCallContent.cs | 12 +++++---- .../OpenAIResponsesChatClient.cs | 9 +++---- .../Contents/McpServerToolCallContentTests.cs | 26 +++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs index 9042793f9cb..e63c72df77a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -22,11 +22,13 @@ public sealed class McpServerToolCallContent : AIContent /// Initializes a new instance of the class. /// /// The tool call ID. - /// is . - /// is empty or composed entirely of whitespace. - public McpServerToolCallContent(string callId) + /// The tool name. + /// or is . + /// or is empty or composed entirely of whitespace. + public McpServerToolCallContent(string callId, string toolName) { CallId = Throw.IfNullOrWhitespace(callId); + ToolName = Throw.IfNullOrWhitespace(toolName); } /// @@ -35,9 +37,9 @@ public McpServerToolCallContent(string callId) public string CallId { get; } /// - /// Gets or sets the name of the tool called. + /// Gets the name of the tool called. /// - public string? ToolName { get; set; } + public string ToolName { get; } /// /// Gets or sets the name of the MCP server. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index ceb875d8d83..23cf2d83049 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -181,9 +181,8 @@ internal static IEnumerable ToChatMessages(IEnumerable break; case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallApprovalRequestItem mtcari: - yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id) + yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName) { - ToolName = mtcari.ToolName, ServerName = mtcari.ServerLabel, Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, RawRepresentation = mtcari, @@ -879,9 +877,8 @@ private static void PopulateAnnotations(ResponseContentPart source, AIContent de /// Adds new for the specified into . private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) { - contents.Add(new McpServerToolCallContent(mtci.Id) + contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName) { - ToolName = mtci.ToolName, ServerName = mtci.ServerLabel, Arguments = JsonSerializer.Deserialize(mtci.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs index 0555d72977d..f72d7d0ebf2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -12,22 +12,22 @@ public class McpServerToolCallContentTests [Fact] public void Constructor_PropsDefault() { - McpServerToolCallContent c = new("callId1"); + McpServerToolCallContent c = new("callId1", "toolName"); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); Assert.Equal("callId1", c.CallId); - Assert.Null(c.ToolName); - Assert.Null(c.ServerName); + Assert.Equal("toolName", c.ToolName); + Assert.Null(c.ServerName); Assert.Null(c.Arguments); } [Fact] public void Constructor_PropsRoundtrip() { - McpServerToolCallContent c = new("callId1"); + McpServerToolCallContent c = new("callId1", "toolName"); Assert.Null(c.RawRepresentation); object raw = new(); @@ -44,21 +44,21 @@ public void Constructor_PropsRoundtrip() c.Arguments = args; Assert.Same(args, c.Arguments); - Assert.Equal("callId1", c.CallId); + Assert.Null(c.ServerName); + c.ServerName = "testServer"; + Assert.Equal("testServer", c.ServerName); - Assert.Null(c.ToolName); - c.ToolName = "toolName"; + Assert.Equal("callId1", c.CallId); Assert.Equal("toolName", c.ToolName); - - Assert.Null(c.ServerName); - c.ServerName = "serverName"; - Assert.Equal("serverName", c.ServerName); } [Fact] public void Constructor_Throws() { - Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty)); - Assert.Throws("callId", () => new McpServerToolCallContent(null!)); + Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name")); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty)); + + Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name")); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!)); } } From 6204c412d612883eca0220b9c9cb92f4cb0ee8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Cant=C3=BA?= Date: Mon, 13 Oct 2025 15:35:50 -0500 Subject: [PATCH 8/8] Make mcp tool call ServerName ctor arg but nullable and augment mcptool ServerAddress property summary to match the ctor argument. --- .../Contents/McpServerToolCallContent.cs | 8 +++++--- .../Tools/HostedMcpServerTool.cs | 2 +- .../OpenAIResponsesChatClient.cs | 9 +++------ .../Contents/AIContentTests.cs | 4 ++-- .../Contents/McpServerToolCallContentTests.cs | 18 +++++++----------- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs index e63c72df77a..3283c09a7ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -23,12 +23,14 @@ public sealed class McpServerToolCallContent : AIContent /// /// The tool call ID. /// The tool name. + /// The MCP server name that hosts the tool. /// or is . /// or is empty or composed entirely of whitespace. - public McpServerToolCallContent(string callId, string toolName) + public McpServerToolCallContent(string callId, string toolName, string? serverName) { CallId = Throw.IfNullOrWhitespace(callId); ToolName = Throw.IfNullOrWhitespace(toolName); + ServerName = serverName; } /// @@ -42,9 +44,9 @@ public McpServerToolCallContent(string callId, string toolName) public string ToolName { get; } /// - /// Gets or sets the name of the MCP server. + /// Gets the name of the MCP server that hosts the tool. /// - public string? ServerName { get; set; } + public string? ServerName { get; } /// /// Gets or sets the arguments used for the tool call. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index e4a024cb12d..7bf7c5ae731 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -36,7 +36,7 @@ public HostedMcpServerTool(string serverName, string serverAddress) public string ServerName { get; } /// - /// Gets the address of the remote MCP server. + /// Gets the address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. /// public string ServerAddress { get; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 709df0e25c4..cd7f1e46971 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -191,9 +191,8 @@ internal static IEnumerable ToChatMessages(IEnumerable break; case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallApprovalRequestItem mtcari: - yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName) + yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) { - ServerName = mtcari.ServerLabel, Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, RawRepresentation = mtcari, }) @@ -944,9 +942,8 @@ private static void PopulateAnnotations(ResponseContentPart source, AIContent de /// Adds new for the specified into . private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) { - contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName) + contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName, mtci.ServerLabel) { - ServerName = mtci.ServerLabel, Arguments = JsonSerializer.Deserialize(mtci.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, // We purposefully do not set the RawRepresentation on the McpServerToolCallContent, only on the McpServerToolResultContent, to avoid diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs index ae33e156e6d..e5734ccd7cf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs @@ -72,9 +72,9 @@ public void Serialization_DerivedTypes_Roundtrips() new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }), new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), new FunctionApprovalResponseContent("request123", approved: true, new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), - new McpServerToolCallContent("call123", "myTool"), + new McpServerToolCallContent("call123", "myTool", "myServer"), new McpServerToolResultContent("call123"), - new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool")), + new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), new McpServerToolApprovalResponseContent("request123", approved: true) ]); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs index f72d7d0ebf2..d5c5b43ed0a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -12,14 +12,13 @@ public class McpServerToolCallContentTests [Fact] public void Constructor_PropsDefault() { - McpServerToolCallContent c = new("callId1", "toolName"); + McpServerToolCallContent c = new("callId1", "toolName", null); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); Assert.Equal("callId1", c.CallId); Assert.Equal("toolName", c.ToolName); - Assert.Null(c.ServerName); Assert.Null(c.Arguments); } @@ -27,7 +26,7 @@ public void Constructor_PropsDefault() [Fact] public void Constructor_PropsRoundtrip() { - McpServerToolCallContent c = new("callId1", "toolName"); + McpServerToolCallContent c = new("callId1", "toolName", "serverName"); Assert.Null(c.RawRepresentation); object raw = new(); @@ -44,21 +43,18 @@ public void Constructor_PropsRoundtrip() c.Arguments = args; Assert.Same(args, c.Arguments); - Assert.Null(c.ServerName); - c.ServerName = "testServer"; - Assert.Equal("testServer", c.ServerName); - Assert.Equal("callId1", c.CallId); Assert.Equal("toolName", c.ToolName); + Assert.Equal("serverName", c.ServerName); } [Fact] public void Constructor_Throws() { - Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name")); - Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty)); + Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null)); - Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name")); - Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!)); + Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!, null)); } }