Skip to content

Commit de0f507

Browse files
authored
Add support for Connectors, Add AuthorizationToken, Enable MCP approvals in User messages, Make MCP tool call ServerName nullable and remove Headers (#6881)
Extended description a.k.a. timeline of the PR: * Add support for Connector ID * 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 * Create MCP approval responses also with user chat role * Remove HostedMcpServerTool.Headers * Don't return empty content if user message only contains mcp approval response * Update serverAddress documentation * Only ServerName should be optional * Make mcp tool call ServerName ctor arg but nullable and augment mcptool ServerAddress property summary to match the ctor argument.
1 parent 4e71161 commit de0f507

File tree

7 files changed

+431
-108
lines changed

7 files changed

+431
-108
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ public sealed class McpServerToolCallContent : AIContent
2323
/// </summary>
2424
/// <param name="callId">The tool call ID.</param>
2525
/// <param name="toolName">The tool name.</param>
26-
/// <param name="serverName">The MCP server name.</param>
27-
/// <exception cref="ArgumentNullException"><paramref name="callId"/>, <paramref name="toolName"/>, or <paramref name="serverName"/> is <see langword="null"/>.</exception>
28-
/// <exception cref="ArgumentException"><paramref name="callId"/>, <paramref name="toolName"/>, or <paramref name="serverName"/> are empty or composed entirely of whitespace.</exception>
29-
public McpServerToolCallContent(string callId, string toolName, string serverName)
26+
/// <param name="serverName">The MCP server name that hosts the tool.</param>
27+
/// <exception cref="ArgumentNullException"><paramref name="callId"/> or <paramref name="toolName"/> is <see langword="null"/>.</exception>
28+
/// <exception cref="ArgumentException"><paramref name="callId"/> or <paramref name="toolName"/> is empty or composed entirely of whitespace.</exception>
29+
public McpServerToolCallContent(string callId, string toolName, string? serverName)
3030
{
3131
CallId = Throw.IfNullOrWhitespace(callId);
3232
ToolName = Throw.IfNullOrWhitespace(toolName);
33-
ServerName = Throw.IfNullOrWhitespace(serverName);
33+
ServerName = serverName;
3434
}
3535

3636
/// <summary>
@@ -44,9 +44,9 @@ public McpServerToolCallContent(string callId, string toolName, string serverNam
4444
public string ToolName { get; }
4545

4646
/// <summary>
47-
/// Gets the name of the MCP server.
47+
/// Gets the name of the MCP server that hosts the tool.
4848
/// </summary>
49-
public string ServerName { get; }
49+
public string? ServerName { get; }
5050

5151
/// <summary>
5252
/// Gets or sets the arguments used for the tool call.

src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,13 @@ public class HostedMcpServerTool : AITool
1818
/// Initializes a new instance of the <see cref="HostedMcpServerTool"/> class.
1919
/// </summary>
2020
/// <param name="serverName">The name of the remote MCP server.</param>
21-
/// <param name="url">The URL of the remote MCP server.</param>
22-
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> or <paramref name="url"/> is <see langword="null"/>.</exception>
23-
/// <exception cref="ArgumentException"><paramref name="serverName"/> is empty or composed entirely of whitespace.</exception>
24-
public HostedMcpServerTool(string serverName, [StringSyntax(StringSyntaxAttribute.Uri)] string url)
25-
: this(serverName, new Uri(Throw.IfNull(url)))
26-
{
27-
}
28-
29-
/// <summary>
30-
/// Initializes a new instance of the <see cref="HostedMcpServerTool"/> class.
31-
/// </summary>
32-
/// <param name="serverName">The name of the remote MCP server.</param>
33-
/// <param name="url">The URL of the remote MCP server.</param>
34-
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> or <paramref name="url"/> is <see langword="null"/>.</exception>
35-
/// <exception cref="ArgumentException"><paramref name="serverName"/> is empty or composed entirely of whitespace.</exception>
36-
public HostedMcpServerTool(string serverName, Uri url)
21+
/// <param name="serverAddress">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.</param>
22+
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> or <paramref name="serverAddress"/> is <see langword="null"/>.</exception>
23+
/// <exception cref="ArgumentException"><paramref name="serverName"/> or <paramref name="serverAddress"/> is empty or composed entirely of whitespace.</exception>
24+
public HostedMcpServerTool(string serverName, string serverAddress)
3725
{
3826
ServerName = Throw.IfNullOrWhitespace(serverName);
39-
Url = Throw.IfNull(url);
27+
ServerAddress = Throw.IfNullOrWhitespace(serverAddress);
4028
}
4129

4230
/// <inheritdoc />
@@ -48,9 +36,14 @@ public HostedMcpServerTool(string serverName, Uri url)
4836
public string ServerName { get; }
4937

5038
/// <summary>
51-
/// Gets the URL of the remote MCP server.
39+
/// 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.
5240
/// </summary>
53-
public Uri Url { get; }
41+
public string ServerAddress { get; }
42+
43+
/// <summary>
44+
/// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server.
45+
/// </summary>
46+
public string? AuthorizationToken { get; set; }
5447

5548
/// <summary>
5649
/// 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)
8174
/// </para>
8275
/// </remarks>
8376
public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; }
84-
85-
/// <summary>
86-
/// Gets or sets the HTTP headers that the AI service should use when calling the remote MCP server.
87-
/// </summary>
88-
/// <remarks>
89-
/// This property is useful for specifying the authentication header or other headers required by the MCP server.
90-
/// </remarks>
91-
public IDictionary<string, string>? Headers { get; set; }
9277
}

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -534,11 +534,17 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
534534
break;
535535

