// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using Azure.DataApiBuilder.Auth;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Mcp.Model;
using Azure.DataApiBuilder.Mcp.Utils;
using Azure.DataApiBuilder.Service.Exceptions;
using Microsoft.AspNetCore.Http;
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;
public bool IsEnabled(RuntimeConfig config) => config.McpDmlTools?.DescribeEntities ?? true;
///
/// Gets the metadata for the describe-entities tool, including its name, description, and input schema.
///
///
public Tool GetToolMetadata()
{
return new Tool
{
Name = "describe_entities",
Description = "Lists all entities and metadata. ALWAYS CALL FIRST. Each entity includes: name, type, fields, parameters, and permissions. The permissions array defines which tools are allowed. 'ALL' expands by type: data->CREATE, READ, UPDATE, DELETE.",
InputSchema = JsonSerializer.Deserialize(
@"{
""type"": ""object"",
""properties"": {
""nameOnly"": {
""type"": ""boolean"",
""description"": ""If true, the response includes only entity names and short summaries, omitting detailed metadata such as fields, parameters, and permissions. Use this when the database contains many entities and the full payload would be too large. The usual strategy is: first call describe_entities with nameOnly=true to get a lightweight list, then call describe_entities again with nameOnly=false for specific entities that require full metadata. This flag is meant for discovery, not execution planning. The model must not assume that nameOnly=true provides enough detail for CRUD or EXECUTE operations.""
},
""entities"": {
""type"": ""array"",
""items"": {
""type"": ""string""
},
""description"": ""Optional list of entity names to describe in full detail. Use this to reduce payload size when only certain entities are relevant. Do NOT pass both entities[] and nameOnly=true together, as that combination is nonsensical: nameOnly=true ignores detailed metadata, while entities[] explicitly requests it. Choose one approach—broad discovery with nameOnly=true OR targeted metadata with entities[].""
}
}
}"
)
};
}
///
/// Executes the DescribeEntities tool, returning metadata about configured entities.
///
public Task ExecuteAsync(
JsonDocument? arguments,
IServiceProvider serviceProvider,
CancellationToken cancellationToken = default)
{
ILogger? logger = serviceProvider.GetService>();
string toolName = GetToolMetadata().Name;
try
{
cancellationToken.ThrowIfCancellationRequested();
RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService();
RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
if (!IsToolEnabled(runtimeConfig))
{
return Task.FromResult(McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger));
}
// Get authorization services to determine current user's role
IAuthorizationResolver authResolver = serviceProvider.GetRequiredService();
IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService();
HttpContext? httpContext = httpContextAccessor.HttpContext;
// Get current user's role for permission filtering
// For discovery tools like describe_entities, we use the first valid role from the header
// This differs from operation-specific tools that check permissions per entity per operation
string? currentUserRole = null;
if (httpContext != null && authResolver.IsValidRoleContext(httpContext))
{
string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString();
if (!string.IsNullOrWhiteSpace(roleHeader))
{
string[] roles = roleHeader
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (roles.Length > 1)
{
logger?.LogWarning("Multiple roles detected in request header: [{Roles}]. Using first role '{FirstRole}' for entity discovery. " +
"Consider using a single role for consistent permission reporting.",
string.Join(", ", roles), roles[0]);
}
// For discovery operations, take the first role from comma-separated list
// This provides a consistent view of available entities for the primary role
currentUserRole = roles.FirstOrDefault();
}
}
// Get current user's role for permission filtering
// For discovery tools like describe_entities, we use the first valid role from the header
// This differs from operation-specific tools that check permissions per entity per operation
if (httpContext != null && authResolver.IsValidRoleContext(httpContext))
{
string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString();
if (!string.IsNullOrWhiteSpace(roleHeader))
{
string[] roles = roleHeader
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (roles.Length > 1)
{
logger?.LogWarning("Multiple roles detected in request header: [{Roles}]. Using first role '{FirstRole}' for entity discovery. " +
"Consider using a single role for consistent permission reporting.",
string.Join(", ", roles), roles[0]);
}
// For discovery operations, take the first role from comma-separated list
// This provides a consistent view of available entities for the primary role
currentUserRole = roles.FirstOrDefault();
}
}
(bool nameOnly, HashSet? entityFilter) = ParseArguments(arguments, logger);
if (currentUserRole == null)
{
logger?.LogWarning("Current user role could not be determined from HTTP context or role header. " +
"Entity permissions will be empty (no permissions shown) rather than using anonymous permissions. " +
"Ensure the '{RoleHeader}' header is properly set.", AuthorizationResolver.CLIENT_ROLE_HEADER);
}
List> entityList = new();
// Track how many entities were filtered out because DML tools are disabled (dml-tools: false).
// This helps provide a more specific error message when all entities are filtered.
int filteredDmlDisabledCount = 0;
if (runtimeConfig.Entities != null)
{
foreach (KeyValuePair entityEntry in runtimeConfig.Entities)
{
cancellationToken.ThrowIfCancellationRequested();
string entityName = entityEntry.Key;
Entity entity = entityEntry.Value;
// Check entity filter first to avoid counting entities that wouldn't be included anyway
if (!ShouldIncludeEntity(entityName, entityFilter))
{
continue;
}
// Filter out entities when dml-tools is explicitly disabled (false).
// This applies to all entity types (tables, views, stored procedures).
// When dml-tools is false, the entity is not exposed via DML tools
// (read_records, create_record, etc.) and should not appear in describe_entities.
if (entity.Mcp?.DmlToolEnabled == false)
{
filteredDmlDisabledCount++;
continue;
}
try
{
DatabaseObject? databaseObject = null;
if (entity.Source.Type == EntitySourceType.StoredProcedure)
{
databaseObject = McpMetadataHelper.TryResolveDatabaseObject(
entityName,
runtimeConfig,
serviceProvider,
out string resolveError,
cancellationToken);
if (databaseObject is null)
{
// Init normally populates DatabaseStoredProcedure for every SP entity
// (or throws and aborts startup). Reaching here means an init invariant
// regressed. Throw so the surrounding catch drops just this entity from
// the response - returning the SP with no parameter info would mislead
// the agent into thinking the SP takes no arguments.
throw new InvalidOperationException(
$"Could not resolve DB metadata for stored procedure entity '{entityName}'. Error: {resolveError}");
}
}
Dictionary entityInfo = nameOnly
? BuildBasicEntityInfo(entityName, entity)
: BuildFullEntityInfo(entityName, entity, currentUserRole, databaseObject);
entityList.Add(entityInfo);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to build info for entity '{EntityName}'", entityName);
}
}
}
if (entityList.Count == 0)
{
// No entities matched the filter criteria
if (entityFilter != null && entityFilter.Count > 0)
{
return Task.FromResult(McpResponseBuilder.BuildErrorResult(
toolName,
"EntitiesNotFound",
$"No entities found matching the filter: {string.Join(", ", entityFilter)}",
logger));
}
// Return a specific error when ALL configured entities have dml-tools: false.
// Only show this error when every entity was intentionally filtered by the dml-tools check above,
// not when some entities failed to build due to exceptions in BuildBasicEntityInfo() or BuildFullEntityInfo() functions.
else if (filteredDmlDisabledCount > 0 &&
runtimeConfig.Entities != null &&
filteredDmlDisabledCount == runtimeConfig.Entities.Entities.Count)
{
return Task.FromResult(McpResponseBuilder.BuildErrorResult(
toolName,
"AllEntitiesFilteredDmlDisabled",
$"All {filteredDmlDisabledCount} configured entities have DML tools disabled (dml-tools: false). Entities with dml-tools disabled do not appear in describe_entities. If the filtered entities are stored procedures with custom-tool enabled, check tools/list.",
logger));
}
// Truly no entities configured in the runtime config, or entities failed to build for other reasons
else
{
return Task.FromResult(McpResponseBuilder.BuildErrorResult(
toolName,
"NoEntitiesConfigured",
"No entities are configured in the runtime configuration.",
logger));
}
}
cancellationToken.ThrowIfCancellationRequested();
entityList = entityList.OrderBy(e => e["name"]?.ToString() ?? string.Empty).ToList();
List