From ef1a683ba308b7a2231f3cf06bfcf10fc0381880 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Sun, 5 Oct 2025 11:31:20 -0700 Subject: [PATCH 1/8] NamespaceToolLoader: the tool loader that supports namespace without creating child azmcp processes --- .../Commands/ServiceCollectionExtensions.cs | 16 +- .../ToolLoading/NamespaceToolLoader.cs | 738 ++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 5 +- .../ToolLoading/NamespaceToolLoaderTests.cs | 525 +++++++++++++ 4 files changed, 1280 insertions(+), 4 deletions(-) create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs create mode 100644 core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs index 4aae2beec2..eb1783b289 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs @@ -75,6 +75,7 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Register server discovery strategies services.AddSingleton(); @@ -85,7 +86,7 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi services.AddSingleton(); // Register MCP discovery strategies based on proxy mode - if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy || serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy) { services.AddSingleton(sp => { @@ -99,6 +100,10 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi return new CompositeDiscoveryStrategy(discoveryStrategies, logger); }); } + else if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + { + services.AddSingleton(); + } // Configure tool loading based on mode if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy) @@ -112,7 +117,14 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi var loggerFactory = sp.GetRequiredService(); var toolLoaders = new List { - sp.GetRequiredService(), + // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers. + new ServerToolLoader( + sp.GetRequiredService(), + sp.GetRequiredService>(), + loggerFactory.CreateLogger() + ), + // NamespaceToolLoader enables direct in-process execution for tools in Azure namespaces + sp.GetRequiredService(), }; // Always add utility commands (subscription, group) in namespace mode diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs new file mode 100644 index 0000000000..9b0891561f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -0,0 +1,738 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// A tool loader that exposes Azure command groups as hierarchical namespace tools with direct in-process tool execution. +/// Provides the same functionality as but without spawning child azmcp processes. +/// Supports learn functionality for progressive discovery of commands within each namespace. +/// +public sealed class NamespaceToolLoader : BaseToolLoader +{ + private readonly CommandFactory _commandFactory; + private readonly IOptions _options; + private readonly IServiceProvider _serviceProvider; + private static readonly List IgnoreCommandGroups = ["extension", "server", "tools"]; + + private readonly Lazy> _cachedNamespaceTools; + private readonly IReadOnlyList _namespaceNames; + private readonly ConcurrentDictionary> _commandsByNamespace = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _cachedLearnToolsByNamespace = new(StringComparer.OrdinalIgnoreCase); + + private static readonly Lazy HierarchicalSchemaCache = new(CreateHierarchicalSchema); + private const string HierarchicalToolDescription = + "This tool is a hierarchical MCP command router.\n" + + "Sub commands are routed to MCP servers that require specific fields inside the \"parameters\" object.\n" + + "To invoke a command, set \"command\" and wrap its args in \"parameters\".\n" + + "Set \"learn=true\" to discover available sub commands."; + + private const string ToolCallProxySchema = """ + { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "The name of the tool to call." + }, + "parameters": { + "type": "object", + "description": "A key/value pair of parameters names and values to pass to the tool call command." + } + }, + "additionalProperties": false + } + """; + + public NamespaceToolLoader( + CommandFactory commandFactory, + IOptions options, + IServiceProvider serviceProvider, + ILogger logger) : base(logger) + { + _commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + _namespaceNames = GetFilteredNamespaceNames(); + _cachedNamespaceTools = new Lazy>(() => + _namespaceNames + .Select(ns => CreateNamespaceTool(ns, GetNamespaceDescription(ns))) + .ToList()); + } + + public override ValueTask ListToolsHandler( + RequestContext request, + CancellationToken cancellationToken) + { + var tools = _cachedNamespaceTools.Value; + _logger.LogInformation("Listing {Count} namespace tools.", tools.Count); + return ValueTask.FromResult(new ListToolsResult { Tools = tools }); + } + + /// + /// Handles tool calls for namespace tools. Supports both learn mode (discovery) and + /// command execution mode (direct command invocation). + /// + public override async ValueTask CallToolHandler( + RequestContext request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + var toolName = request.Params.Name; + var args = request.Params.Arguments; + + // Validate namespace exists + if (!_namespaceNames.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + { + return new CallToolResult + { + Content = [new TextContentBlock + { + Text = $"Namespace '{toolName}' not found. Available namespaces: {string.Join(", ", _namespaceNames)}" + }], + IsError = true + }; + } + + // Parse hierarchical structure from tool arguments + var parsedArgs = request.Params.Arguments as IReadOnlyDictionary; + if (parsedArgs == null && request.Params.Arguments != null) + { + // Convert from Dictionary if needed + parsedArgs = ConvertToJsonElements(request.Params.Arguments as Dictionary); + } + + var (intent, command, parameters, learn) = ParseHierarchicalCall(parsedArgs); // Auto-learn if intent provided but no command specified + if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command)) + { + learn = true; + } + + try + { + if (learn && string.IsNullOrEmpty(command)) + { + // Learn mode: Return available commands for this namespace + return await HandleLearnRequest(request, intent ?? "", toolName, cancellationToken); + } + else if (!string.IsNullOrEmpty(toolName) && !string.IsNullOrEmpty(command)) + { + // Execution mode: Execute the specified command + return await ExecuteNamespaceCommand(request, intent ?? "", toolName, command, parameters, cancellationToken); + } + } + catch (KeyNotFoundException ex) + { + _logger.LogError(ex, "Key not found while calling namespace tool: {Tool}", toolName); + + return new CallToolResult + { + Content = [new TextContentBlock + { + Text = $""" + The tool '{toolName}.{command}' was not found or does not support the specified command. + Please ensure the tool name and command are correct. + If you want to learn about available tools, run again with the "learn=true" argument. + """ + }], + IsError = true + }; + } + + return new CallToolResult + { + Content = [new TextContentBlock + { + Text = """ + The "command" parameter is required when not learning. + Run again with the "learn" argument to get a list of available tools and their parameters. + To learn about a specific tool, use the "tool" argument with the name of the tool. + """ + }], + IsError = false + }; + } + + /// + /// Handles learn requests for a namespace, returning available commands with their schemas. + /// Uses caching to avoid rebuilding tool definitions on repeated requests. + /// + private async Task HandleLearnRequest( + RequestContext request, + string intent, + string nameSpace, + CancellationToken cancellationToken) + { + if (_cachedLearnToolsByNamespace.TryGetValue(nameSpace, out var cachedTools)) + { + var cachedJson = JsonSerializer.Serialize(cachedTools, ServerJsonContext.Default.ListTool); + return CreateLearnResponse(nameSpace, cachedJson); + } + + // Build tools for this namespace (lazy load if not cached) + var namespaceCommands = GetOrLoadNamespaceCommands(nameSpace); + var tools = namespaceCommands + .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) + .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) + .ToList(); + + // Cache for future requests + _cachedLearnToolsByNamespace[nameSpace] = tools; + + var toolsJson = JsonSerializer.Serialize(tools, ServerJsonContext.Default.ListTool); + + var learnResponse = CreateLearnResponse(nameSpace, toolsJson); + + // If client supports sampling and intent is provided, try to infer command + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var (commandName, parameters) = await GetCommandAndParametersFromIntentAsync( + request, intent, nameSpace, tools, cancellationToken); + + if (commandName != null) + { + return await ExecuteNamespaceCommand(request, intent, nameSpace, commandName, parameters, cancellationToken); + } + } + + return learnResponse; + } + + /// + /// Executes a command within a namespace. + /// + private async Task ExecuteNamespaceCommand( + RequestContext request, + string intent, + string nameSpace, + string command, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + var namespaceCommands = GetOrLoadNamespaceCommands(nameSpace); + + // Try to find the command - handle both "command" and "namespace command" formats + if (!namespaceCommands.TryGetValue(command, out var cmd)) + { + var fullCommandName = $"{nameSpace} {command}"; + if (!namespaceCommands.TryGetValue(fullCommandName, out cmd)) + { + _logger.LogWarning("Namespace {Namespace} does not have a command {Command}.", nameSpace, command); + + if (string.IsNullOrWhiteSpace(intent)) + { + return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); + } + + // Try to infer command from intent + var tools = _cachedLearnToolsByNamespace.GetValueOrDefault(nameSpace) + ?? GetToolsForNamespace(nameSpace); + + var samplingResult = await GetCommandAndParametersFromIntentAsync( + request, intent, nameSpace, tools, cancellationToken); + + if (string.IsNullOrWhiteSpace(samplingResult.commandName)) + { + return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); + } + + command = samplingResult.commandName; + parameters = samplingResult.parameters; + + if (!namespaceCommands.TryGetValue(command, out cmd)) + { + return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); + } + } + } + + try + { + await NotifyProgressAsync(request, $"Calling {nameSpace} {command}...", cancellationToken); + + // Direct execution (same as CommandFactoryToolLoader) + var commandContext = new CommandContext(_serviceProvider, Activity.Current); + var realCommand = cmd.GetCommand(); + + ParseResult commandOptions; + if (realCommand.Options.Count == 1 && IsRawMcpToolInputOption(realCommand.Options[0])) + { + commandOptions = realCommand.ParseFromRawMcpToolInput(parameters); + } + else + { + commandOptions = realCommand.ParseFromDictionary(parameters); + } + + _logger.LogTrace("Executing namespace command '{Namespace} {Command}'", nameSpace, command); + + var commandResponse = await cmd.ExecuteAsync(commandContext, commandOptions); + + // Check if command requires missing parameters + var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse); + var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous; + + if (jsonResponse.Contains("Missing required options", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Namespace command '{Namespace} {Command}' requires additional parameters.", nameSpace, command); + + var commandTool = GetCommandTool(nameSpace, command); + var commandToolJson = JsonSerializer.Serialize(commandTool, ServerJsonContext.Default.Tool); + + return new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = $""" + The '{command}' command is missing required parameters. + + - Review the following command spec and identify the required arguments from the input schema. + - Omit any arguments that are not required or do not apply to your use case. + - Wrap all command arguments into the root "parameters" argument. + - If required data is missing infer the data from your context or prompt the user as needed. + - Run the tool again with the "command" and root "parameters" object. + + Command Spec: + {commandToolJson} + + Original Error: + {jsonResponse} + """ + } + ], + IsError = true + }; + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = jsonResponse }], + IsError = isError + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while calling namespace tool: {Namespace}, command: {Command}", nameSpace, command); + return new CallToolResult + { + Content = [new TextContentBlock + { + Text = $""" + There was an error finding or calling tool and command. + Failed to call namespace: {nameSpace}, command: {command} + Error: {ex.Message} + + Run again with the "learn=true" to get a list of available commands and their parameters. + """ + }], + IsError = true + }; + } + } + + /// + /// Gets filtered namespace names at construction time (lightweight operation). + /// + private IReadOnlyList GetFilteredNamespaceNames() + { + return _commandFactory.RootGroup.SubGroup + .Where(group => !IgnoreCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => _options.Value.Namespace == null || + _options.Value.Namespace.Length == 0 || + _options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Select(group => group.Name) + .ToList(); + } + + /// + /// Gets or lazily loads commands for a specific namespace. + /// Commands are only loaded when first accessed, improving startup time and memory usage. + /// + private IReadOnlyDictionary GetOrLoadNamespaceCommands(string nameSpace) + { + return _commandsByNamespace.GetOrAdd(nameSpace, ns => _commandFactory.GroupCommands([ns])); + } + + /// + /// Creates a hierarchical namespace tool with learn capabilities. + /// Uses cached hierarchical schema to avoid repeated JSON serialization. + /// + private static Tool CreateNamespaceTool(string nameSpace, string description) + { + return new Tool + { + Name = nameSpace, + Description = $"{description}\n{HierarchicalToolDescription}", + InputSchema = HierarchicalSchemaCache.Value + }; + } + + /// + /// Creates the hierarchical input schema used by all namespace tools. + /// This schema is shared across all instances and cached statically. + /// + private static JsonElement CreateHierarchicalSchema() + { + return JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "The intent of the azure operation to perform." + }, + "command": { + "type": "string", + "description": "The command to execute against the specified tool." + }, + "parameters": { + "type": "object", + "description": "The parameters to pass to the tool command." + }, + "learn": { + "type": "boolean", + "description": "To learn about the tool and its supported child tools and parameters.", + "default": false + } + }, + "required": ["intent"], + "additionalProperties": false + } + """, ServerJsonContext.Default.JsonElement); + } + + /// + /// Creates a tool definition from a command (same logic as CommandFactoryToolLoader). + /// + private static Tool CreateToolFromCommand(string fullName, IBaseCommand command) + { + var underlyingCommand = command.GetCommand(); + var tool = new Tool + { + Name = fullName, + Description = underlyingCommand.Description, + }; + + var metadata = command.Metadata; + tool.Annotations = new ToolAnnotations() + { + DestructiveHint = metadata.Destructive, + IdempotentHint = metadata.Idempotent, + OpenWorldHint = metadata.OpenWorld, + ReadOnlyHint = metadata.ReadOnly, + Title = command.Title, + }; + + if (metadata.Secret) + { + tool.Meta = new JsonObject { ["SecretHint"] = metadata.Secret }; + } + + var schema = new ToolInputSchema(); + var options = command.GetCommand().Options; + + if (options?.Count > 0) + { + if (options.Count == 1 && IsRawMcpToolInputOption(options[0])) + { + var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject(); + tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject); + return tool; + } + else + { + foreach (var option in options) + { + var propName = NameNormalization.NormalizeOptionName(option.Name); + schema.Properties.Add(propName, TypeToJsonTypeMapper.CreatePropertySchema(option.ValueType, option.Description)); + } + schema.Required = [.. options.Where(p => p.Required).Select(p => NameNormalization.NormalizeOptionName(p.Name))]; + } + } + + tool.InputSchema = JsonSerializer.SerializeToElement(schema, ServerJsonContext.Default.ToolInputSchema); + return tool; + } + + /// + /// Parses hierarchical call structure from MCP tool arguments. + /// + private static (string? intent, string? command, IReadOnlyDictionary parameters, bool learn) ParseHierarchicalCall( + IReadOnlyDictionary? args) + { + if (args == null) + { + return (null, null, new Dictionary(), false); + } + + string? intent = null; + string? command = null; + bool learn = false; + IReadOnlyDictionary parameters = new Dictionary(); + + if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) + { + intent = intentElem.GetString(); + } + + if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) + { + learn = true; + } + + if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) + { + command = commandElem.GetString(); + } + + if (args.TryGetValue("parameters", out var paramsElem) && paramsElem.ValueKind == JsonValueKind.Object) + { + parameters = paramsElem.EnumerateObject() + .ToDictionary(prop => prop.Name, prop => prop.Value); + } + + return (intent, command, parameters, learn); + } + + /// + /// Converts Dictionary to Dictionary for command parsing. + /// + private static IReadOnlyDictionary ConvertToJsonElements(Dictionary? dict) + { + if (dict == null || dict.Count == 0) + { + return new Dictionary(); + } + + var result = new Dictionary(); + foreach (var kvp in dict) + { + if (kvp.Value == null) + { + result[kvp.Key] = JsonDocument.Parse("null").RootElement; + } + else if (kvp.Value is JsonElement elem) + { + result[kvp.Key] = elem; + } + else if (kvp.Value is string str) + { + result[kvp.Key] = JsonDocument.Parse($"\"{str}\"").RootElement; + } + else if (kvp.Value is bool b) + { + result[kvp.Key] = JsonDocument.Parse(b.ToString().ToLower()).RootElement; + } + else if (kvp.Value is int or long or double or float or decimal) + { + result[kvp.Key] = JsonDocument.Parse(kvp.Value.ToString() ?? "null").RootElement; + } + else + { + // For complex objects, use JsonElement.ValueKind if possible + // Otherwise convert to string representation + try + { + var jsonString = JsonSerializer.SerializeToElement(kvp.Value, ServerJsonContext.Default.Object).GetRawText(); + result[kvp.Key] = JsonDocument.Parse(jsonString).RootElement; + } + catch + { + // Fallback to string representation + result[kvp.Key] = JsonDocument.Parse($"\"{kvp.Value}\"").RootElement; + } + } + } + return result; + } + + private static bool IsRawMcpToolInputOption(Option option) + { + const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; + if (string.Equals(NameNormalization.NormalizeOptionName(option.Name), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return option.Aliases.Any(alias => + string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)); + } + + private CallToolResult CreateLearnResponse(string nameSpace, string toolsJson) + { + return new CallToolResult + { + Content = [new TextContentBlock + { + Text = $""" + Here are the available command and their parameters for '{nameSpace}' tool. + If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters. + Next, identify the command you want to execute and run again with the "command" and "parameters" arguments. + + {toolsJson} + """ + }], + IsError = false + }; + } + + private List GetToolsForNamespace(string nameSpace) + { + var namespaceCommands = GetOrLoadNamespaceCommands(nameSpace); + return namespaceCommands + .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) + .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) + .ToList(); + } + + private Tool GetCommandTool(string nameSpace, string commandName) + { + var tools = _cachedLearnToolsByNamespace.GetValueOrDefault(nameSpace) + ?? GetToolsForNamespace(nameSpace); + + return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); + } + + private string GetNamespaceDescription(string nameSpace) + { + var group = _commandFactory.RootGroup.SubGroup + .FirstOrDefault(g => string.Equals(g.Name, nameSpace, StringComparison.OrdinalIgnoreCase)); + + return group?.Description ?? $"Azure {nameSpace} operations"; + } + + private static bool SupportsSampling(McpServer server) + { + return server?.ClientCapabilities?.Sampling != null; + } + + private static async Task NotifyProgressAsync(RequestContext request, string message, CancellationToken cancellationToken) + { + var progressToken = request.Params?.ProgressToken; + if (progressToken == null) + { + return; + } + + await request.Server.NotifyProgressAsync(progressToken.Value, + new ProgressNotificationValue + { + Progress = 0f, + Message = message, + }, cancellationToken); + } + + private async Task<(string? commandName, IReadOnlyDictionary parameters)> GetCommandAndParametersFromIntentAsync( + RequestContext request, + string intent, + string nameSpace, + List availableTools, + CancellationToken cancellationToken) + { + await NotifyProgressAsync(request, $"Learning about {nameSpace} capabilities...", cancellationToken); + + JsonElement toolParams = GetParametersJsonElement(request); + var toolParamsJson = toolParams.GetRawText(); + var availableToolsJson = JsonSerializer.Serialize(availableTools, ServerJsonContext.Default.ListTool); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = [ + new SamplingMessage + { + Role = Role.Assistant, + Content = new TextContentBlock + { + Text = $""" + This is a list of available commands for the {nameSpace} server. + + Your task: + - Select the single command that best matches the user's intent. + - Return a valid JSON object that matches the provided result schema. + - Map the user's intent and known parameters to the command's input schema, ensuring parameter names and types match the schema exactly (no extra or missing parameters). + - Only include parameters that are defined in the selected command's input schema. + - Do not guess or invent parameters. + - If no command matches, return JSON schema with "Unknown" tool name. + + Result Schema: + {ToolCallProxySchema} + + Intent: + {intent ?? "No specific intent provided"} + + Known Parameters: + {toolParamsJson} + + Available Commands: + {availableToolsJson} + """ + } + } + ], + }; + + try + { + var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); + var samplingContent = samplingResponse.Content as TextContentBlock; + var toolCallJson = samplingContent?.Text?.Trim(); + string? commandName = null; + IReadOnlyDictionary parameters = new Dictionary(); + + if (!string.IsNullOrEmpty(toolCallJson)) + { + var doc = JsonDocument.Parse(toolCallJson); + var root = doc.RootElement; + if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) + { + commandName = toolProp.GetString(); + } + if (root.TryGetProperty("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object) + { + parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => prop.Value) ?? new Dictionary(); + } + } + + if (commandName != null && commandName != "Unknown") + { + return (commandName, parameters); + } + } + catch + { + _logger.LogError("Failed to get command and parameters from intent: {Intent} for namespace: {Namespace}", intent, nameSpace); + } + + return (null, new Dictionary()); + } + + /// + /// Disposes resources owned by this tool loader. + /// Clears the cached tool lists dictionary. + /// + protected override async ValueTask DisposeAsyncCore() + { + _cachedLearnToolsByNamespace.Clear(); + await ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs index 5eb26e4d22..12df19a1b1 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs @@ -101,13 +101,14 @@ public void AddAzureMcpServer_WithNamespaceProxy_RegistersCompositeToolLoader() var provider = services.BuildServiceProvider(); // Verify the correct tool loader is registered - // In namespace mode, we now use CompositeToolLoader that includes ServerToolLoader + // In namespace mode, we now use CompositeToolLoader that includes NamespaceToolLoader Assert.NotNull(provider.GetService()); Assert.IsType(provider.GetService()); // Verify discovery strategy is registered + // In namespace mode, we only use RegistryDiscoveryStrategy (for external MCP servers) Assert.NotNull(provider.GetService()); - Assert.IsType(provider.GetService()); + Assert.IsType(provider.GetService()); } [Fact] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs new file mode 100644 index 0000000000..a402497e1f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.UnitTests.Areas.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public sealed class NamespaceToolLoaderTests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly CommandFactory _commandFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + + public NamespaceToolLoaderTests() + { + _serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider() as ServiceProvider + ?? throw new InvalidOperationException("Failed to create service provider"); + _commandFactory = CommandFactoryHelpers.CreateCommandFactory(_serviceProvider); + _options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + _logger = _serviceProvider.GetRequiredService>(); + } + + [Fact] + public void Constructor_InitializesSuccessfully() + { + // Arrange & Act + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + + // Assert + Assert.NotNull(loader); + } + + [Fact] + public void Constructor_ThrowsOnNullCommandFactory() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(null!, _options, _serviceProvider, _logger)); + } + + [Fact] + public void Constructor_ThrowsOnNullOptions() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(_commandFactory, null!, _serviceProvider, _logger)); + } + + [Fact] + public void Constructor_ThrowsOnNullServiceProvider() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(_commandFactory, _options, null!, _logger)); + } + + [Fact] + public async Task ListToolsHandler_ReturnsNamespaceTools() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateListToolsRequest(); + + // Act + var result = await loader.ListToolsHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.NotEmpty(result.Tools); + + // Verify hierarchical structure + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotNull(tool.Description); + Assert.Contains("hierarchical", tool.Description, StringComparison.OrdinalIgnoreCase); + + // Verify hierarchical schema structure + var schema = tool.InputSchema; + Assert.True(schema.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("intent", out _)); + Assert.True(properties.TryGetProperty("command", out _)); + Assert.True(properties.TryGetProperty("parameters", out _)); + Assert.True(properties.TryGetProperty("learn", out _)); + } + } + + [Fact] + public async Task ListToolsHandler_CachesResults() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateListToolsRequest(); + + // Act - Call twice + var result1 = await loader.ListToolsHandler(request, CancellationToken.None); + var result2 = await loader.ListToolsHandler(request, CancellationToken.None); + + // Assert - Should return same cached instance + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task ListToolsHandler_FiltersNamespacesWhenConfigured() + { + // Arrange + using var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider() as ServiceProvider + ?? throw new InvalidOperationException("Failed to create service provider"); + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions + { + Namespace = ["storage", "keyvault"] + }); + var logger = serviceProvider.GetRequiredService>(); + + var loader = new NamespaceToolLoader(commandFactory, options, serviceProvider, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await loader.ListToolsHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + Assert.All(result.Tools, tool => + Assert.True(tool.Name == "storage" || tool.Name == "keyvault")); + } + + [Fact] + public async Task CallToolHandler_WithLearnTrue_ReturnsAvailableCommands() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "list resources" + }); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("available command", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithLearnTrue_CachesCommandList() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "list resources" + }); + + // Act - Call twice + var result1 = await loader.CallToolHandler(request, CancellationToken.None); + var result2 = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert - Both should succeed and return same cached content + Assert.False(result1.IsError); + Assert.False(result2.IsError); + + var text1 = (result1.Content[0] as TextContentBlock)?.Text; + var text2 = (result2.Content[0] as TextContentBlock)?.Text; + Assert.Equal(text1, text2); + } + + [Fact] + public async Task CallToolHandler_WithIntentButNoCommand_AutoEnablesLearn() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["intent"] = "list resources" + // No command specified, should auto-enable learn + }); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("available command", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithInvalidNamespace_ReturnsError() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateCallToolRequest("nonexistent-namespace", new Dictionary + { + ["learn"] = true + }); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("not found", textContent.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Available namespaces", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithNullToolName_ThrowsArgumentException() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateCallToolRequest(null!, new Dictionary()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await loader.CallToolHandler(request, CancellationToken.None)); + } + + [Fact] + public async Task CallToolHandler_WithoutCommandOrLearn_ReturnsHelpMessage() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary()); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("command", textContent.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("learn", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_ParsesHierarchicalStructure() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var arguments = new Dictionary + { + ["intent"] = JsonDocument.Parse("\"list resources\"").RootElement, + ["command"] = JsonDocument.Parse("\"list\"").RootElement, + ["parameters"] = JsonDocument.Parse("""{"subscription":"test-sub"}""").RootElement, + ["learn"] = JsonDocument.Parse("false").RootElement + }; + + var request = CreateCallToolRequestWithJsonElements(toolName, arguments); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + // Result depends on whether command exists, but parsing should succeed + } + + [Fact] + public async Task CallToolHandler_ConvertsObjectDictionaryToJsonElements() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var arguments = new Dictionary + { + ["intent"] = "list resources", + ["command"] = "list", + ["parameters"] = new Dictionary { ["subscription"] = "test-sub" }, + ["learn"] = false + }; + + var request = CreateCallToolRequest(toolName, arguments); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + // Conversion should succeed without throwing + } + + [Fact] + public async Task CallToolHandler_HandlesCommandNotFoundGracefully() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["intent"] = "do something", + ["command"] = "nonexistent-command", + ["parameters"] = new Dictionary() + }); + + // Act + var result = await loader.CallToolHandler(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + // Should fallback to learn mode or return error + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + } + + [Fact] + public async Task CallToolHandler_LazyLoadsCommandsPerNamespace() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + + // Get two different namespaces + var listRequest = CreateListToolsRequest(); + var tools = await loader.ListToolsHandler(listRequest, CancellationToken.None); + + if (tools.Tools.Count < 2) + { + // Skip test if not enough namespaces + return; + } + + var namespace1 = tools.Tools[0].Name; + var namespace2 = tools.Tools[1].Name; + + // Act - Access only first namespace + var request1 = CreateCallToolRequest(namespace1, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + await loader.CallToolHandler(request1, CancellationToken.None); + + // Now access second namespace + var request2 = CreateCallToolRequest(namespace2, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + var result2 = await loader.CallToolHandler(request2, CancellationToken.None); + + // Assert - Both should succeed, proving lazy loading works + Assert.NotNull(result2); + Assert.False(result2.IsError); + } + + [Fact] + public async Task CallToolHandler_ThreadSafeLazyLoading() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + // Act - Simulate concurrent access + var tasks = Enumerable.Range(0, 10).Select(async _ => + { + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "concurrent test" + }); + + return await loader.CallToolHandler(request, CancellationToken.None); + }); + + var results = await Task.WhenAll(tasks); + + // Assert - All should succeed without race conditions + Assert.All(results, result => + { + Assert.NotNull(result); + Assert.False(result.IsError); + }); + + // All should return same cached content + var firstText = (results[0].Content[0] as TextContentBlock)?.Text; + Assert.All(results, result => + { + var text = (result.Content[0] as TextContentBlock)?.Text; + Assert.Equal(firstText, text); + }); + } + + [Fact] + public async Task DisposeAsync_ClearsCaches() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + // Populate cache + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + await loader.CallToolHandler(request, CancellationToken.None); + + // Act + await loader.DisposeAsync(); + + // Assert - No exception should be thrown + // Cache clearing is internal, but disposal should complete successfully + } + + // Helper methods + + private string GetFirstAvailableNamespace() + { + var namespaces = _commandFactory.RootGroup.SubGroup + .Where(g => g.Name != "extension" && g.Name != "server" && g.Name != "tools") + .Select(g => g.Name) + .ToList(); + + return namespaces.FirstOrDefault() ?? "storage"; + } + + private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest( + string toolName, + Dictionary arguments) + { + // Convert Dictionary to Dictionary + var jsonArguments = new Dictionary(); + foreach (var kvp in arguments) + { + if (kvp.Value is bool b) + { + jsonArguments[kvp.Key] = JsonDocument.Parse(b.ToString().ToLower()).RootElement; + } + else if (kvp.Value is string s) + { + jsonArguments[kvp.Key] = JsonDocument.Parse($"\"{s}\"").RootElement; + } + else if (kvp.Value is Dictionary dict) + { + var json = JsonSerializer.Serialize(dict); + jsonArguments[kvp.Key] = JsonDocument.Parse(json).RootElement; + } + else if (kvp.Value == null) + { + jsonArguments[kvp.Key] = JsonDocument.Parse("null").RootElement; + } + } + + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = jsonArguments + } + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequestWithJsonElements( + string toolName, + Dictionary arguments) + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments + } + }; + } + + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} From 7bc39f56a31c940e4c7a9c4dad702bcf943a1cc8 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Sun, 5 Oct 2025 21:21:41 -0700 Subject: [PATCH 2/8] Remove ConvertToJsonElements and rely on the guarantees provided by mcp-libs. --- .../ToolLoading/NamespaceToolLoader.cs | 65 +------------------ 1 file changed, 3 insertions(+), 62 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index 9b0891561f..a5c3ee5c35 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -97,7 +97,6 @@ public override async ValueTask CallToolHandler( } var toolName = request.Params.Name; - var args = request.Params.Arguments; // Validate namespace exists if (!_namespaceNames.Contains(toolName, StringComparer.OrdinalIgnoreCase)) @@ -112,17 +111,11 @@ public override async ValueTask CallToolHandler( }; } - // Parse hierarchical structure from tool arguments - var parsedArgs = request.Params.Arguments as IReadOnlyDictionary; - if (parsedArgs == null && request.Params.Arguments != null) - { - // Convert from Dictionary if needed - parsedArgs = ConvertToJsonElements(request.Params.Arguments as Dictionary); - } - - var (intent, command, parameters, learn) = ParseHierarchicalCall(parsedArgs); // Auto-learn if intent provided but no command specified + var args = request.Params.Arguments; + var (intent, command, parameters, learn) = ParseHierarchicalCall(args); if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command)) { + // Auto-learn if intent provided but no command specified learn = true; } @@ -514,58 +507,6 @@ private static (string? intent, string? command, IReadOnlyDictionary - /// Converts Dictionary to Dictionary for command parsing. - /// - private static IReadOnlyDictionary ConvertToJsonElements(Dictionary? dict) - { - if (dict == null || dict.Count == 0) - { - return new Dictionary(); - } - - var result = new Dictionary(); - foreach (var kvp in dict) - { - if (kvp.Value == null) - { - result[kvp.Key] = JsonDocument.Parse("null").RootElement; - } - else if (kvp.Value is JsonElement elem) - { - result[kvp.Key] = elem; - } - else if (kvp.Value is string str) - { - result[kvp.Key] = JsonDocument.Parse($"\"{str}\"").RootElement; - } - else if (kvp.Value is bool b) - { - result[kvp.Key] = JsonDocument.Parse(b.ToString().ToLower()).RootElement; - } - else if (kvp.Value is int or long or double or float or decimal) - { - result[kvp.Key] = JsonDocument.Parse(kvp.Value.ToString() ?? "null").RootElement; - } - else - { - // For complex objects, use JsonElement.ValueKind if possible - // Otherwise convert to string representation - try - { - var jsonString = JsonSerializer.SerializeToElement(kvp.Value, ServerJsonContext.Default.Object).GetRawText(); - result[kvp.Key] = JsonDocument.Parse(jsonString).RootElement; - } - catch - { - // Fallback to string representation - result[kvp.Key] = JsonDocument.Parse($"\"{kvp.Value}\"").RootElement; - } - } - } - return result; - } - private static bool IsRawMcpToolInputOption(Option option) { const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; From 7cf92136ce0e70f140757bfce1dd6ea54651e863 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Sun, 5 Oct 2025 21:36:06 -0700 Subject: [PATCH 3/8] Using JsonSerializer.SerializeToElement for tests --- .../ToolLoading/NamespaceToolLoaderTests.cs | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs index a402497e1f..717622c9b8 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -469,28 +469,9 @@ private static ModelContextProtocol.Server.RequestContext string toolName, Dictionary arguments) { - // Convert Dictionary to Dictionary - var jsonArguments = new Dictionary(); - foreach (var kvp in arguments) - { - if (kvp.Value is bool b) - { - jsonArguments[kvp.Key] = JsonDocument.Parse(b.ToString().ToLower()).RootElement; - } - else if (kvp.Value is string s) - { - jsonArguments[kvp.Key] = JsonDocument.Parse($"\"{s}\"").RootElement; - } - else if (kvp.Value is Dictionary dict) - { - var json = JsonSerializer.Serialize(dict); - jsonArguments[kvp.Key] = JsonDocument.Parse(json).RootElement; - } - else if (kvp.Value == null) - { - jsonArguments[kvp.Key] = JsonDocument.Parse("null").RootElement; - } - } + var jsonArguments = arguments.ToDictionary( + kvp => kvp.Key, + kvp => JsonSerializer.SerializeToElement(kvp.Value)); var mockServer = Substitute.For(); return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) From 0de553fded4bec6fe5762a01d8781f5cbee6c48f Mon Sep 17 00:00:00 2001 From: anuchandy Date: Mon, 6 Oct 2025 10:12:26 -0700 Subject: [PATCH 4/8] Simplify CreateNamespaceTool --- .../ToolLoading/NamespaceToolLoader.cs | 125 ++++++++---------- 1 file changed, 53 insertions(+), 72 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index a5c3ee5c35..eac7b6323b 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -33,13 +33,6 @@ public sealed class NamespaceToolLoader : BaseToolLoader private readonly ConcurrentDictionary> _commandsByNamespace = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary> _cachedLearnToolsByNamespace = new(StringComparer.OrdinalIgnoreCase); - private static readonly Lazy HierarchicalSchemaCache = new(CreateHierarchicalSchema); - private const string HierarchicalToolDescription = - "This tool is a hierarchical MCP command router.\n" + - "Sub commands are routed to MCP servers that require specific fields inside the \"parameters\" object.\n" + - "To invoke a command, set \"command\" and wrap its args in \"parameters\".\n" + - "Set \"learn=true\" to discover available sub commands."; - private const string ToolCallProxySchema = """ { "type": "object", @@ -57,6 +50,33 @@ public sealed class NamespaceToolLoader : BaseToolLoader } """; + private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "The intent of the azure operation to perform." + }, + "command": { + "type": "string", + "description": "The command to execute against the specified tool." + }, + "parameters": { + "type": "object", + "description": "The parameters to pass to the tool command." + }, + "learn": { + "type": "boolean", + "description": "To learn about the tool and its supported child tools and parameters.", + "default": false + } + }, + "required": ["intent"], + "additionalProperties": false + } + """, ServerJsonContext.Default.JsonElement); + public NamespaceToolLoader( CommandFactory commandFactory, IOptions options, @@ -70,7 +90,7 @@ public NamespaceToolLoader( _namespaceNames = GetFilteredNamespaceNames(); _cachedNamespaceTools = new Lazy>(() => _namespaceNames - .Select(ns => CreateNamespaceTool(ns, GetNamespaceDescription(ns))) + .Select(ns => CreateNamespaceTool(ns)) .ToList()); } @@ -79,7 +99,6 @@ public override ValueTask ListToolsHandler( CancellationToken cancellationToken) { var tools = _cachedNamespaceTools.Value; - _logger.LogInformation("Listing {Count} namespace tools.", tools.Count); return ValueTask.FromResult(new ListToolsResult { Tools = tools }); } @@ -187,13 +206,9 @@ private async Task HandleLearnRequest( .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) .ToList(); - // Cache for future requests + // Cache tools for future learn requests _cachedLearnToolsByNamespace[nameSpace] = tools; - var toolsJson = JsonSerializer.Serialize(tools, ServerJsonContext.Default.ListTool); - - var learnResponse = CreateLearnResponse(nameSpace, toolsJson); - // If client supports sampling and intent is provided, try to infer command if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) { @@ -206,7 +221,8 @@ private async Task HandleLearnRequest( } } - return learnResponse; + var toolsJson = JsonSerializer.Serialize(tools, ServerJsonContext.Default.ListTool); + return CreateLearnResponse(nameSpace, toolsJson); } /// @@ -235,7 +251,6 @@ private async Task ExecuteNamespaceCommand( return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); } - // Try to infer command from intent var tools = _cachedLearnToolsByNamespace.GetValueOrDefault(nameSpace) ?? GetToolsForNamespace(nameSpace); @@ -344,7 +359,16 @@ There was an error finding or calling tool and command. } /// - /// Gets filtered namespace names at construction time (lightweight operation). + /// Gets or lazily loads commands for a specific namespace. + /// Commands are only loaded when first accessed, improving startup time and memory usage. + /// + private IReadOnlyDictionary GetOrLoadNamespaceCommands(string nameSpace) + { + return _commandsByNamespace.GetOrAdd(nameSpace, ns => _commandFactory.GroupCommands([ns])); + } + + /// + /// Gets filtered namespace names. /// private IReadOnlyList GetFilteredNamespaceNames() { @@ -357,63 +381,28 @@ private IReadOnlyList GetFilteredNamespaceNames() .ToList(); } - /// - /// Gets or lazily loads commands for a specific namespace. - /// Commands are only loaded when first accessed, improving startup time and memory usage. - /// - private IReadOnlyDictionary GetOrLoadNamespaceCommands(string nameSpace) - { - return _commandsByNamespace.GetOrAdd(nameSpace, ns => _commandFactory.GroupCommands([ns])); - } - /// /// Creates a hierarchical namespace tool with learn capabilities. - /// Uses cached hierarchical schema to avoid repeated JSON serialization. /// - private static Tool CreateNamespaceTool(string nameSpace, string description) + private Tool CreateNamespaceTool(string nameSpace) { + var group = _commandFactory.RootGroup.SubGroup + .First(g => string.Equals(g.Name, nameSpace, StringComparison.OrdinalIgnoreCase)); + var description = group.Description; + return new Tool { Name = nameSpace, - Description = $"{description}\n{HierarchicalToolDescription}", - InputSchema = HierarchicalSchemaCache.Value + Description = description + """ + This tool is a hierarchical MCP command router. + Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. + To invoke a command, set "command" and wrap its args in "parameters". + Set "learn=true" to discover available sub commands. + """, + InputSchema = ToolSchema }; } - /// - /// Creates the hierarchical input schema used by all namespace tools. - /// This schema is shared across all instances and cached statically. - /// - private static JsonElement CreateHierarchicalSchema() - { - return JsonSerializer.Deserialize(""" - { - "type": "object", - "properties": { - "intent": { - "type": "string", - "description": "The intent of the azure operation to perform." - }, - "command": { - "type": "string", - "description": "The command to execute against the specified tool." - }, - "parameters": { - "type": "object", - "description": "The parameters to pass to the tool command." - }, - "learn": { - "type": "boolean", - "description": "To learn about the tool and its supported child tools and parameters.", - "default": false - } - }, - "required": ["intent"], - "additionalProperties": false - } - """, ServerJsonContext.Default.JsonElement); - } - /// /// Creates a tool definition from a command (same logic as CommandFactoryToolLoader). /// @@ -554,14 +543,6 @@ private Tool GetCommandTool(string nameSpace, string commandName) return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); } - private string GetNamespaceDescription(string nameSpace) - { - var group = _commandFactory.RootGroup.SubGroup - .FirstOrDefault(g => string.Equals(g.Name, nameSpace, StringComparison.OrdinalIgnoreCase)); - - return group?.Description ?? $"Azure {nameSpace} operations"; - } - private static bool SupportsSampling(McpServer server) { return server?.ClientCapabilities?.Sampling != null; From 96de765ab317fb56dd2b0fd8a61f174c448755b3 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Mon, 6 Oct 2025 13:16:08 -0700 Subject: [PATCH 5/8] Align NamespaceToolLoader logic closer to ServerToolLoader (enabling common logic extraction later) --- .../ToolLoading/NamespaceToolLoader.cs | 559 +++++++++--------- .../ToolLoading/NamespaceToolLoaderTests.cs | 1 - 2 files changed, 273 insertions(+), 287 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index eac7b6323b..69aabad5f0 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using System.Text.Json.Nodes; @@ -17,21 +16,34 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; /// -/// A tool loader that exposes Azure command groups as hierarchical namespace tools with direct in-process tool execution. +/// A tool loader that exposes Azure command groups as hierarchical namespace tools with direct in-process execution. /// Provides the same functionality as but without spawning child azmcp processes. /// Supports learn functionality for progressive discovery of commands within each namespace. /// -public sealed class NamespaceToolLoader : BaseToolLoader +public sealed class NamespaceToolLoader( + CommandFactory commandFactory, + IOptions options, + IServiceProvider serviceProvider, + ILogger logger) : BaseToolLoader(logger) { - private readonly CommandFactory _commandFactory; - private readonly IOptions _options; - private readonly IServiceProvider _serviceProvider; - private static readonly List IgnoreCommandGroups = ["extension", "server", "tools"]; + private readonly CommandFactory _commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); + private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - private readonly Lazy> _cachedNamespaceTools; - private readonly IReadOnlyList _namespaceNames; - private readonly ConcurrentDictionary> _commandsByNamespace = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> _cachedLearnToolsByNamespace = new(StringComparer.OrdinalIgnoreCase); + private readonly Lazy> _availableNamespaces = new Lazy>(() => + { + return commandFactory.RootGroup.SubGroup + .Where(group => !IgnoreCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => options.Value.Namespace == null || + options.Value.Namespace.Length == 0 || + options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Select(group => group.Name) + .ToList(); + }); + + private static readonly List IgnoreCommandGroups = ["extension", "server", "tools"]; + private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase); + private ListToolsResult? _cachedListToolsResult; private const string ToolCallProxySchema = """ { @@ -77,64 +89,75 @@ public sealed class NamespaceToolLoader : BaseToolLoader } """, ServerJsonContext.Default.JsonElement); - public NamespaceToolLoader( - CommandFactory commandFactory, - IOptions options, - IServiceProvider serviceProvider, - ILogger logger) : base(logger) + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) { - _commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); - _options = options ?? throw new ArgumentNullException(nameof(options)); - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - - _namespaceNames = GetFilteredNamespaceNames(); - _cachedNamespaceTools = new Lazy>(() => - _namespaceNames - .Select(ns => CreateNamespaceTool(ns)) - .ToList()); - } + if (_cachedListToolsResult != null) + { + return ValueTask.FromResult(_cachedListToolsResult); + } - public override ValueTask ListToolsHandler( - RequestContext request, - CancellationToken cancellationToken) - { - var tools = _cachedNamespaceTools.Value; - return ValueTask.FromResult(new ListToolsResult { Tools = tools }); + var namespaces = _availableNamespaces.Value; + var allToolsResponse = new ListToolsResult + { + Tools = new List() + }; + + foreach (var namespaceName in namespaces) + { + var group = _commandFactory.RootGroup.SubGroup + .First(g => string.Equals(g.Name, namespaceName, StringComparison.OrdinalIgnoreCase)); + + var tool = new Tool + { + Name = namespaceName, + Description = group.Description + """ + This tool is a hierarchical MCP command router. + Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. + To invoke a command, set "command" and wrap its args in "parameters". + Set "learn=true" to discover available sub commands. + """, + InputSchema = ToolSchema, + }; + + allToolsResponse.Tools.Add(tool); + } + + // Cache the result + _cachedListToolsResult = allToolsResponse; + return ValueTask.FromResult(allToolsResponse); } - /// - /// Handles tool calls for namespace tools. Supports both learn mode (discovery) and - /// command execution mode (direct command invocation). - /// - public override async ValueTask CallToolHandler( - RequestContext request, - CancellationToken cancellationToken) + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Params?.Name)) { throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); } - var toolName = request.Params.Name; + string tool = request.Params.Name; + var args = request.Params?.Arguments; + string? intent = null; + string? command = null; + bool learn = false; - // Validate namespace exists - if (!_namespaceNames.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + if (args != null) { - return new CallToolResult + if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) { - Content = [new TextContentBlock - { - Text = $"Namespace '{toolName}' not found. Available namespaces: {string.Join(", ", _namespaceNames)}" - }], - IsError = true - }; + intent = intentElem.GetString(); + } + if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) + { + learn = true; + } + if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) + { + command = commandElem.GetString(); + } } - var args = request.Params.Arguments; - var (intent, command, parameters, learn) = ParseHierarchicalCall(args); if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command)) { - // Auto-learn if intent provided but no command specified learn = true; } @@ -142,141 +165,122 @@ public override async ValueTask CallToolHandler( { if (learn && string.IsNullOrEmpty(command)) { - // Learn mode: Return available commands for this namespace - return await HandleLearnRequest(request, intent ?? "", toolName, cancellationToken); + return await InvokeToolLearn(request, intent ?? "", tool, cancellationToken); } - else if (!string.IsNullOrEmpty(toolName) && !string.IsNullOrEmpty(command)) + else if (!string.IsNullOrEmpty(tool) && !string.IsNullOrEmpty(command)) { - // Execution mode: Execute the specified command - return await ExecuteNamespaceCommand(request, intent ?? "", toolName, command, parameters, cancellationToken); + var toolParams = GetParametersFromArgs(args); + return await InvokeChildToolAsync(request, intent ?? "", tool, command, toolParams, cancellationToken); } } catch (KeyNotFoundException ex) { - _logger.LogError(ex, "Key not found while calling namespace tool: {Tool}", toolName); + _logger.LogError(ex, "Key not found while calling tool: {Tool}", tool); return new CallToolResult { - Content = [new TextContentBlock - { - Text = $""" - The tool '{toolName}.{command}' was not found or does not support the specified command. - Please ensure the tool name and command are correct. - If you want to learn about available tools, run again with the "learn=true" argument. + Content = + [ + new TextContentBlock { + Text = $""" + The tool '{tool}.{command}' was not found or does not support the specified command. + Please ensure the tool name and command are correct. + If you want to learn about available tools, run again with the "learn=true" argument. """ - }], + } + ], IsError = true }; } return new CallToolResult { - Content = [new TextContentBlock - { - Text = """ - The "command" parameter is required when not learning. - Run again with the "learn" argument to get a list of available tools and their parameters. - To learn about a specific tool, use the "tool" argument with the name of the tool. + Content = + [ + new TextContentBlock { + Text = """ + The "command" parameters are required when not learning + Run again with the "learn" argument to get a list of available tools and their parameters. + To learn about a specific tool, use the "tool" argument with the name of the tool. """ - }], + } + ], IsError = false }; } - /// - /// Handles learn requests for a namespace, returning available commands with their schemas. - /// Uses caching to avoid rebuilding tool definitions on repeated requests. - /// - private async Task HandleLearnRequest( + private async Task InvokeChildToolAsync( RequestContext request, - string intent, - string nameSpace, + string? intent, + string namespaceName, + string command, + IReadOnlyDictionary parameters, CancellationToken cancellationToken) { - if (_cachedLearnToolsByNamespace.TryGetValue(nameSpace, out var cachedTools)) + if (request.Params == null) { - var cachedJson = JsonSerializer.Serialize(cachedTools, ServerJsonContext.Default.ListTool); - return CreateLearnResponse(nameSpace, cachedJson); - } + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; - // Build tools for this namespace (lazy load if not cached) - var namespaceCommands = GetOrLoadNamespaceCommands(nameSpace); - var tools = namespaceCommands - .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) - .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) - .ToList(); + _logger.LogWarning(content.Text); - // Cache tools for future learn requests - _cachedLearnToolsByNamespace[nameSpace] = tools; + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } - // If client supports sampling and intent is provided, try to infer command - if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + IReadOnlyDictionary namespaceCommands; + try { - var (commandName, parameters) = await GetCommandAndParametersFromIntentAsync( - request, intent, nameSpace, tools, cancellationToken); - - if (commandName != null) + namespaceCommands = _commandFactory.GroupCommands([namespaceName]); + if (namespaceCommands == null || namespaceCommands.Count == 0) { - return await ExecuteNamespaceCommand(request, intent, nameSpace, commandName, parameters, cancellationToken); + _logger.LogError("Failed to get commands for namespace: {Namespace}", namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); } } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while getting commands for namespace: {Namespace}", namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } - var toolsJson = JsonSerializer.Serialize(tools, ServerJsonContext.Default.ListTool); - return CreateLearnResponse(nameSpace, toolsJson); - } - - /// - /// Executes a command within a namespace. - /// - private async Task ExecuteNamespaceCommand( - RequestContext request, - string intent, - string nameSpace, - string command, - IReadOnlyDictionary parameters, - CancellationToken cancellationToken) - { - var namespaceCommands = GetOrLoadNamespaceCommands(nameSpace); - - // Try to find the command - handle both "command" and "namespace command" formats - if (!namespaceCommands.TryGetValue(command, out var cmd)) + try { - var fullCommandName = $"{nameSpace} {command}"; - if (!namespaceCommands.TryGetValue(fullCommandName, out cmd)) - { - _logger.LogWarning("Namespace {Namespace} does not have a command {Command}.", nameSpace, command); + var availableTools = await GetChildToolListAsync(request, namespaceName); + // When the specified command is not available, we try to learn about the tool's capabilities + // and infer the command and parameters from the users intent. + if (!availableTools.Any(t => string.Equals(t.Name, command, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning("Namespace {Namespace} does not have a command {Command}.", namespaceName, command); if (string.IsNullOrWhiteSpace(intent)) { - return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); } - var tools = _cachedLearnToolsByNamespace.GetValueOrDefault(nameSpace) - ?? GetToolsForNamespace(nameSpace); - - var samplingResult = await GetCommandAndParametersFromIntentAsync( - request, intent, nameSpace, tools, cancellationToken); - + var samplingResult = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); if (string.IsNullOrWhiteSpace(samplingResult.commandName)) { - return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); + return await InvokeToolLearn(request, intent ?? "", namespaceName, cancellationToken); } command = samplingResult.commandName; parameters = samplingResult.parameters; - - if (!namespaceCommands.TryGetValue(command, out cmd)) - { - return await HandleLearnRequest(request, intent, nameSpace, cancellationToken); - } } - } - try - { - await NotifyProgressAsync(request, $"Calling {nameSpace} {command}...", cancellationToken); + await NotifyProgressAsync(request, $"Calling {namespaceName} {command}...", cancellationToken); + + if (!namespaceCommands.TryGetValue(command, out var cmd)) + { + _logger.LogError("Command {Command} found in tools but missing from namespace {Namespace} commands.", command, namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } - // Direct execution (same as CommandFactoryToolLoader) var commandContext = new CommandContext(_serviceProvider, Activity.Current); var realCommand = cmd.GetCommand(); @@ -290,46 +294,42 @@ private async Task ExecuteNamespaceCommand( commandOptions = realCommand.ParseFromDictionary(parameters); } - _logger.LogTrace("Executing namespace command '{Namespace} {Command}'", nameSpace, command); + _logger.LogTrace("Executing namespace command '{Namespace} {Command}'", namespaceName, command); var commandResponse = await cmd.ExecuteAsync(commandContext, commandOptions); - - // Check if command requires missing parameters var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse); var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous; if (jsonResponse.Contains("Missing required options", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("Namespace command '{Namespace} {Command}' requires additional parameters.", nameSpace, command); - - var commandTool = GetCommandTool(nameSpace, command); - var commandToolJson = JsonSerializer.Serialize(commandTool, ServerJsonContext.Default.Tool); + var childToolSpecJson = await GetChildToolJsonAsync(request, namespaceName, command); - return new CallToolResult + _logger.LogWarning("Namespace {Namespace} command {Command} requires additional parameters.", namespaceName, command); + var finalResponse = new CallToolResult { Content = [ - new TextContentBlock - { - Text = $""" - The '{command}' command is missing required parameters. - - - Review the following command spec and identify the required arguments from the input schema. - - Omit any arguments that are not required or do not apply to your use case. - - Wrap all command arguments into the root "parameters" argument. - - If required data is missing infer the data from your context or prompt the user as needed. - - Run the tool again with the "command" and root "parameters" object. - - Command Spec: - {commandToolJson} - - Original Error: - {jsonResponse} - """ - } + new TextContentBlock { + Text = $""" + The '{command}' command is missing required parameters. + + - Review the following command spec and identify the required arguments from the input schema. + - Omit any arguments that are not required or do not apply to your use case. + - Wrap all command arguments into the root "parameters" argument. + - If required data is missing infer the data from your context or prompt the user as needed. + - Run the tool again with the "command" and root "parameters" object. + + Command Spec: + {childToolSpecJson} + """ + } ], IsError = true }; + + // Add original response content + finalResponse.Content.Add(new TextContentBlock { Text = jsonResponse }); + return finalResponse; } return new CallToolResult @@ -340,67 +340,115 @@ private async Task ExecuteNamespaceCommand( } catch (Exception ex) { - _logger.LogError(ex, "Exception thrown while calling namespace tool: {Namespace}, command: {Command}", nameSpace, command); + _logger.LogError(ex, "Exception thrown while calling namespace: {Namespace}, command: {Command}", namespaceName, command); return new CallToolResult { - Content = [new TextContentBlock - { - Text = $""" - There was an error finding or calling tool and command. - Failed to call namespace: {nameSpace}, command: {command} - Error: {ex.Message} + Content = + [ + new TextContentBlock { + Text = $""" + There was an error finding or calling tool and command. + Failed to call namespace: {namespaceName}, command: {command} + Error: {ex.Message} - Run again with the "learn=true" to get a list of available commands and their parameters. - """ - }], - IsError = true + Run again with the "learn=true" to get a list of available commands and their parameters. + """ + } + ] }; } } - /// - /// Gets or lazily loads commands for a specific namespace. - /// Commands are only loaded when first accessed, improving startup time and memory usage. - /// - private IReadOnlyDictionary GetOrLoadNamespaceCommands(string nameSpace) + private async Task InvokeToolLearn(RequestContext request, string? intent, string namespaceName, CancellationToken cancellationToken) { - return _commandsByNamespace.GetOrAdd(nameSpace, ns => _commandFactory.GroupCommands([ns])); + var toolsJson = await GetChildToolListJsonAsync(request, namespaceName); + + var learnResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + Here are the available command and their parameters for '{namespaceName}' tool. + If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters. + Next, identify the command you want to execute and run again with the "command" and "parameters" arguments. + + {toolsJson} + """ + } + ], + IsError = false + }; + var response = learnResponse; + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var availableTools = await GetChildToolListAsync(request, namespaceName); + (string? commandName, IReadOnlyDictionary parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); + if (commandName != null) + { + response = await InvokeChildToolAsync(request, intent, namespaceName, commandName, parameters, cancellationToken); + } + } + return response; } /// - /// Gets filtered namespace names. + /// Gets the available tools from the namespace commands and caches the result for subsequent requests. /// - private IReadOnlyList GetFilteredNamespaceNames() + private async Task> GetChildToolListAsync(RequestContext request, string namespaceName) { - return _commandFactory.RootGroup.SubGroup - .Where(group => !IgnoreCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) - .Where(group => _options.Value.Namespace == null || - _options.Value.Namespace.Length == 0 || - _options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) - .Select(group => group.Name) + // Check cache first + if (_cachedToolLists.TryGetValue(namespaceName, out var cachedList)) + { + return cachedList; + } + + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + var availableNamespaces = _availableNamespaces.Value; + if (!availableNamespaces.Any(ns => string.Equals(ns, namespaceName, StringComparison.OrdinalIgnoreCase))) + { + var availableList = string.Join(", ", availableNamespaces); + throw new KeyNotFoundException($"The namespace '{namespaceName}' was not found. Available namespaces: {availableList}"); + } + + var namespaceCommands = _commandFactory.GroupCommands([namespaceName]); + if (namespaceCommands == null) + { + _logger.LogWarning("No commands found for namespace: {Namespace}", namespaceName); + return []; + } + + var list = namespaceCommands + .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) + .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) .ToList(); + + // Cache for subsequent requests + _cachedToolLists[namespaceName] = list; + + return await ValueTask.FromResult(list); } - /// - /// Creates a hierarchical namespace tool with learn capabilities. - /// - private Tool CreateNamespaceTool(string nameSpace) + private async Task GetChildToolListJsonAsync(RequestContext request, string namespaceName) { - var group = _commandFactory.RootGroup.SubGroup - .First(g => string.Equals(g.Name, nameSpace, StringComparison.OrdinalIgnoreCase)); - var description = group.Description; - - return new Tool - { - Name = nameSpace, - Description = description + """ - This tool is a hierarchical MCP command router. - Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. - To invoke a command, set "command" and wrap its args in "parameters". - Set "learn=true" to discover available sub commands. - """, - InputSchema = ToolSchema - }; + var listTools = await GetChildToolListAsync(request, namespaceName); + return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool); + } + + private async Task GetChildToolAsync(RequestContext request, string namespaceName, string commandName) + { + var tools = await GetChildToolListAsync(request, namespaceName); + return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task GetChildToolJsonAsync(RequestContext request, string namespaceName, string commandName) + { + var tool = await GetChildToolAsync(request, namespaceName, commandName); + return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool); } /// @@ -456,46 +504,6 @@ private static Tool CreateToolFromCommand(string fullName, IBaseCommand command) return tool; } - /// - /// Parses hierarchical call structure from MCP tool arguments. - /// - private static (string? intent, string? command, IReadOnlyDictionary parameters, bool learn) ParseHierarchicalCall( - IReadOnlyDictionary? args) - { - if (args == null) - { - return (null, null, new Dictionary(), false); - } - - string? intent = null; - string? command = null; - bool learn = false; - IReadOnlyDictionary parameters = new Dictionary(); - - if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) - { - intent = intentElem.GetString(); - } - - if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) - { - learn = true; - } - - if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) - { - command = commandElem.GetString(); - } - - if (args.TryGetValue("parameters", out var paramsElem) && paramsElem.ValueKind == JsonValueKind.Object) - { - parameters = paramsElem.EnumerateObject() - .ToDictionary(prop => prop.Name, prop => prop.Value); - } - - return (intent, command, parameters, learn); - } - private static bool IsRawMcpToolInputOption(Option option) { const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; @@ -508,39 +516,20 @@ private static bool IsRawMcpToolInputOption(Option option) string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)); } - private CallToolResult CreateLearnResponse(string nameSpace, string toolsJson) + private static IReadOnlyDictionary GetParametersFromArgs(IReadOnlyDictionary? args) { - return new CallToolResult + if (args == null || !args.TryGetValue("parameters", out var paramsElem)) { - Content = [new TextContentBlock - { - Text = $""" - Here are the available command and their parameters for '{nameSpace}' tool. - If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters. - Next, identify the command you want to execute and run again with the "command" and "parameters" arguments. - - {toolsJson} - """ - }], - IsError = false - }; - } - - private List GetToolsForNamespace(string nameSpace) - { - var namespaceCommands = GetOrLoadNamespaceCommands(nameSpace); - return namespaceCommands - .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) - .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) - .ToList(); - } + return new Dictionary(); + } - private Tool GetCommandTool(string nameSpace, string commandName) - { - var tools = _cachedLearnToolsByNamespace.GetValueOrDefault(nameSpace) - ?? GetToolsForNamespace(nameSpace); + if (paramsElem.ValueKind == JsonValueKind.Object) + { + return paramsElem.EnumerateObject() + .ToDictionary(prop => prop.Name, prop => prop.Value); + } - return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); + return new Dictionary(); } private static bool SupportsSampling(McpServer server) @@ -567,11 +556,11 @@ await request.Server.NotifyProgressAsync(progressToken.Value, private async Task<(string? commandName, IReadOnlyDictionary parameters)> GetCommandAndParametersFromIntentAsync( RequestContext request, string intent, - string nameSpace, + string namespaceName, List availableTools, CancellationToken cancellationToken) { - await NotifyProgressAsync(request, $"Learning about {nameSpace} capabilities...", cancellationToken); + await NotifyProgressAsync(request, $"Learning about {namespaceName} capabilities...", cancellationToken); JsonElement toolParams = GetParametersJsonElement(request); var toolParamsJson = toolParams.GetRawText(); @@ -583,10 +572,9 @@ await request.Server.NotifyProgressAsync(progressToken.Value, new SamplingMessage { Role = Role.Assistant, - Content = new TextContentBlock - { + Content = new TextContentBlock{ Text = $""" - This is a list of available commands for the {nameSpace} server. + This is a list of available commands for the {namespaceName} server. Your task: - Select the single command that best matches the user's intent. @@ -612,7 +600,6 @@ await request.Server.NotifyProgressAsync(progressToken.Value, } ], }; - try { var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); @@ -642,7 +629,7 @@ await request.Server.NotifyProgressAsync(progressToken.Value, } catch { - _logger.LogError("Failed to get command and parameters from intent: {Intent} for namespace: {Namespace}", intent, nameSpace); + _logger.LogError("Failed to get command and parameters from intent: {Intent} for namespace: {Namespace}", intent, namespaceName); } return (null, new Dictionary()); @@ -654,7 +641,7 @@ await request.Server.NotifyProgressAsync(progressToken.Value, /// protected override async ValueTask DisposeAsyncCore() { - _cachedLearnToolsByNamespace.Clear(); + _cachedToolLists.Clear(); await ValueTask.CompletedTask; } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs index 717622c9b8..ab9560a586 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -232,7 +232,6 @@ public async Task CallToolHandler_WithInvalidNamespace_ReturnsError() var textContent = result.Content[0] as TextContentBlock; Assert.NotNull(textContent); Assert.Contains("not found", textContent.Text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Available namespaces", textContent.Text, StringComparison.OrdinalIgnoreCase); } [Fact] From dcde3388939ab6e137576524a0951efb45bf10f3 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Tue, 7 Oct 2025 10:49:44 -0700 Subject: [PATCH 6/8] align command exclusion behavior with ServerToolLoader --- .../Server/Commands/ToolLoading/NamespaceToolLoader.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index 69aabad5f0..691fd038bb 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Net; using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Commands; @@ -33,7 +34,7 @@ public sealed class NamespaceToolLoader( private readonly Lazy> _availableNamespaces = new Lazy>(() => { return commandFactory.RootGroup.SubGroup - .Where(group => !IgnoreCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) .Where(group => options.Value.Namespace == null || options.Value.Namespace.Length == 0 || options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) @@ -41,7 +42,6 @@ public sealed class NamespaceToolLoader( .ToList(); }); - private static readonly List IgnoreCommandGroups = ["extension", "server", "tools"]; private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase); private ListToolsResult? _cachedListToolsResult; @@ -408,10 +408,10 @@ private async Task> GetChildToolListAsync(RequestContext string.Equals(ns, namespaceName, StringComparison.OrdinalIgnoreCase))) + var namespaces = _availableNamespaces.Value; + if (!namespaces.Any(ns => string.Equals(ns, namespaceName, StringComparison.OrdinalIgnoreCase))) { - var availableList = string.Join(", ", availableNamespaces); + var availableList = string.Join(", ", namespaces); throw new KeyNotFoundException($"The namespace '{namespaceName}' was not found. Available namespaces: {availableList}"); } From 18e49be0c894547ab07a5ab2881e15e912ebbf19 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Tue, 7 Oct 2025 11:33:59 -0700 Subject: [PATCH 7/8] simplify methods (remove unnecessary async attributes) --- .../ToolLoading/NamespaceToolLoader.cs | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs index 691fd038bb..351de2660c 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -251,7 +251,7 @@ private async Task InvokeChildToolAsync( try { - var availableTools = await GetChildToolListAsync(request, namespaceName); + var availableTools = GetChildToolList(request, namespaceName); // When the specified command is not available, we try to learn about the tool's capabilities // and infer the command and parameters from the users intent. @@ -302,7 +302,7 @@ private async Task InvokeChildToolAsync( if (jsonResponse.Contains("Missing required options", StringComparison.OrdinalIgnoreCase)) { - var childToolSpecJson = await GetChildToolJsonAsync(request, namespaceName, command); + var childToolSpecJson = GetChildToolJson(request, namespaceName, command); _logger.LogWarning("Namespace {Namespace} command {Command} requires additional parameters.", namespaceName, command); var finalResponse = new CallToolResult @@ -361,7 +361,7 @@ There was an error finding or calling tool and command. private async Task InvokeToolLearn(RequestContext request, string? intent, string namespaceName, CancellationToken cancellationToken) { - var toolsJson = await GetChildToolListJsonAsync(request, namespaceName); + var toolsJson = GetChildToolListJson(request, namespaceName); var learnResponse = new CallToolResult { @@ -382,7 +382,7 @@ private async Task InvokeToolLearn(RequestContext parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); if (commandName != null) { @@ -395,7 +395,7 @@ private async Task InvokeToolLearn(RequestContext /// Gets the available tools from the namespace commands and caches the result for subsequent requests. /// - private async Task> GetChildToolListAsync(RequestContext request, string namespaceName) + private List GetChildToolList(RequestContext request, string namespaceName) { // Check cache first if (_cachedToolLists.TryGetValue(namespaceName, out var cachedList)) @@ -430,24 +430,19 @@ private async Task> GetChildToolListAsync(RequestContext GetChildToolListJsonAsync(RequestContext request, string namespaceName) + private string GetChildToolListJson(RequestContext request, string namespaceName) { - var listTools = await GetChildToolListAsync(request, namespaceName); + var listTools = GetChildToolList(request, namespaceName); return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool); } - private async Task GetChildToolAsync(RequestContext request, string namespaceName, string commandName) + private string GetChildToolJson(RequestContext request, string namespaceName, string commandName) { - var tools = await GetChildToolListAsync(request, namespaceName); - return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); - } - - private async Task GetChildToolJsonAsync(RequestContext request, string namespaceName, string commandName) - { - var tool = await GetChildToolAsync(request, namespaceName, commandName); + var tools = GetChildToolList(request, namespaceName); + var tool = tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool); } @@ -639,9 +634,9 @@ await request.Server.NotifyProgressAsync(progressToken.Value, /// Disposes resources owned by this tool loader. /// Clears the cached tool lists dictionary. /// - protected override async ValueTask DisposeAsyncCore() + protected override ValueTask DisposeAsyncCore() { _cachedToolLists.Clear(); - await ValueTask.CompletedTask; + return ValueTask.CompletedTask; } } From a4d949b035f8ff731249ac89092e0cce07f0cd85 Mon Sep 17 00:00:00 2001 From: anuchandy Date: Tue, 7 Oct 2025 11:36:06 -0700 Subject: [PATCH 8/8] adjust tests to adapt exclusion behavior --- .../Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs index ab9560a586..b533f3ca65 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -448,7 +448,7 @@ public async Task DisposeAsync_ClearsCaches() private string GetFirstAvailableNamespace() { var namespaces = _commandFactory.RootGroup.SubGroup - .Where(g => g.Name != "extension" && g.Name != "server" && g.Name != "tools") + .Where(g => !Azure.Mcp.Core.Areas.Server.Commands.Discovery.DiscoveryConstants.IgnoredCommandGroups.Contains(g.Name, StringComparer.OrdinalIgnoreCase)) .Select(g => g.Name) .ToList();