mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Add sample for invoking Foundry Toolbox tools from declarative workflows
This commit is contained in:
@@ -239,6 +239,7 @@
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
|
||||
@@ -478,6 +478,17 @@ internal static class WorkflowSamples
|
||||
ExpectedOutputDescription = ["The output should show a workflow invoking a function tool (e.g. a menu plugin) to answer a question about the soup of the day."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_InvokeFoundryToolboxMcp",
|
||||
ProjectPath = "samples/03-workflows/Declarative/InvokeFoundryToolboxMcp",
|
||||
RequiredEnvironmentVariables = ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME", "FOUNDRY_TOOLBOX_NAME", "FOUNDRY_AGENT_TOOLSET_API_VERSION"],
|
||||
Inputs = ["How do I use Azure OpenAI with my data?"],
|
||||
InputDelayMs = 3000,
|
||||
ExpectedOutputDescription = ["The output should show a workflow using Foundry Toolbox MCP tools to search Microsoft Learn documentation and web search to provide a summary of results."],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Workflow_Declarative_InvokeMcpTool",
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
|
||||
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
|
||||
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="InvokeFoundryToolboxMcp.yaml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#
|
||||
# This workflow demonstrates invoking MCP tools through a Foundry toolbox MCP proxy.
|
||||
#
|
||||
# The toolbox is provisioned with TWO different tool types:
|
||||
# 1. A Foundry built-in web_search tool
|
||||
# 2. A Microsoft Learn MCP server (microsoft_docs)
|
||||
# Both are surfaced through the same MCP-compatible toolbox endpoint.
|
||||
#
|
||||
# The workflow:
|
||||
# 1. Accepts a documentation/web search query as input
|
||||
# 2. Lists the tools exposed by the Foundry toolbox using reserved toolName: tools/list
|
||||
# 3. Invokes the microsoft_docs_search MCP tool
|
||||
# 4. Invokes the built-in web_search tool against the same toolbox endpoint
|
||||
# 5. Uses an agent to summarize and combine both result sets
|
||||
#
|
||||
# Example input:
|
||||
# How do I use Azure OpenAI with my data?
|
||||
#
|
||||
kind: Workflow
|
||||
trigger:
|
||||
|
||||
kind: OnConversationStart
|
||||
id: workflow_invoke_foundry_toolbox_mcp
|
||||
actions:
|
||||
|
||||
# Set the search query from user input.
|
||||
- kind: SetVariable
|
||||
id: set_search_query
|
||||
variable: Local.SearchQuery
|
||||
value: =System.LastMessage.Text
|
||||
|
||||
# List tools exposed by the Foundry toolbox MCP proxy.
|
||||
- kind: InvokeMcpTool
|
||||
id: list_toolbox_tools
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: tools/list
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.ToolboxTools
|
||||
|
||||
# Invoke a specific tool exposed through the toolbox and add the result to the conversation.
|
||||
- kind: InvokeMcpTool
|
||||
id: search_docs_with_toolbox
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
arguments:
|
||||
query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.SearchResult
|
||||
|
||||
# Invoke the web_search built-in tool through the same toolbox proxy. The toolbox surfaces
|
||||
# built-in Foundry tools (like web_search) alongside MCP tools through one MCP-compatible
|
||||
# endpoint. Note that web_search expects argument 'search_query' (not 'query').
|
||||
- kind: InvokeMcpTool
|
||||
id: search_web_with_toolbox
|
||||
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
|
||||
serverLabel: foundry_toolbox
|
||||
toolName: =Env.FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME
|
||||
conversationId: =System.ConversationId
|
||||
headers:
|
||||
Foundry-Features: Toolboxes=V1Preview
|
||||
arguments:
|
||||
search_query: =Local.SearchQuery
|
||||
output:
|
||||
autoSend: true
|
||||
result: Local.WebSearchResult
|
||||
|
||||
# Use the agent to summarize what happened and answer from the toolbox result.
|
||||
- kind: InvokeAzureAgent
|
||||
id: summarize_toolbox_result
|
||||
agent:
|
||||
name: FoundryToolboxMcpAgent
|
||||
conversationId: =System.ConversationId
|
||||
input:
|
||||
messages: =UserMessage("Combine the Microsoft Learn docs results and the Foundry web search results in the conversation to answer the query " & Local.SearchQuery)
|
||||
output:
|
||||
autoSend: true
|
||||
messages: Local.Summary
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates using InvokeMcpTool to call MCP tools through a Foundry toolbox.
|
||||
// It creates a sample toolbox that exposes Microsoft Learn MCP tools, lists the toolbox tools
|
||||
// through the reserved tools/list operation, then calls microsoft_docs_search from the workflow.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.AI.Projects.Agents;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Responses;
|
||||
using Shared.Foundry;
|
||||
using Shared.Workflows;
|
||||
|
||||
#pragma warning disable OPENAI001 // Experimental API
|
||||
#pragma warning disable AAIP001 // AgentToolboxes is experimental
|
||||
|
||||
namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp;
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP tools exposed through a Foundry toolbox.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sample provisions a toolbox with Microsoft Learn MCP tools, uses the reserved
|
||||
/// <c>tools/list</c> tool name to list the toolbox tools, calls one specific toolbox tool,
|
||||
/// and has a Foundry agent summarize the results.
|
||||
/// </remarks>
|
||||
internal sealed class Program
|
||||
{
|
||||
private const string ToolboxNameSetting = "FOUNDRY_TOOLBOX_NAME";
|
||||
private const string ToolboxApiVersionSetting = "FOUNDRY_AGENT_TOOLSET_API_VERSION";
|
||||
private const string ToolboxMcpServerUrlSetting = "FOUNDRY_TOOLBOX_MCP_SERVER_URL";
|
||||
private const string DocsServerLabelSetting = "FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL";
|
||||
private const string WebSearchToolNameSetting = "FOUNDRY_TOOLBOX_WEB_SEARCH_TOOL_NAME";
|
||||
private const string DefaultToolboxName = "declarative_foundry_toolbox_mcp";
|
||||
private const string DefaultToolboxApiVersion = "v1";
|
||||
private const string DefaultDocsServerLabel = "microsoft_docs";
|
||||
private const string DefaultWebSearchToolName = "web_search";
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// Initialize configuration
|
||||
IConfiguration configuration = Application.InitializeConfig();
|
||||
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
|
||||
string toolboxName = configuration[ToolboxNameSetting] ?? DefaultToolboxName;
|
||||
string toolboxApiVersion = configuration[ToolboxApiVersionSetting] ?? DefaultToolboxApiVersion;
|
||||
string docsServerLabel = configuration[DocsServerLabelSetting] ?? DefaultDocsServerLabel;
|
||||
string webSearchToolName = configuration[WebSearchToolNameSetting] ?? DefaultWebSearchToolName;
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
DefaultAzureCredential credential = new();
|
||||
|
||||
// Ensure sample toolbox and agent exist in Foundry
|
||||
string toolboxEndpoint = await CreateSampleToolboxAsync(toolboxName, docsServerLabel, foundryEndpoint, credential);
|
||||
string toolboxMcpServerUrl = BuildToolboxMcpServerUrl(toolboxEndpoint, toolboxName, toolboxApiVersion);
|
||||
Environment.SetEnvironmentVariable(ToolboxMcpServerUrlSetting, toolboxMcpServerUrl);
|
||||
// Expose the server label to the workflow so the YAML can build prefixed MCP tool names dynamically.
|
||||
Environment.SetEnvironmentVariable(DocsServerLabelSetting, docsServerLabel);
|
||||
// Expose the web search tool name so the YAML can reference it without hard-coding.
|
||||
Environment.SetEnvironmentVariable(WebSearchToolNameSetting, webSearchToolName);
|
||||
|
||||
await CreateAgentAsync(foundryEndpoint, configuration, credential);
|
||||
|
||||
// Get input from command line or console
|
||||
string workflowInput = Application.GetInput(args);
|
||||
|
||||
// Create the MCP tool handler for invoking the Foundry toolbox MCP proxy.
|
||||
List<HttpClient> createdHttpClients = [];
|
||||
DefaultMcpToolHandler mcpToolHandler = new(
|
||||
httpClientProvider: async (serverUrl, _) =>
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(serverUrl, toolboxMcpServerUrl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
FoundryToolboxBearerTokenHandler handler = new(credential)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler()
|
||||
};
|
||||
HttpClient httpClient = new(handler);
|
||||
createdHttpClients.Add(httpClient);
|
||||
return httpClient;
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Create the workflow factory with MCP tool provider
|
||||
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
|
||||
{
|
||||
McpToolHandler = mcpToolHandler
|
||||
};
|
||||
|
||||
// Execute the workflow
|
||||
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
|
||||
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up connections and dispose created HttpClients
|
||||
await mcpToolHandler.DisposeAsync();
|
||||
|
||||
foreach (HttpClient httpClient in createdHttpClients)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration, TokenCredential credential)
|
||||
{
|
||||
AIProjectClient aiProjectClient = new(foundryEndpoint, credential);
|
||||
|
||||
await aiProjectClient.CreateAgentAsync(
|
||||
agentName: "FoundryToolboxMcpAgent",
|
||||
agentDefinition: DefineToolboxAgent(configuration),
|
||||
agentDescription: "Summarizes Foundry toolbox MCP tool results");
|
||||
}
|
||||
|
||||
private static DeclarativeAgentDefinition DefineToolboxAgent(IConfiguration configuration)
|
||||
{
|
||||
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful assistant that explains results produced by tools exposed through a Foundry toolbox.
|
||||
The conversation history contains output from BOTH a Microsoft Learn documentation search (MCP) and a Foundry web search.
|
||||
Synthesize an answer that draws on both sources, calls out where they agree or differ, and notes which toolbox tool produced each fact when it is relevant.
|
||||
Be concise.
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential)
|
||||
{
|
||||
AgentAdministrationClientOptions options = new();
|
||||
options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall);
|
||||
AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options);
|
||||
AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes();
|
||||
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Toolbox does not exist.
|
||||
}
|
||||
|
||||
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
|
||||
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: serverLabel,
|
||||
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
|
||||
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
name: name,
|
||||
tools: [webTool, mcpTool],
|
||||
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
|
||||
|
||||
Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))");
|
||||
|
||||
return $"{foundryEndpoint.ToString().TrimEnd('/')}/toolboxes";
|
||||
}
|
||||
|
||||
private static string BuildToolboxMcpServerUrl(string toolboxEndpoint, string toolboxName, string apiVersion) =>
|
||||
$"{toolboxEndpoint.TrimEnd('/')}/{toolboxName}/mcp?api-version={Uri.EscapeDataString(apiVersion)}";
|
||||
|
||||
private sealed class FoundryToolboxBearerTokenHandler(TokenCredential credential) : DelegatingHandler
|
||||
{
|
||||
private static readonly TokenRequestContext s_tokenContext =
|
||||
new(["https://ai.azure.com/.default"]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(s_tokenContext, cancellationToken);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy
|
||||
{
|
||||
private const string FeatureHeader = "Foundry-Features";
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
message.Request.Headers.Add(FeatureHeader, feature);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
@@ -24,6 +27,14 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
|
||||
/// to the MCP protocol <c>tools/list</c> discovery operation.
|
||||
/// </summary>
|
||||
public const string ListToolsToolName = "tools/list";
|
||||
|
||||
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
@@ -53,9 +64,21 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
if (IsListToolsToolName(toolName))
|
||||
{
|
||||
ThrowIfListToolsArgumentsSpecified(arguments);
|
||||
}
|
||||
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (IsListToolsToolName(toolName))
|
||||
{
|
||||
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
|
||||
}
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
|
||||
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
|
||||
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
|
||||
? null
|
||||
@@ -72,6 +95,23 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
internal static bool IsListToolsToolName(string toolName) =>
|
||||
string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal);
|
||||
|
||||
internal static McpServerToolResultContent CreateListToolsResultContent(IEnumerable<Tool> tools)
|
||||
{
|
||||
Throw.IfNull(tools);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString())
|
||||
{
|
||||
Outputs = []
|
||||
};
|
||||
|
||||
resultContent.Outputs.Add(new TextContent(SerializeToolsList(tools)));
|
||||
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -183,6 +223,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
if (arguments is { Count: > 0 })
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The reserved MCP '{ListToolsToolName}' operation does not accept tool arguments.",
|
||||
nameof(arguments));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
|
||||
{
|
||||
// Ensure Outputs list is initialized
|
||||
@@ -230,6 +280,17 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
TextContentBlock text => new TextContent(text.Text),
|
||||
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
|
||||
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
|
||||
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
|
||||
{
|
||||
return block.Resource switch
|
||||
{
|
||||
TextResourceContents text => new TextContent(text.Text),
|
||||
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
@@ -255,4 +316,39 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
|
||||
}
|
||||
|
||||
private static string SerializeToolsList(IEnumerable<Tool> tools)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using (Utf8JsonWriter writer = new(stream, s_toolListJsonWriterOptions))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteStartArray("tools");
|
||||
|
||||
foreach (Tool tool in tools)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", tool.Name);
|
||||
writer.WriteString("description", tool.Description);
|
||||
writer.WritePropertyName("inputSchema");
|
||||
tool.InputSchema.WriteTo(writer);
|
||||
writer.WritePropertyName("outputSchema");
|
||||
if (tool.OutputSchema is JsonElement outputSchema)
|
||||
{
|
||||
outputSchema.WriteTo(writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
+157
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -320,6 +321,92 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reserved Tools/List Tests
|
||||
|
||||
[Fact]
|
||||
public void IsListToolsToolName_WithReservedName_ShouldReturnTrue()
|
||||
{
|
||||
// Act
|
||||
bool result = DefaultMcpToolHandler.IsListToolsToolName(DefaultMcpToolHandler.ListToolsToolName);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsListToolsToolName_WithRegularToolName_ShouldReturnFalse()
|
||||
{
|
||||
// Act
|
||||
bool result = DefaultMcpToolHandler.IsListToolsToolName("search");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeToolAsync_WithListToolsArguments_ShouldThrowArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
DefaultMcpToolHandler handler = new();
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
Func<Task> act = async () => await handler.InvokeToolAsync(
|
||||
serverUrl: "http://localhost:12345/mcp",
|
||||
serverLabel: "test",
|
||||
toolName: DefaultMcpToolHandler.ListToolsToolName,
|
||||
arguments: new Dictionary<string, object?> { ["ignored"] = true },
|
||||
headers: null,
|
||||
connectionName: null);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*does not accept tool arguments*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await handler.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateListToolsResultContent_WithTools_ShouldSerializeToolMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement inputSchema = JsonSerializer.Deserialize<JsonElement>(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [ "query" ]
|
||||
}
|
||||
""");
|
||||
Tool tool = new()
|
||||
{
|
||||
Name = "search",
|
||||
Description = "Searches documentation.",
|
||||
InputSchema = inputSchema
|
||||
};
|
||||
|
||||
// Act
|
||||
McpServerToolResultContent result = DefaultMcpToolHandler.CreateListToolsResultContent([tool]);
|
||||
|
||||
// Assert
|
||||
TextContent text = result.Outputs.Should().ContainSingle().Subject.Should().BeOfType<TextContent>().Subject;
|
||||
using JsonDocument document = JsonDocument.Parse(text.Text);
|
||||
JsonElement listedTool = document.RootElement.GetProperty("tools")[0];
|
||||
listedTool.GetProperty("name").GetString().Should().Be("search");
|
||||
listedTool.GetProperty("description").GetString().Should().Be("Searches documentation.");
|
||||
listedTool.GetProperty("inputSchema").GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interface Implementation Tests
|
||||
|
||||
[Fact]
|
||||
@@ -488,5 +575,75 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
dataContent.MediaType.Should().Be("audio/*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent()
|
||||
{
|
||||
// Arrange
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new TextResourceContents
|
||||
{
|
||||
Text = "embedded text payload",
|
||||
Uri = "resource://example",
|
||||
MimeType = "text/plain",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<TextContent>()
|
||||
.Which.Text.Should().Be("embedded text payload");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = "application/zip",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/zip");
|
||||
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = null!,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/octet-stream");
|
||||
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+38
@@ -432,6 +432,44 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl
|
||||
VerifyInvocationEvent(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithReservedListToolsNameAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.InitializeSystem();
|
||||
const string ListToolsToolName = "tools/list";
|
||||
string? capturedToolName = null;
|
||||
InvokeMcpTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeMcpToolExecuteWithReservedListToolsNameAsync),
|
||||
serverUrl: TestServerUrl,
|
||||
toolName: ListToolsToolName);
|
||||
Mock<IMcpToolHandler> mockProvider = new();
|
||||
mockProvider.Setup(provider => provider.InvokeToolAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IDictionary<string, object?>?>(),
|
||||
It.IsAny<IDictionary<string, string>?>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string?, string, IDictionary<string, object?>?, IDictionary<string, string>?, string?, CancellationToken>(
|
||||
(_, _, toolName, _, _, _, _) => capturedToolName = toolName)
|
||||
.ReturnsAsync(new McpServerToolResultContent("list-tools-call-id")
|
||||
{
|
||||
Outputs = [new TextContent("{\"tools\":[]}")]
|
||||
});
|
||||
MockAgentProvider mockAgentProvider = new();
|
||||
InvokeMcpToolExecutor action = new(model, mockProvider.Object, mockAgentProvider.Object, this.State);
|
||||
|
||||
// Act
|
||||
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
|
||||
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
VerifyInvocationEvent(events);
|
||||
Assert.Equal(ListToolsToolName, capturedToolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeMcpToolExecuteWithMultipleContentTypesAsync()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user