536536
case HostedMcpServerTool mcpTool:
537-
McpTool responsesMcpTool = ResponseTool.CreateMcpTool(
538-
mcpTool.ServerName,
539-
mcpTool.Url,
540-
serverDescription: mcpTool.ServerDescription,
541-
headers: mcpTool.Headers);
537+
McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ?
538+
ResponseTool.CreateMcpTool(
539+
mcpTool.ServerName,
540+
url,
541+
mcpTool.AuthorizationToken,
542+
mcpTool.ServerDescription) :
543+
ResponseTool.CreateMcpTool(
544+
mcpTool.ServerName,
545+
new McpToolConnectorId(mcpTool.ServerAddress),
546+
mcpTool.AuthorizationToken,
547+
mcpTool.ServerDescription);
542548

543549
if (mcpTool.AllowedTools is not null)
544550
{
@@ -657,7 +663,57 @@ internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<Chat
657663

658664
if (input.Role == ChatRole.User)
659665
{
660-
yield return ResponseItem.CreateUserMessageItem(ToResponseContentParts(input.Contents));
666+
bool handleEmptyMessage = true; // MCP approval responses (and future cases) yield an item rather than adding a part and we don't want to return an empty user message in that case.
667+
List<ResponseContentPart> parts = [];
668+
foreach (AIContent item in input.Contents)
669+
{
670+
switch (item)
671+
{
672+
case AIContent when item.RawRepresentation is ResponseContentPart rawRep:
673+
parts.Add(rawRep);
674+
break;
675+
676+
case TextContent textContent:
677+
parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text));
678+
break;
679+
680+
case UriContent uriContent when uriContent.HasTopLevelMediaType("image"):
681+
parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri));
682+
break;
683+
684+
case DataContent dataContent when dataContent.HasTopLevelMediaType("image"):
685+
parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType));
686+
break;
687+
688+
case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase):
689+
parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"));
690+
break;
691+
692+
case HostedFileContent fileContent:
693+
parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId));
694+
break;
695+
696+
case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal):
697+
parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message));
698+
break;
699+
700+
case McpServerToolApprovalResponseContent mcpApprovalResponseContent:
701+
handleEmptyMessage = false;
702+
yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved);
703+
break;
704+
}
705+
}
706+
707+
if (parts.Count == 0 && handleEmptyMessage)
708+
{
709+
parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty));
710+
}
711+
712+
if (parts.Count > 0)
713+
{
714+
yield return ResponseItem.CreateUserMessageItem(parts);
715+
}
716+
661717
continue;
662718
}
663719

@@ -883,52 +939,6 @@ private static void PopulateAnnotations(ResponseContentPart source, AIContent de
883939
}
884940
}
885941

