-
Notifications
You must be signed in to change notification settings - Fork 467
Add Consolidated mode #784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
a698072
Initial change for adding consolidated mode
fanyang-mono 868ff1b
Merge remote-tracking branch 'origin/main' into composite_tools
fanyang-mono 06c03bb
Make consolidated mode work end-to-end with all the tools
fanyang-mono 2c69ac4
Fix format
fanyang-mono 9e9d338
Merge remote-tracking branch 'origin/main' into composite_tools
fanyang-mono 82b327e
Add all the new commands
fanyang-mono 71f3a6b
Merged a couple consolidated tools
fanyang-mono f896a42
Add ToolDescriptionEvaluator result
fanyang-mono b9e3009
Add instructions on how to add new tool to consolidated mode
fanyang-mono db4fabf
Merge remote-tracking branch 'origin/main' into composite_tools
fanyang-mono ae83696
Add one more tool to consolidated mode and fixed test failures
fanyang-mono c26cbdf
Add documentation
fanyang-mono 5d23eab
Honor --read-only server setting
fanyang-mono 76f8256
Honor --namespace server setting
fanyang-mono 2cb7179
Update doc
fanyang-mono 6cfa19d
Update test
fanyang-mono f6373c6
Update test
fanyang-mono 2a320d5
Address review feedbacks
fanyang-mono aaa58ae
Fix ToolMetadata values
fanyang-mono File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
215 changes: 215 additions & 0 deletions
215
core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.Reflection; | ||
| using System.Text.Json; | ||
| using Azure.Mcp.Core.Areas.Server.Models; | ||
| using Azure.Mcp.Core.Areas.Server.Options; | ||
| using Azure.Mcp.Core.Commands; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; | ||
|
|
||
| /// <summary> | ||
| /// Discovery strategy that exposes command groups as MCP servers. | ||
| /// This strategy converts Azure CLI command groups into MCP servers, allowing them to be accessed via the MCP protocol. | ||
| /// </summary> | ||
| /// <param name="commandFactory">The command factory used to access available command groups.</param> | ||
| /// <param name="options">Options for configuring the service behavior.</param> | ||
| /// <param name="logger">Logger instance for this discovery strategy.</param> | ||
| public sealed class ConsolidatedToolDiscoveryStrategy(CommandFactory commandFactory, IOptions<ServiceStartOptions> options, ILogger<ConsolidatedToolDiscoveryStrategy> logger) : BaseDiscoveryStrategy(logger) | ||
| { | ||
| private readonly CommandFactory _commandFactory = commandFactory; | ||
| private readonly IOptions<ServiceStartOptions> _options = options; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the entry point to use for the command group servers. | ||
| /// This can be used to specify a custom entry point for the commands. | ||
| /// </summary> | ||
| public string? EntryPoint { get; set; } = null; | ||
| public static readonly string[] IgnoredCommandGroups = ["server", "tools"]; | ||
|
|
||
| /// <summary> | ||
| /// Discovers available command groups and converts them to MCP server providers. | ||
| /// </summary> | ||
| /// <returns>A collection of command group server providers.</returns> | ||
| public override Task<IEnumerable<IMcpServerProvider>> DiscoverServersAsync() | ||
| { | ||
| // Load consolidated tools from JSON file | ||
| var consolidatedTools = new List<ConsolidatedToolDefinition>(); | ||
| try | ||
| { | ||
| var assembly = Assembly.GetExecutingAssembly(); | ||
| var resourceName = "Azure.Mcp.Core.Areas.Server.Resources.consolidated-tools.json"; | ||
|
fanyang-mono marked this conversation as resolved.
|
||
| using var stream = assembly.GetManifestResourceStream(resourceName); | ||
| if (stream == null) | ||
| { | ||
| var errorMessage = $"Failed to load embedded resource '{resourceName}'"; | ||
| _logger.LogError(errorMessage); | ||
| throw new InvalidOperationException(errorMessage); | ||
| } | ||
|
|
||
| using var reader = new StreamReader(stream); | ||
| var json = reader.ReadToEnd(); | ||
| using var jsonDoc = JsonDocument.Parse(json); | ||
| if (!jsonDoc.RootElement.TryGetProperty("consolidated_tools", out var toolsArray)) | ||
| { | ||
| var errorMessage = "Property 'consolidated_tools' not found in consolidated-tools.json"; | ||
| _logger.LogError(errorMessage); | ||
| throw new InvalidOperationException(errorMessage); | ||
| } | ||
|
|
||
| consolidatedTools = JsonSerializer.Deserialize(toolsArray.GetRawText(), ServerJsonContext.Default.ListConsolidatedToolDefinition) ?? new List<ConsolidatedToolDefinition>(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Failed to load consolidated tools from JSON file"); | ||
| return Task.FromResult<IEnumerable<IMcpServerProvider>>(new List<IMcpServerProvider>()); | ||
| } | ||
|
|
||
| var providers = new List<IMcpServerProvider>(); | ||
| var allCommands = _commandFactory.AllCommands; | ||
|
|
||
| // Filter out commands that belong to ignored command groups | ||
| var filteredCommands = allCommands | ||
| .Where(kvp => | ||
| { | ||
| var serviceArea = _commandFactory.GetServiceArea(kvp.Key); | ||
| return serviceArea == null || !IgnoredCommandGroups.Contains(serviceArea, StringComparer.OrdinalIgnoreCase); | ||
| }) | ||
| .Where(kvp => _options.Value.ReadOnly == false || kvp.Value.Metadata.ReadOnly == true) | ||
| .Where(kvp => | ||
| { | ||
| // Filter by namespace if specified | ||
| if (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0) | ||
| { | ||
| return true; | ||
| } | ||
| var serviceArea = _commandFactory.GetServiceArea(kvp.Key); | ||
| return serviceArea != null && _options.Value.Namespace.Contains(serviceArea, StringComparer.OrdinalIgnoreCase); | ||
| }) | ||
| .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); | ||
|
|
||
| // Track unmatched commands | ||
| var unmatchedCommands = new HashSet<string>(filteredCommands.Keys, StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| // Iterate through each consolidated tool definition | ||
| foreach (var consolidatedTool in consolidatedTools) | ||
| { | ||
| // Find all commands that match this consolidated tool's mapped tool list | ||
| var matchingCommands = filteredCommands | ||
| .Where(kvp => consolidatedTool.MappedToolList != null && | ||
| consolidatedTool.MappedToolList.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase)) | ||
|
fanyang-mono marked this conversation as resolved.
|
||
| .ToList(); | ||
|
|
||
| if (matchingCommands.Count == 0) | ||
| { | ||
| continue; | ||
| } | ||
|
fanyang-mono marked this conversation as resolved.
|
||
|
|
||
| #if DEBUG | ||
| // In debug mode, validate that all tools in MappedToolList found a match when conditions are met | ||
| if (_options.Value.ReadOnly == false && (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0)) | ||
| { | ||
| if (consolidatedTool.MappedToolList != null) | ||
| { | ||
| var matchedToolNames = new HashSet<string>(matchingCommands.Select(mc => mc.Key), StringComparer.OrdinalIgnoreCase); | ||
| var unmatchedToolsInList = consolidatedTool.MappedToolList | ||
| .Where(toolName => !matchedToolNames.Contains(toolName)) | ||
| .ToList(); | ||
|
|
||
| if (unmatchedToolsInList.Count > 0) | ||
| { | ||
| var unmatchedToolsList = string.Join(", ", unmatchedToolsInList); | ||
| var errorMessage = $"Consolidated tool '{consolidatedTool.Name}' has {unmatchedToolsInList.Count} tools in MappedToolList that didn't find a match in filteredCommands: {unmatchedToolsList}"; | ||
| _logger.LogError(errorMessage); | ||
| // throw new InvalidOperationException(errorMessage); | ||
| } | ||
| } | ||
| } | ||
| #endif | ||
|
|
||
| // Create a new CommandGroup and add the matching commands | ||
| var commandGroup = new CommandGroup(consolidatedTool.Name, consolidatedTool.Description); | ||
| _commandFactory.RootGroup.AddSubGroup(commandGroup); | ||
|
fanyang-mono marked this conversation as resolved.
|
||
|
|
||
| foreach (var (commandName, command) in matchingCommands) | ||
| { | ||
| // Validate that the command's metadata matches the consolidated tool's metadata | ||
| if (!AreMetadataEqual(command.Metadata, consolidatedTool.ToolMetadata)) | ||
| { | ||
| var errorMessage = $"Command '{commandName}' has mismatched ToolMetadata for consolidated tool '{consolidatedTool.Name}'. " + | ||
| $"Command metadata: [Destructive={command.Metadata.Destructive}, Idempotent={command.Metadata.Idempotent}, " + | ||
| $"OpenWorld={command.Metadata.OpenWorld}, ReadOnly={command.Metadata.ReadOnly}, Secret={command.Metadata.Secret}, " + | ||
| $"LocalRequired={command.Metadata.LocalRequired}], " + | ||
| $"Consolidated tool metadata: [Destructive={consolidatedTool.ToolMetadata?.Destructive}, " + | ||
| $"Idempotent={consolidatedTool.ToolMetadata?.Idempotent}, OpenWorld={consolidatedTool.ToolMetadata?.OpenWorld}, " + | ||
| $"ReadOnly={consolidatedTool.ToolMetadata?.ReadOnly}, Secret={consolidatedTool.ToolMetadata?.Secret}, " + | ||
| $"LocalRequired={consolidatedTool.ToolMetadata?.LocalRequired}]"; | ||
| #if DEBUG | ||
| _logger.LogError(errorMessage); | ||
| throw new InvalidOperationException(errorMessage); | ||
|
JasonYeMSFT marked this conversation as resolved.
|
||
| #else | ||
| _logger.LogWarning(errorMessage); | ||
| #endif | ||
| } | ||
|
|
||
| commandGroup.AddCommand(commandName, command); | ||
| // Remove matched commands from the unmatched list | ||
| unmatchedCommands.Remove(commandName); | ||
| } | ||
|
|
||
| commandGroup.ToolMetadata = consolidatedTool.ToolMetadata; | ||
|
|
||
| ConsolidatedToolServerProvider serverProvider = new ConsolidatedToolServerProvider(commandGroup) | ||
| { | ||
| ReadOnly = _options.Value.ReadOnly ?? false, | ||
| EntryPoint = EntryPoint, | ||
| }; | ||
|
|
||
| providers.Add(serverProvider); | ||
| } | ||
|
|
||
| // Check for unmatched commands | ||
| if (unmatchedCommands.Count > 0) | ||
| { | ||
| var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c)); | ||
| var errorMessage = $"Found {unmatchedCommands.Count} unmatched commands: {unmatchedList}"; | ||
| #if DEBUG | ||
| _logger.LogError(errorMessage); | ||
| throw new InvalidOperationException(errorMessage); | ||
| #else | ||
| _logger.LogWarning("Found {Count} unmatched commands: {Commands}", unmatchedCommands.Count, unmatchedList); | ||
| #endif | ||
| } | ||
|
|
||
| return Task.FromResult<IEnumerable<IMcpServerProvider>>(providers); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Compares two ToolMetadata objects for equality. | ||
| /// </summary> | ||
| /// <param name="metadata1">The first ToolMetadata to compare.</param> | ||
| /// <param name="metadata2">The second ToolMetadata to compare.</param> | ||
| /// <returns>True if the metadata objects are equal, false otherwise.</returns> | ||
| private static bool AreMetadataEqual(ToolMetadata? metadata1, ToolMetadata? metadata2) | ||
| { | ||
| if (metadata1 == null && metadata2 == null) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| if (metadata1 == null || metadata2 == null) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| return metadata1.Destructive == metadata2.Destructive && | ||
| metadata1.Idempotent == metadata2.Idempotent && | ||
| metadata1.OpenWorld == metadata2.OpenWorld && | ||
| metadata1.ReadOnly == metadata2.ReadOnly && | ||
| metadata1.Secret == metadata2.Secret && | ||
| metadata1.LocalRequired == metadata2.LocalRequired; | ||
| } | ||
| } | ||
96 changes: 96 additions & 0 deletions
96
core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Azure.Mcp.Core.Areas.Server.Options; | ||
| using Azure.Mcp.Core.Commands; | ||
| using ModelContextProtocol.Client; | ||
|
|
||
| namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; | ||
|
|
||
| /// <summary> | ||
| /// Server provider that starts the azmcp server in "all" mode while explicitly | ||
| /// enumerating each tool (command) in a command group using repeated --tool flags. | ||
| /// This allows selective exposure of only the commands that belong to the provided group | ||
| /// without relying on the namespace grouping mechanism. | ||
| /// </summary> | ||
| public sealed class ConsolidatedToolServerProvider(CommandGroup commandGroup) : IMcpServerProvider | ||
| { | ||
| private readonly CommandGroup _commandGroup = commandGroup; | ||
| private string? _entryPoint = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the entry point executable path for the MCP server. | ||
| /// If set to null or empty, defaults to the current process executable. | ||
| /// </summary> | ||
| public string? EntryPoint | ||
| { | ||
| get => _entryPoint; | ||
| set => _entryPoint = string.IsNullOrWhiteSpace(value) | ||
| ? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName | ||
| : value; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets whether the MCP server should run in read-only mode. | ||
| /// </summary> | ||
| public bool ReadOnly { get; set; } = false; | ||
|
|
||
| /// <summary> | ||
| /// Creates an MCP client from a command group. | ||
| /// </summary> | ||
| public async Task<McpClient> CreateClientAsync(McpClientOptions clientOptions) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(EntryPoint)) | ||
| { | ||
| throw new InvalidOperationException("EntryPoint must be set before creating the MCP client."); | ||
| } | ||
|
|
||
| var arguments = BuildArguments(); | ||
|
|
||
| var transportOptions = new StdioClientTransportOptions | ||
| { | ||
| Name = _commandGroup.Name, | ||
| Command = EntryPoint, | ||
| Arguments = arguments, | ||
| }; | ||
|
|
||
| var clientTransport = new StdioClientTransport(transportOptions); | ||
| return await McpClient.CreateAsync(clientTransport, clientOptions); | ||
|
anuchandy marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builds the command-line arguments for the MCP server process. | ||
| /// Pattern: server start --mode all (--tool <qualifiedCommand>)+ [--read-only] | ||
| /// </summary> | ||
| internal string[] BuildArguments() | ||
| { | ||
| var arguments = new List<string> { "server", "start", "--mode", "all" }; | ||
|
|
||
| foreach (var kvp in _commandGroup.Commands) | ||
| { | ||
| arguments.Add("--tool"); | ||
| arguments.Add(kvp.Key); | ||
| } | ||
|
|
||
| if (ReadOnly) | ||
| { | ||
| arguments.Add($"--{ServiceOptionDefinitions.ReadOnlyName}"); | ||
| } | ||
|
|
||
| return [.. arguments]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Creates metadata for the MCP server provider based on the command group. | ||
| /// </summary> | ||
| public McpServerMetadata CreateMetadata() | ||
| { | ||
| return new McpServerMetadata | ||
| { | ||
| Id = _commandGroup.Name, | ||
| Name = _commandGroup.Name, | ||
| Description = _commandGroup.Description, | ||
| ToolMetadata = _commandGroup.ToolMetadata, | ||
| }; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.