diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 3e7ade6075..95c53d1d28 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -5,79 +5,383 @@ using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; +using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using static Azure.DataApiBuilder.Mcp.Model.McpEnums; namespace Azure.DataApiBuilder.Mcp.BuiltInTools { + /// + /// Tool to describe all entities configured in DAB, including their types and metadata. + /// public class DescribeEntitiesTool : IMcpTool { + /// + /// Gets the type of the tool, which is BuiltIn for this implementation. + /// public ToolType ToolType { get; } = ToolType.BuiltIn; + /// + /// Gets the metadata for the delete-record tool, including its name, description, and input schema. + /// + /// public Tool GetToolMetadata() { return new Tool { - Name = "describe-entities", - Description = "Lists and describes all entities in the database." + Name = "describe_entities", + Description = "Lists and describes all entities in the database, including their types and available operations.", + InputSchema = JsonSerializer.Deserialize( + @"{ + ""type"": ""object"", + ""properties"": { + ""nameOnly"": { + ""type"": ""boolean"", + ""description"": ""If true, only entity names and descriptions will be returned. If false, full metadata including fields, parameters etc. will be included. Default is false."" + }, + ""entities"": { + ""type"": ""array"", + ""items"": { + ""type"": ""string"" + }, + ""description"": ""Optional list of specific entity names to filter by. If empty, all entities will be described."" + } + } + }" + ) }; } + /// + /// Executes the DescribeEntities tool, returning metadata about configured entities. + /// public Task ExecuteAsync( JsonDocument? arguments, IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { + ILogger? logger = serviceProvider.GetService>(); + try { - // Get the runtime config provider - RuntimeConfigProvider? runtimeConfigProvider = serviceProvider.GetService(); - if (runtimeConfigProvider == null || !runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + cancellationToken.ThrowIfCancellationRequested(); + + RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); + RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); + + if (!IsToolEnabled(runtimeConfig)) { - return Task.FromResult(new CallToolResult - { - Content = [new TextContentBlock { Type = "text", Text = "Error: Runtime configuration not available." }] - }); + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "ToolDisabled", + $"The {GetToolMetadata().Name} tool is disabled in the configuration.", + logger)); } - // Extract entity information from the runtime config - Dictionary entities = new(); + (bool nameOnly, HashSet? entityFilter) = ParseArguments(arguments, logger); + + List> entityList = new(); if (runtimeConfig.Entities != null) { - foreach (KeyValuePair entity in runtimeConfig.Entities) + foreach (KeyValuePair entityEntry in runtimeConfig.Entities) { - entities[entity.Key] = new + cancellationToken.ThrowIfCancellationRequested(); + + string entityName = entityEntry.Key; + Entity entity = entityEntry.Value; + + if (!ShouldIncludeEntity(entityName, entityFilter)) { - source = entity.Value.Source, - permissions = entity.Value.Permissions?.Select(p => new - { - role = p.Role, - actions = p.Actions - }) - }; + continue; + } + + try + { + Dictionary entityInfo = nameOnly + ? BuildBasicEntityInfo(entityName, entity) + : BuildFullEntityInfo(entityName, entity); + + entityList.Add(entityInfo); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to build info for entity {EntityName}", entityName); + } + } + } + + if (entityList.Count == 0) + { + if (entityFilter != null && entityFilter.Count > 0) + { + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "EntitiesNotFound", + $"No entities found matching the filter: {string.Join(", ", entityFilter)}", + logger)); + } + else + { + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "NoEntitiesConfigured", + "No entities are configured in the runtime configuration.", + logger)); } } - string entitiesJson = JsonSerializer.Serialize(entities, new JsonSerializerOptions + cancellationToken.ThrowIfCancellationRequested(); + + entityList = entityList.OrderBy(e => e["name"]?.ToString() ?? string.Empty).ToList(); + + List finalEntityList = entityList.Cast().ToList(); + + Dictionary responseData = new() { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); + ["entities"] = finalEntityList, + ["count"] = finalEntityList.Count, + ["mode"] = nameOnly ? "basic" : "full" + }; - return Task.FromResult(new CallToolResult + if (entityFilter != null && entityFilter.Count > 0) { - Content = [new TextContentBlock { Type = "application/json", Text = entitiesJson }] - }); + responseData["filter"] = entityFilter.ToArray(); + } + + logger?.LogInformation( + "DescribeEntitiesTool returned {EntityCount} entities in {Mode} mode.", + finalEntityList.Count, + nameOnly ? "basic" : "full"); + + return Task.FromResult(McpResponseBuilder.BuildSuccessResult( + responseData, + logger, + $"DescribeEntitiesTool success: {finalEntityList.Count} entities returned.")); + } + catch (OperationCanceledException) + { + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "OperationCanceled", + "The describe operation was canceled.", + logger)); + } + catch (DataApiBuilderException dabEx) + { + logger?.LogError(dabEx, "Data API Builder error in DescribeEntitiesTool"); + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "DataApiBuilderError", + dabEx.Message, + logger)); + } + catch (ArgumentException argEx) + { + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "InvalidArguments", + argEx.Message, + logger)); + } + catch (InvalidOperationException ioEx) + { + logger?.LogError(ioEx, "Invalid operation in DescribeEntitiesTool"); + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "InvalidOperation", + "Failed to retrieve entity metadata: " + ioEx.Message, + logger)); } catch (Exception ex) { - return Task.FromResult(new CallToolResult + logger?.LogError(ex, "Unexpected error in DescribeEntitiesTool"); + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + "UnexpectedError", + "An unexpected error occurred while describing entities.", + logger)); + } + } + + /// + /// Determines whether the tool is enabled based on the specified runtime configuration. + /// + /// The runtime configuration to evaluate. Must not be null. + /// if the tool is enabled and the DescribeEntities property of McpDmlTools + /// is set to ; otherwise, . + private static bool IsToolEnabled(RuntimeConfig runtimeConfig) + { + return runtimeConfig.McpDmlTools?.DescribeEntities == true; + } + + /// + /// Parses the input arguments to extract the 'nameOnly' flag and the optional entity filter list. + /// + /// The arguments to parse + /// The logger + /// A tuple containing the parsed 'nameOnly' flag and the optional entity filter list. + private static (bool nameOnly, HashSet? entityFilter) ParseArguments(JsonDocument? arguments, ILogger? logger) + { + bool nameOnly = false; + HashSet? entityFilter = null; + + if (arguments?.RootElement.ValueKind == JsonValueKind.Object) + { + if (arguments.RootElement.TryGetProperty("nameOnly", out JsonElement nameOnlyElement)) { - Content = [new TextContentBlock { Type = "text", Text = $"Error: {ex.Message}" }] - }); + if (nameOnlyElement.ValueKind == JsonValueKind.True || nameOnlyElement.ValueKind == JsonValueKind.False) + { + nameOnly = nameOnlyElement.GetBoolean(); + } + } + + if (arguments.RootElement.TryGetProperty("entities", out JsonElement entitiesElement) && + entitiesElement.ValueKind == JsonValueKind.Array) + { + entityFilter = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (JsonElement entityElement in entitiesElement.EnumerateArray()) + { + if (entityElement.ValueKind == JsonValueKind.String) + { + string? entityName = entityElement.GetString(); + if (!string.IsNullOrWhiteSpace(entityName)) + { + entityFilter.Add(entityName); + } + } + } + + if (entityFilter.Count == 0) + { + entityFilter = null; + } + } + } + + logger?.LogDebug("Parsed arguments - nameOnly: {NameOnly}, entityFilter: {EntityFilter}", + nameOnly, entityFilter != null ? string.Join(", ", entityFilter) : "none"); + + return (nameOnly, entityFilter); + } + + /// + /// Determines whether the specified entity should be included based on the provided entity filter. + /// + /// The name of the entity to evaluate. + /// A set of entity names to include. If or empty, all entities are included. + /// if the entity should be included; otherwise, . + private static bool ShouldIncludeEntity(string entityName, HashSet? entityFilter) + { + return entityFilter == null || entityFilter.Count == 0 || entityFilter.Contains(entityName); + } + + /// + /// Creates a dictionary containing basic information about an entity. + /// + /// The name of the entity to include in the dictionary. + /// The entity object from which to extract additional information. + /// A dictionary with two keys: "name", containing the entity name, and "description", containing the entity's + /// description or an empty string if the description is null. + private static Dictionary BuildBasicEntityInfo(string entityName, Entity entity) + { + return new Dictionary + { + ["name"] = entityName, + ["description"] = entity.Description ?? string.Empty + }; + } + + /// + /// Builds full entity info: name, description, fields, parameters (for stored procs), permissions. + /// + private static Dictionary BuildFullEntityInfo(string entityName, Entity entity) + { + Dictionary info = new() + { + ["name"] = entityName, + ["description"] = entity.Description ?? string.Empty, + ["fields"] = BuildFieldMetadataInfo(entity.Fields), + }; + + if (entity.Source.Type == EntitySourceType.StoredProcedure) + { + info["parameters"] = BuildParameterMetadataInfo(entity.Source.Parameters); + } + + info["permissions"] = BuildPermissionsInfo(entity); + + return info; + } + + /// + /// Builds a list of metadata information objects from the provided collection of fields. + /// + /// A list of objects representing the fields to process. Can be null. + /// A list of objects, each containing the name and description of a field. If is + /// null, an empty list is returned. + private static List BuildFieldMetadataInfo(List? fields) + { + List result = new(); + + if (fields != null) + { + foreach (FieldMetadata field in fields) + { + result.Add(new + { + name = field.Name, + description = field.Description ?? string.Empty + }); + } } + + return result; + } + + /// + /// Builds a list of parameter metadata objects containing information about each parameter. + /// + /// A list of objects representing the parameters to process. Can be null. + /// A list of anonymous objects, each containing the parameter's name, whether it is required, its default + /// value, and its description. Returns an empty list if is null. + private static List BuildParameterMetadataInfo(List? parameters) + { + List result = new(); + + if (parameters != null) + { + foreach (ParameterMetadata param in parameters) + { + result.Add(new + { + name = param.Name, + required = param.Default == null, // required if no default + @default = param.Default, + description = param.Description ?? string.Empty + }); + } + } + + return result; + } + + /// + /// Build a list of permission metadata info + /// + /// The entity object + /// A list of permissions available to the entity + private static string[] BuildPermissionsInfo(Entity entity) + { + HashSet permissions = new(); + + if (entity.Permissions != null) + { + foreach (EntityPermission permission in entity.Permissions) + { + foreach (EntityAction action in permission.Actions) + { + permissions.Add(action.Action.ToString().ToUpperInvariant()); + } + } + } + + return permissions.OrderBy(p => p).ToArray(); } } }