886-
/// <summary>Convert a list of <see cref="AIContent"/>s to a list of <see cref="ResponseContentPart"/>.</summary>
887-
private static List<ResponseContentPart> ToResponseContentParts(IList<AIContent> contents)
888-
{
889-
List<ResponseContentPart> parts = [];
890-
foreach (var content in contents)
891-
{
892-
switch (content)
893-
{
894-
case AIContent when content.RawRepresentation is ResponseContentPart rawRep:
895-
parts.Add(rawRep);
896-
break;
897-
898-
case TextContent textContent:
899-
parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text));
900-
break;
901-
902-
case UriContent uriContent when uriContent.HasTopLevelMediaType("image"):
903-
parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri));
904-
break;
905-
906-
case DataContent dataContent when dataContent.HasTopLevelMediaType("image"):
907-
parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType));
908-
break;
909-
910-
case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase):
911-
parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"));
912-
break;
913-
914-
case HostedFileContent fileContent:
915-
parts.Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId));
916-
break;
917-
918-
case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal):
919-
parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message));
920-
break;
921-
}
922-
}
923-
924-
if (parts.Count == 0)
925-
{
926-
parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty));
927-
}
928-
929-
return parts;
930-
}
931-
932942
/// <summary>Adds new <see cref="AIContent"/> for the specified <paramref name="mtci"/> into <paramref name="contents"/>.</summary>
933943
private static void AddMcpToolCallContent(McpToolCallItem mtci, IList<AIContent> contents)
934944
{

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ public class McpServerToolCallContentTests
1212
[Fact]
1313
public void Constructor_PropsDefault()
1414
{
15-
McpServerToolCallContent c = new("callId1", "toolName", "serverName");
15+
McpServerToolCallContent c = new("callId1", "toolName", null);
1616

1717
Assert.Null(c.RawRepresentation);
1818
Assert.Null(c.AdditionalProperties);
1919

2020
Assert.Equal("callId1", c.CallId);
2121
Assert.Equal("toolName", c.ToolName);
22-
Assert.Equal("serverName", c.ServerName);
23-
22+
Assert.Null(c.ServerName);
2423
Assert.Null(c.Arguments);
2524
}
2625

@@ -52,12 +51,10 @@ public void Constructor_PropsRoundtrip()
5251
[Fact]
5352
public void Constructor_Throws()
5453
{
55-
Assert.Throws<ArgumentException>("callId", () => new McpServerToolCallContent(string.Empty, "name", "serverName"));
56-
Assert.Throws<ArgumentException>("toolName", () => new McpServerToolCallContent("callId1", string.Empty, "serverName"));
57-
Assert.Throws<ArgumentException>("serverName", () => new McpServerToolCallContent("callId1", "name", string.Empty));
54+
Assert.Throws<ArgumentException>("callId", () => new McpServerToolCallContent(string.Empty, "name", null));
55+
Assert.Throws<ArgumentException>("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null));
5856

59-
Assert.Throws<ArgumentNullException>("callId", () => new McpServerToolCallContent(null!, "name", "serverName"));
60-
Assert.Throws<ArgumentNullException>("toolName", () => new McpServerToolCallContent("callId1", null!, "serverName"));
61-
Assert.Throws<ArgumentNullException>("serverName", () => new McpServerToolCallContent("callId1", "name", null!));
57+
Assert.Throws<ArgumentNullException>("callId", () => new McpServerToolCallContent(null!, "name", null));
58+
Assert.Throws<ArgumentNullException>("toolName", () => new McpServerToolCallContent("callId1", null!, null));
6259
}
6360
}

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,34 @@ public void Constructor_PropsDefault()
1717
Assert.Empty(tool.AdditionalProperties);
1818

1919
Assert.Equal("serverName", tool.ServerName);
20-
Assert.Equal("https://localhost/", tool.Url.ToString());
20+
Assert.Equal("https://localhost/", tool.ServerAddress);
2121

2222
Assert.Empty(tool.Description);
23+
Assert.Null(tool.AuthorizationToken);
24+
Assert.Null(tool.ServerDescription);
2325
Assert.Null(tool.AllowedTools);
2426
Assert.Null(tool.ApprovalMode);
2527
}
2628

