Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,22 @@ If you are contributing significant changes, or if the issue is already assigned

6. **Add CODEOWNERS entry** in [CODEOWNERS](https://github.com/microsoft/mcp/blob/main/.github/CODEOWNERS) [(example)](https://github.com/microsoft/mcp/commit/08f73efe826d5d47c0f93be5ed9e614740e82091)

7. **Create Pull Request**:
7. **Add new tool to consolidated mode**:
- Open `core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json` file, where the tool grouping definition is stored for consolidated mode. In Agent mode, add it to the chat as context.
- Paste the follow prompt for Copilot to generate the change to add the new tool:
```txt
I have this list of tools which haven't been matched with any consolidated tools in this file. Help me add them to the one with the best matching category and exact matching toolMetadata. Update existing consolidated tools where newly mapped tools are added. If you can't find one, suggest a new consolidated tool.

<Add new tool name here>
```
- Use the following command to find out the correct tool name for your new tool
```
cd servers/Azure.Mcp.Server/src/bin/Debug/net9.0
./azmcp[.exe] tools list --name --namespace <tool_area>
```
- Commit the change.

8. **Create Pull Request**:
- Reference the issue you created
- Include tests in the `/tests` folder
- Ensure all tests pass
Expand Down Expand Up @@ -269,7 +284,22 @@ Optional `--namespace` and `--mode` parameters can be used to configure differen
}
```

**Combined Mode** (filter namespaces with proxy mode):
**Consolidated Mode** (grouped related operations):
It honors both --read-only and --namespace switches.

```json
{
"servers": {
"azure-mcp-server": {
"type": "stdio",
"command": "<absolute-path-to>/mcp/servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp[.exe]",
"args": ["server", "start", "--mode", "consolidated"]
}
}
}
```

**Combined Mode** (filter namespaces with any mode):

```json
{
Expand Down Expand Up @@ -299,9 +329,11 @@ Optional `--namespace` and `--mode` parameters can be used to configure differen

> **Server Mode Summary:**
>
> - **Default Mode**: No additional parameters - exposes all tools individually
> - **Default Mode (Namespace)**: No additional parameters - collapses tools by namespace (current default)
> - **Consolidated Mode**: `--mode consolidated` - exposes consolidated tools grouping related operations, optimized for AI agents.
> - **Namespace Mode**: `--namespace <service-name>` - expose specific services
> - **Namespace Proxy Mode**: `--mode namespace` - collapse tools by namespace (useful for VS Code's 128 tool limit)
> - **All Tools Mode**: `--mode all` - expose all ~800+ individual tools
> - **Single Tool Mode**: `--mode single` - single "azure" tool with internal routing
> - **Specific Tool Mode**: `--tool <tool-name>` - expose only specific tools by name (finest granularity)
> - **Combined Mode**: Multiple options can be used together (`--namespace` + `--mode` etc.)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft Corporation.
Comment thread
fanyang-mono marked this conversation as resolved.
// 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";
Comment thread
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))
Comment thread
fanyang-mono marked this conversation as resolved.
.ToList();

if (matchingCommands.Count == 0)
{
continue;
}
Comment thread
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);
Comment thread
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);
Comment thread
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;
}
}
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);
Comment thread
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,
};
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Commands;
using ModelContextProtocol.Client;

namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
Expand All @@ -27,6 +28,11 @@ public sealed class McpServerMetadata(string id = "", string name = "", string d
/// Gets or sets a description of the server's purpose or capabilities.
/// </summary>
public string Description { get; set; } = description;

/// <summary>
/// Gets or sets the tool metadata for this server, containing tool-specific information.
/// </summary>
public ToolMetadata? ToolMetadata { get; set; }
Comment thread
fanyang-mono marked this conversation as resolved.
}

/// <summary>
Expand Down
Loading