Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ public sealed class McpServerToolCallContent : AIContent
/// </summary>
/// <param name="callId">The tool call ID.</param>
/// <param name="toolName">The tool name.</param>
/// <param name="serverName">The MCP server name.</param>
/// <exception cref="ArgumentNullException"><paramref name="callId"/>, <paramref name="toolName"/>, or <paramref name="serverName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="callId"/>, <paramref name="toolName"/>, or <paramref name="serverName"/> are empty or composed entirely of whitespace.</exception>
public McpServerToolCallContent(string callId, string toolName, string serverName)
/// <param name="serverName">The MCP server name that hosts the tool.</param>
/// <exception cref="ArgumentNullException"><paramref name="callId"/> or <paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="callId"/> or <paramref name="toolName"/> is empty or composed entirely of whitespace.</exception>
public McpServerToolCallContent(string callId, string toolName, string? serverName)
{
CallId = Throw.IfNullOrWhitespace(callId);
ToolName = Throw.IfNullOrWhitespace(toolName);
ServerName = Throw.IfNullOrWhitespace(serverName);
ServerName = serverName;
}

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

/// <summary>
/// Gets the name of the MCP server.
/// Gets the name of the MCP server that hosts the tool.
/// </summary>
public string ServerName { get; }
public string? ServerName { get; }

/// <summary>
/// Gets or sets the arguments used for the tool call.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,13 @@ public class HostedMcpServerTool : AITool
/// Initializes a new instance of the <see cref="HostedMcpServerTool"/> class.
/// </summary>
/// <param name="serverName">The name of the remote MCP server.</param>
/// <param name="url">The URL of the remote MCP server.</param>
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> or <paramref name="url"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="serverName"/> is empty or composed entirely of whitespace.</exception>
public HostedMcpServerTool(string serverName, [StringSyntax(StringSyntaxAttribute.Uri)] string url)
: this(serverName, new Uri(Throw.IfNull(url)))
{
}

/// <summary>
/// Initializes a new instance of the <see cref="HostedMcpServerTool"/> class.
/// </summary>
/// <param name="serverName">The name of the remote MCP server.</param>
/// <param name="url">The URL of the remote MCP server.</param>
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> or <paramref name="url"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="serverName"/> is empty or composed entirely of whitespace.</exception>
public HostedMcpServerTool(string serverName, Uri url)
/// <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>
/// <exception cref="ArgumentNullException"><paramref name="serverName"/> or <paramref name="serverAddress"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="serverName"/> or <paramref name="serverAddress"/> is empty or composed entirely of whitespace.</exception>
public HostedMcpServerTool(string serverName, string serverAddress)
{
ServerName = Throw.IfNullOrWhitespace(serverName);
Url = Throw.IfNull(url);
ServerAddress = Throw.IfNullOrWhitespace(serverAddress);
}

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

/// <summary>
/// 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.
/// </summary>
public Uri Url { get; }
public string ServerAddress { get; }

/// <summary>
/// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server.
/// </summary>
public string? AuthorizationToken { get; set; }

/// <summary>
/// Gets or sets the description of the remote MCP server, used to provide more context to the AI service.
Expand Down Expand Up @@ -81,12 +74,4 @@ public HostedMcpServerTool(string serverName, Uri url)
/// </para>
/// </remarks>
public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; }

/// <summary>
/// Gets or sets the HTTP headers that the AI service should use when calling the remote MCP server.
/// </summary>
/// <remarks>
/// This property is useful for specifying the authentication header or other headers required by the MCP server.
/// </remarks>
public IDictionary<string, string>? Headers { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -657,7 +663,57 @@ internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<Chat

if (input.Role == ChatRole.User)
{
yield return ResponseItem.CreateUserMessageItem(ToResponseContentParts(input.Contents));
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.
List<ResponseContentPart> 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;
}

Expand Down Expand Up @@ -883,52 +939,6 @@ private static void PopulateAnnotations(ResponseContentPart source, AIContent de
}
}

/// <summary>Convert a list of <see cref="AIContent"/>s to a list of <see cref="ResponseContentPart"/>.</summary>
private static List<ResponseContentPart> ToResponseContentParts(IList<AIContent> contents)
{
List<ResponseContentPart> 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;
}

/// <summary>Adds new <see cref="AIContent"/> for the specified <paramref name="mtci"/> into <paramref name="contents"/>.</summary>
private static void AddMcpToolCallContent(McpToolCallItem mtci, IList<AIContent> contents)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -52,12 +51,10 @@ public void Constructor_PropsRoundtrip()
[Fact]
public void Constructor_Throws()
{
Assert.Throws<ArgumentException>("callId", () => new McpServerToolCallContent(string.Empty, "name", "serverName"));
Assert.Throws<ArgumentException>("toolName", () => new McpServerToolCallContent("callId1", string.Empty, "serverName"));
Assert.Throws<ArgumentException>("serverName", () => new McpServerToolCallContent("callId1", "name", string.Empty));
Assert.Throws<ArgumentException>("callId", () => new McpServerToolCallContent(string.Empty, "name", null));
Assert.Throws<ArgumentException>("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null));

Assert.Throws<ArgumentNullException>("callId", () => new McpServerToolCallContent(null!, "name", "serverName"));
Assert.Throws<ArgumentNullException>("toolName", () => new McpServerToolCallContent("callId1", null!, "serverName"));
Assert.Throws<ArgumentNullException>("serverName", () => new McpServerToolCallContent("callId1", "name", null!));
Assert.Throws<ArgumentNullException>("callId", () => new McpServerToolCallContent(null!, "name", null));
Assert.Throws<ArgumentNullException>("toolName", () => new McpServerToolCallContent("callId1", null!, null));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,34 @@ 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);
}

[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("mcp", tool.Name);
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;
Expand All @@ -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<string, string> headers = [];
tool.Headers = headers;
Assert.Same(headers, tool.Headers);
}

[Fact]
public void Constructor_Throws()
{
Assert.Throws<ArgumentException>(() => new HostedMcpServerTool(string.Empty, new Uri("https://localhost/")));
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool(null!, new Uri("https://localhost/")));
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool("name", (Uri)null!));
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool("name", (string)null!));
Assert.Throws<UriFormatException>(() => new HostedMcpServerTool("name", string.Empty));
Assert.Throws<ArgumentException>(() => new HostedMcpServerTool(string.Empty, "https://localhost/"));
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool(null!, "https://localhost/"));
Assert.Throws<ArgumentException>(() => new HostedMcpServerTool("name", string.Empty));
Assert.Throws<ArgumentNullException>(() => new HostedMcpServerTool("name", null!));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMessage> 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<McpServerToolApprovalRequestContent>());
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<McpServerToolCallContent>());
Assert.Equal("search_events", toolCall.ToolName);

var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType<McpServerToolResultContent>());
var content = Assert.IsType<TextContent>(Assert.Single(toolResult.Output!));
Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text);
}
}
}
Loading
Loading