Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b2903b0
Add the option to start Azure MCP server with a list of specific tools
fanyang-mono Oct 2, 2025
800115f
Fix format
fanyang-mono Oct 3, 2025
d81a602
Merge remote-tracking branch 'origin/main' into tool_mode
fanyang-mono Oct 3, 2025
1f7e9a7
Fix tests with new syntax
fanyang-mono Oct 3, 2025
c034ec6
Update CHANGELOG
fanyang-mono Oct 3, 2025
97d08c7
Make namespace mode and registry based tool loader honor --tool swith…
fanyang-mono Oct 6, 2025
fefa503
Update all docs
fanyang-mono Oct 6, 2025
1236b5b
Merge remote-tracking branch 'origin/main' into tool_mode
fanyang-mono Oct 6, 2025
74a92db
Update core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefi…
fanyang-mono Oct 7, 2025
f651051
Update docs/azmcp-commands.md
fanyang-mono Oct 7, 2025
4efa111
Fix format
fanyang-mono Oct 7, 2025
46d2cab
Merge remote-tracking branch 'origin/main' into tool_mode
fanyang-mono Oct 7, 2025
4004c2b
Fix format
fanyang-mono Oct 7, 2025
2bde267
Address review feedbacks
fanyang-mono Oct 7, 2025
670a227
When --tool is used, It automatically switches to `all` mode.
fanyang-mono Oct 7, 2025
d623d6f
Update doc to provide clear explanation on --tool behavior
fanyang-mono Oct 7, 2025
0ba9856
Check for substring match instead of exact string match
fanyang-mono Oct 8, 2025
61a6729
Merge remote-tracking branch 'origin/main' into tool_mode
fanyang-mono Oct 8, 2025
8d0a2b7
Fix format
fanyang-mono Oct 8, 2025
f552dd9
Update area name
fanyang-mono Oct 8, 2025
2a2af8d
Disallow --namespace and --tool options being used together
fanyang-mono Oct 8, 2025
2d37dc5
Merge remote-tracking branch 'origin/main' into tool_mode
fanyang-mono Oct 8, 2025
c0373c3
Clean up
fanyang-mono Oct 8, 2025
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
17 changes: 16 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,13 +283,28 @@ Optional `--namespace` and `--mode` parameters can be used to configure differen
}
```

**Specific Tool Mode** (expose only specific tools):

```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", "--tool", "azmcp_storage_account_get", "--tool", "azmcp_subscription_list"]
}
}
}
```

> **Server Mode Summary:**
>
> - **Default Mode**: No additional parameters - exposes all tools individually
> - **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)
> - **Single Tool Mode**: `--mode single` - single "azure" tool with internal routing
> - **Combined Mode**: Both `--namespace` and `--mode` can be used together
> - **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.)

#### Start from IDE

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi
Namespace = serviceStartOptions.Namespace,
ReadOnly = serviceStartOptions.ReadOnly ?? false,
InsecureDisableElicitation = serviceStartOptions.InsecureDisableElicitation,
Tool = serviceStartOptions.Tool,
};

if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy)
Expand Down Expand Up @@ -132,7 +133,8 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi
var utilityToolLoaderOptions = new ToolLoaderOptions(
Namespace: Discovery.DiscoveryConstants.UtilityNamespaces,
ReadOnly: defaultToolLoaderOptions.ReadOnly,
InsecureDisableElicitation: defaultToolLoaderOptions.InsecureDisableElicitation
InsecureDisableElicitation: defaultToolLoaderOptions.InsecureDisableElicitation,
Tool: defaultToolLoaderOptions.Tool
);

toolLoaders.Add(new CommandFactoryToolLoader(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ protected override void RegisterOptions(Command command)
command.Options.Add(ServiceOptionDefinitions.Transport);
command.Options.Add(ServiceOptionDefinitions.Namespace);
command.Options.Add(ServiceOptionDefinitions.Mode);
command.Options.Add(ServiceOptionDefinitions.Tool);
command.Options.Add(ServiceOptionDefinitions.ReadOnly);
command.Options.Add(ServiceOptionDefinitions.Debug);
command.Options.Add(ServiceOptionDefinitions.EnableInsecureTransports);
Expand All @@ -67,6 +68,10 @@ protected override void RegisterOptions(Command command)
ValidateMode(commandResult.GetValueOrDefault(ServiceOptionDefinitions.Mode), commandResult);
ValidateTransport(commandResult.GetValueOrDefault(ServiceOptionDefinitions.Transport), commandResult);
ValidateInsecureTransportsConfiguration(commandResult.GetValueOrDefault(ServiceOptionDefinitions.EnableInsecureTransports), commandResult);
ValidateNamespaceAndToolMutualExclusion(
commandResult.GetValueOrDefault<string[]?>(ServiceOptionDefinitions.Namespace.Name),
commandResult.GetValueOrDefault<string[]?>(ServiceOptionDefinitions.Tool.Name),
commandResult);
});
}

Expand All @@ -77,11 +82,21 @@ protected override void RegisterOptions(Command command)
/// <returns>A configured ServiceStartOptions instance.</returns>
protected override ServiceStartOptions BindOptions(ParseResult parseResult)
{
var mode = parseResult.GetValueOrDefault<string?>(ServiceOptionDefinitions.Mode.Name);
var tools = parseResult.GetValueOrDefault<string[]?>(ServiceOptionDefinitions.Tool.Name);

// When --tool switch is used, automatically change the mode to "all"
if (tools != null && tools.Length > 0)
{
mode = ModeTypes.All;
}

var options = new ServiceStartOptions
{
Transport = parseResult.GetValueOrDefault<string>(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo,
Namespace = parseResult.GetValueOrDefault<string[]?>(ServiceOptionDefinitions.Namespace.Name),
Mode = parseResult.GetValueOrDefault<string?>(ServiceOptionDefinitions.Mode.Name),
Mode = mode,
Tool = tools,
ReadOnly = parseResult.GetValueOrDefault<bool?>(ServiceOptionDefinitions.ReadOnly.Name),
Debug = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.Debug.Name),
EnableInsecureTransports = parseResult.GetValueOrDefault<bool>(ServiceOptionDefinitions.EnableInsecureTransports.Name),
Expand Down Expand Up @@ -175,6 +190,23 @@ private static void ValidateInsecureTransportsConfiguration(bool enableInsecureT
commandResult.AddError("Using --enable-insecure-transport requires the host to have either Managed Identity or Workload Identity enabled. Please refer to the troubleshooting guidelines here at https://aka.ms/azmcp/troubleshooting.");
}

/// <summary>
/// Validates that --namespace and --tool options are not used together.
/// </summary>
/// <param name="namespaces">The namespace values.</param>
/// <param name="tools">The tool values.</param>
/// <param name="commandResult">Command result to update on failure.</param>
private static void ValidateNamespaceAndToolMutualExclusion(string[]? namespaces, string[]? tools, CommandResult commandResult)
{
bool hasNamespace = namespaces != null && namespaces.Length > 0;
bool hasTool = tools != null && tools.Length > 0;

if (hasNamespace && hasTool)
{
commandResult.AddError("The --namespace and --tool options cannot be used together. Please specify either --namespace to filter by service namespace or --tool to filter by specific tool names, but not both.");
}
}

/// <summary>
/// Provides custom error messages for specific exception types to improve user experience.
/// </summary>
Expand All @@ -186,6 +218,8 @@ ArgumentException argEx when argEx.Message.Contains("Invalid transport") =>
"Invalid transport option specified. Use --transport stdio for the supported transport mechanism.",
ArgumentException argEx when argEx.Message.Contains("Invalid mode") =>
"Invalid mode option specified. Use --mode single, namespace, or all for the supported modes.",
ArgumentException argEx when argEx.Message.Contains("--namespace and --tool options cannot be used together") =>
"Configuration error: The --namespace and --tool options are mutually exclusive. Use either one or the other to filter available tools.",
InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --enable-insecure-transport") =>
"Insecure transport configuration error. Ensure proper authentication configured with Managed Identity or Workload Identity.",
_ => base.GetErrorMessage(ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,19 @@ private static bool IsRawMcpToolInputOption(Option option)
/// <returns>A result containing the list of available tools.</returns>
public ValueTask<ListToolsResult> ListToolsHandler(RequestContext<ListToolsRequestParams> request, CancellationToken cancellationToken)
{
var tools = CommandFactory.GetVisibleCommands(_toolCommands)
var visibleCommands = CommandFactory.GetVisibleCommands(_toolCommands);

// Filter by specific tools if provided
if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
{
visibleCommands = visibleCommands.Where(kvp =>
{
var toolKey = kvp.Key;
return _options.Value.Tool.Any(tool => tool.Contains(toolKey, StringComparison.OrdinalIgnoreCase));
});
}

var tools = visibleCommands
.Select(kvp => GetTool(kvp.Key, kvp.Value))
.Where(tool => !_options.Value.ReadOnly || (tool.Annotations?.ReadOnlyHint == true))
.ToList();
Expand Down Expand Up @@ -98,6 +110,25 @@ public async ValueTask<CallToolResult> CallToolHandler(RequestContext<CallToolRe
}

var toolName = request.Params.Name;

// Check if tool filtering is enabled and validate the requested tool
if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
{
if (!_options.Value.Tool.Any(tool => tool.Contains(toolName, StringComparison.OrdinalIgnoreCase)))
{
var content = new TextContentBlock
{
Text = $"Tool '{toolName}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}",
};

return new CallToolResult
{
Content = [content],
IsError = true,
};
}
}

var activity = Activity.Current;

if (activity != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ public override async ValueTask<ListToolsResult> ListToolsHandler(RequestContext
.Select(t => t.ProtocolTool)
.Where(t => !_options.Value.ReadOnly || (t.Annotations?.ReadOnlyHint == true));

// Filter by specific tools if provided
if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
{
filteredTools = filteredTools.Where(t => _options.Value.Tool.Any(tool => tool.Contains(t.Name, StringComparison.OrdinalIgnoreCase)));
}

foreach (var tool in filteredTools)
{
allToolsResponse.Tools.Add(tool);
Expand Down Expand Up @@ -90,6 +96,26 @@ public override async ValueTask<CallToolResult> CallToolHandler(RequestContext<C
// Initialize the tool client map if not already done
await InitializeAsync(cancellationToken);

// Check if tool filtering is enabled and validate the requested tool
if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
{
if (!_options.Value.Tool.Any(tool => tool.Contains(request.Params.Name, StringComparison.OrdinalIgnoreCase)))
{
var content = new TextContentBlock
{
Text = $"Tool '{request.Params.Name}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}",
};

_logger.LogWarning(content.Text);

return new CallToolResult
{
Content = [content],
IsError = true,
};
}
}

if (!_toolClientMap.TryGetValue(request.Params.Name, out var mcpClient) || mcpClient == null)
{
var content = new TextContentBlock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public override async ValueTask<ListToolsResult> ListToolsHandler(RequestContext
foreach (var server in serverList)
{
var metadata = server.CreateMetadata();

var tool = new Tool
{
Name = metadata.Name,
Expand All @@ -96,6 +97,7 @@ public override async ValueTask<CallToolResult> CallToolHandler(RequestContext<C
}

string tool = request.Params.Name;

var args = request.Params?.Arguments;
string? intent = null;
string? command = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
/// <param name="Namespace">The namespaces to filter commands by. If null or empty, all commands will be included.</param>
/// <param name="ReadOnly">Whether the tool loader should operate in read-only mode. When true, only tools marked as read-only will be exposed.</param>
/// <param name="InsecureDisableElicitation">Whether elicitation is disabled (insecure mode). When true, elicitation will always be treated as accepted.</param>
public sealed record ToolLoaderOptions(string[]? Namespace = null, bool ReadOnly = false, bool InsecureDisableElicitation = false);
/// <param name="Tool">The specific tool names to filter by. When specified, only these tools will be exposed.</param>
public sealed record ToolLoaderOptions(string[]? Namespace = null, bool ReadOnly = false, bool InsecureDisableElicitation = false, string[]? Tool = null);
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static class ServiceOptionDefinitions
public const string TransportName = "transport";
public const string NamespaceName = "namespace";
public const string ModeName = "mode";
public const string ToolName = "tool";
public const string ReadOnlyName = "read-only";
public const string DebugName = "debug";
public const string EnableInsecureTransportsName = "enable-insecure-transports";
Expand Down Expand Up @@ -41,6 +42,17 @@ public static class ServiceOptionDefinitions
DefaultValueFactory = _ => (string?)ModeTypes.NamespaceProxy
};

public static readonly Option<string[]?> Tool = new Option<string[]?>(
$"--{ToolName}"
)
{
Description = "Expose only specific tools by name (e.g., 'azmcp_acr_registry_list'). Repeat this option to include multiple tools, e.g., --tool \"azmcp_acr_registry_list\" --tool \"azmcp_group_list\". It automatically switches to \"all\" mode when \"--tool\" is used. When used together with \"--namespace\", only tools within the specified namespaces are considered.",
Required = false,
Arity = ArgumentArity.OneOrMore,
AllowMultipleArgumentsPerToken = true,
DefaultValueFactory = _ => null
};

public static readonly Option<bool?> ReadOnly = new(
$"--{ReadOnlyName}")
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ public class ServiceStartOptions
[JsonPropertyName("mode")]
public string? Mode { get; set; } = ModeTypes.NamespaceProxy;

/// <summary>
/// Gets or sets the specific tool names to expose.
/// When specified, only these tools will be available.
/// </summary>
[JsonPropertyName("tool")]
public string[]? Tool { get; set; } = null;

/// <summary>
/// Gets or sets whether the server should operate in read-only mode.
/// When true, only tools marked as read-only will be available.
Expand Down
2 changes: 1 addition & 1 deletion core/Azure.Mcp.Core/src/Commands/CommandFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ public string RemoveRootGroupFromCommandName(string fullCommandName)
}

/// <summary>
/// Gets the service area given the full command name (i.e. 'storage_account_list' or 'azmcp_storage_account_list' would return 'storage').
/// Gets the service area given the full command name (i.e. 'storage_account_list' or 'azmcp_storage_account_get' would return 'storage').
/// </summary>
/// <param name="fullCommandName">Name of the command.</param>
public string? GetServiceArea(string fullCommandName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,4 +576,47 @@ public async Task VerifyUniqueToolNames_InDefaultMode()
}

#endregion

#region Tool Mode Tests

[Fact]
public async Task ToolMode_AutomaticallyChangesToAllMode()
{
// Arrange - Test that --tool switch automatically changes mode to "all"
await using var client = await CreateClientAsync("server", "start", "--tool", "azmcp_group_list", "--tool", "azmcp_subscription_list");

// Act
var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);

// Assert
Assert.NotEmpty(listResult);

var toolNames = listResult.Select(t => t.Name).ToList();

// Should only include the specified tools
Assert.Equal(2, toolNames.Count);
Assert.Contains("azmcp_group_list", toolNames);
Assert.Contains("azmcp_subscription_list", toolNames);
}

[Fact]
public async Task ToolMode_OverridesExplicitNamespaceMode()
{
// Arrange - Test that --tool switch overrides --mode namespace
await using var client = await CreateClientAsync("server", "start", "--mode", "namespace", "--tool", "azmcp_group_list");

// Act
var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);

// Assert
Assert.NotEmpty(listResult);

var toolNames = listResult.Select(t => t.Name).ToList();

// Should only include the specified tool, mode should be automatically changed to "all"
Assert.Single(toolNames);
Assert.Contains("azmcp_group_list", toolNames);
}

#endregion
}
Loading
Loading