Skip to content

Commit f2bb2d5

Browse files
jozkeejeffhandley
authored andcommitted
Add support for Connectors, Add AuthorizationToken, Enable MCP approvals in User messages, Make MCP tool call ServerName nullable and remove Headers (dotnet#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 5066b10 commit f2bb2d5

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)