2729
[Fact]
2830
public void Constructor_Roundtrips()
2931
{
30-
HostedMcpServerTool tool = new("serverName", "https://localhost/");
32+
HostedMcpServerTool tool = new("serverName", "connector_id");
3133

3234
Assert.Empty(tool.AdditionalProperties);
3335
Assert.Empty(tool.Description);
3436
Assert.Equal("mcp", tool.Name);
3537
Assert.Equal(tool.Name, tool.ToString());
3638

3739
Assert.Equal("serverName", tool.ServerName);
38-
Assert.Equal("https://localhost/", tool.Url.ToString());
40+
Assert.Equal("connector_id", tool.ServerAddress);
3941
Assert.Empty(tool.Description);
4042

43+
Assert.Null(tool.AuthorizationToken);
44+
string authToken = "Bearer token123";
45+
tool.AuthorizationToken = authToken;
46+
Assert.Equal(authToken, tool.AuthorizationToken);
47+
4148
Assert.Null(tool.ServerDescription);
4249
string serverDescription = "This is a test server";
4350
tool.ServerDescription = serverDescription;
@@ -58,20 +65,14 @@ public void Constructor_Roundtrips()
5865
var customApprovalMode = new HostedMcpServerToolRequireSpecificApprovalMode(["tool1"], ["tool2"]);
5966
tool.ApprovalMode = customApprovalMode;
6067
Assert.Same(customApprovalMode, tool.ApprovalMode);
61-
62-
Assert.Null(tool.Headers);
63-
Dictionary<string, string> headers = [];
64-
tool.Headers = headers;
65-
Assert.Same(headers, tool.Headers);
6668
}
6769

6870
[Fact]
6971
public void Constructor_Throws()
7072
{
71-
Assert.Throws<ArgumentException>(() => new HostedMcpServerTool(string.Empty, new Uri("https://localhost/")));
72-
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool(null!, new Uri("https://localhost/")));
73-
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool("name", (Uri)null!));
74-
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool("name", (string)null!));
75-
Assert.Throws<UriFormatException>(() => new HostedMcpServerTool("name", string.Empty));
73+
Assert.Throws<ArgumentException>(() => new HostedMcpServerTool(string.Empty, "https://localhost/"));
74+
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool(null!, "https://localhost/"));
75+
Assert.Throws<ArgumentException>(() => new HostedMcpServerTool("name", string.Empty));
76+
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool("name", null!));
7677
}
7778
}

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,63 @@ public async Task GetStreamingResponseAsync_BackgroundResponses_WithFunction()
338338
Assert.Contains("5:43", responseText);
339339
Assert.Equal(1, callCount);
340340
}
341+
342+
[ConditionalFact]
343+
public async Task RemoteMCP_Connector()
344+
{
345+
SkipIfNotEnabled();
346+
347+
if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string accessToken)
348+
{
349+
throw new SkipTestException(
350+
"To run this test, set a value for RemoteMCP:ConnectorAccessToken. " +
351+
"You can obtain one by following https://platform.openai.com/docs/guides/tools-connectors-mcp?quickstart-panels=connector#authorizing-a-connector.");
352+
}
353+
354+
await RunAsync(false, false);
355+
await RunAsync(true, true);
356+
357+
async Task RunAsync(bool streaming, bool approval)
358+
{
359+
ChatOptions chatOptions = new()
360+
{
361+
Tools = [new HostedMcpServerTool("calendar", "connector_googlecalendar")
362+
{
363+
ApprovalMode = approval ?
364+
HostedMcpServerToolApprovalMode.AlwaysRequire :
365+
HostedMcpServerToolApprovalMode.NeverRequire,
366+
AuthorizationToken = accessToken
367+
}
368+
],
369+
};
370+
371+
using var client = CreateChatClient()!;
372+
373+
List<ChatMessage> input = [new ChatMessage(ChatRole.User, "What is on my calendar for today?")];
374+
375+
ChatResponse response = streaming ?
376+
await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() :
377+
await client.GetResponseAsync(input, chatOptions);
378+
379+
if (approval)
380+
{
381+
input.AddRange(response.Messages);
382+
var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolApprovalRequestContent>());
383+
Assert.Equal("search_events", approvalRequest.ToolCall.ToolName);
384+
input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)]));
385+
386+
response = streaming ?
387+
await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() :
388+
await client.GetResponseAsync(input, chatOptions);
389+
}
390+
391+
Assert.NotNull(response);
392+
var toolCall = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolCallContent>());
393+
Assert.Equal("search_events", toolCall.ToolName);
394+
395+
var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolResultContent>());
396+
var content = Assert.IsType<TextContent>(Assert.Single(toolResult.Output!));
397+
Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text);
398+
}
399+
}
341400
}

0 commit comments

Comments
 (0)