diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs index ec7f993a25f..3283c09a7ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -23,14 +23,14 @@ public sealed class McpServerToolCallContent : AIContent /// /// The tool call ID. /// The tool name. - /// The MCP server name. - /// , , or is . - /// , , or are empty or composed entirely of whitespace. - public McpServerToolCallContent(string callId, string toolName, string serverName) + /// The MCP server name that hosts the tool. + /// or is . + /// or is empty or composed entirely of whitespace. + public McpServerToolCallContent(string callId, string toolName, string? serverName) { CallId = Throw.IfNullOrWhitespace(callId); ToolName = Throw.IfNullOrWhitespace(toolName); - ServerName = Throw.IfNullOrWhitespace(serverName); + ServerName = serverName; } /// @@ -44,9 +44,9 @@ public McpServerToolCallContent(string callId, string toolName, string serverNam public string ToolName { get; } /// - /// Gets the name of the MCP server. + /// Gets the name of the MCP server that hosts the tool. /// - public string ServerName { get; } + 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 d55ffb3788c..7bf7c5ae731 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -18,25 +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 is . - /// 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 is . - /// is empty or composed entirely of whitespace. - public HostedMcpServerTool(string serverName, Uri url) + /// 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) { ServerName = Throw.IfNullOrWhitespace(serverName); - Url = Throw.IfNull(url); + ServerAddress = Throw.IfNullOrWhitespace(serverAddress); } /// @@ -48,9 +36,14 @@ public HostedMcpServerTool(string serverName, Uri url) public string ServerName { get; } /// - /// Gets the URL 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 Uri Url { get; } + public string ServerAddress { get; } + + /// + /// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server. + /// + public string? AuthorizationToken { get; set; } /// /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. @@ -81,12 +74,4 @@ public HostedMcpServerTool(string serverName, Uri url) /// /// 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 the authentication header or other 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 b1e24461f84..cd7f1e46971 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -534,11 +534,17 @@ 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 = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + url, + mcpTool.AuthorizationToken, + mcpTool.ServerDescription) : + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + new McpToolConnectorId(mcpTool.ServerAddress), + mcpTool.AuthorizationToken, + mcpTool.ServerDescription); if (mcpTool.AllowedTools is not null) { @@ -657,7 +663,57 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable parts = []; + foreach (AIContent item in input.Contents) + { + switch (item) + { + 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; } @@ -883,52 +939,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.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs index ce6516124cd..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,15 +12,14 @@ public class McpServerToolCallContentTests [Fact] public void Constructor_PropsDefault() { - McpServerToolCallContent c = new("callId1", "toolName", "serverName"); + 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.Equal("serverName", c.ServerName); - + Assert.Null(c.ServerName); Assert.Null(c.Arguments); } @@ -52,12 +51,10 @@ public void Constructor_PropsRoundtrip() [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(string.Empty, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null)); - 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(null!, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!, 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 6ab073e1dda..fe826a26820 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -17,9 +17,11 @@ 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.ServerAddress); Assert.Empty(tool.Description); + Assert.Null(tool.AuthorizationToken); + Assert.Null(tool.ServerDescription); Assert.Null(tool.AllowedTools); Assert.Null(tool.ApprovalMode); } @@ -27,7 +29,7 @@ public void Constructor_PropsDefault() [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); @@ -35,9 +37,14 @@ public void Constructor_Roundtrips() Assert.Equal(tool.Name, tool.ToString()); Assert.Equal("serverName", tool.ServerName); - Assert.Equal("https://localhost/", tool.Url.ToString()); + 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; @@ -58,20 +65,14 @@ 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] 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)); + 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 35d72e09436..07e0cd94201 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -338,4 +338,63 @@ public async Task GetStreamingResponseAsync_BackgroundResponses_WithFunction() Assert.Contains("5:43", responseText); Assert.Equal(1, callCount); } + + [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); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index c4c6f6b767d..fce0bab3ee5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -826,6 +826,277 @@ public async Task MultipleOutputItems_NonStreaming() Assert.Equal(36, response.Usage.TotalTokenCount); } + [Theory] + [InlineData("user")] + [InlineData("tool")] + public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) + { + string input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ] + } + """; + + string output = """ + { + "id": "resp_04e29d5bdd80bd9f0068e6b01f786081a29148febb92892aee", + "object": "response", + "created_at": 1759948831, + "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": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", + "type": "mcp_approval_request", + "arguments": "{\"repoName\":\"dotnet/extensions\"}", + "name": "ask_question", + "server_label": "deepwiki" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "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": 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": [ + { + "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)]