// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Data.Common; using System.Text.Json; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; using Azure.DataApiBuilder.Mcp.Utils; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.SqlClient; 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 delete records from a table/view entity configured in DAB. /// Supports both simple and composite primary keys. /// public class DeleteRecordTool : 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?.DeleteRecord ?? true; /// /// Gets the metadata for the delete-record tool, including its name, description, and input schema. /// public Tool GetToolMetadata() { return new Tool { Name = "delete_record", Description = "STEP 1: describe_entities -> find entities with DELETE permission and their key fields. STEP 2: call this tool with full key values.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""entity"": { ""type"": ""string"", ""description"": ""Entity name with DELETE permission."" }, ""keys"": { ""type"": ""object"", ""description"": ""All key fields identifying the record."" } }, ""required"": [""entity"", ""keys""] }" ) }; } /// /// Executes the delete-record tool, deleting an existing record in the specified entity using provided keys. /// public async Task ExecuteAsync( JsonDocument? arguments, IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); string toolName = GetToolMetadata().Name; try { // Cancellation check at the start cancellationToken.ThrowIfCancellationRequested(); // 1) Resolve required services & configuration RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); RuntimeConfig config = runtimeConfigProvider.GetConfig(); // 2) Check if the tool is enabled in configuration before proceeding if (config.McpDmlTools?.DeleteRecord != true) { return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } // 3) Parsing & basic argument validation if (arguments is null) { return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } if (!McpArgumentParser.TryParseEntityAndKeys(arguments.RootElement, out string entityName, out Dictionary keys, out string parseError)) { return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } // Check entity-level DML tool configuration if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && entity.Mcp?.DmlToolEnabled == false) { return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); } // 4) Resolve metadata for entity existence if (!McpMetadataHelper.TryResolveMetadata( entityName, config, serviceProvider, out ISqlMetadataProvider sqlMetadataProvider, out DatabaseObject dbObject, out string dataSourceName, out string metadataError)) { return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Validate it's a table or view if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) { return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. Use 'execute-entity' for stored procedures.", logger); } // 5) Authorization IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( httpContext!, authResolver, entityName, EntityActionOperation.Delete, out string? effectiveRole, out string authError)) { return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", authError, logger); } // Need MetadataProviderFactory for RequestValidator; resolve here. IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); DeleteRequestContext context = new( entityName: entityName, dbo: dbObject, isList: false); foreach (KeyValuePair kvp in keys) { if (kvp.Value is null) { return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); } context.PrimaryKeyValuePairs[kvp.Key] = kvp.Value; } requestValidator.ValidatePrimaryKey(context); IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(dbType); IActionResult? mutationResult = null; try { // Cancellation check before executing cancellationToken.ThrowIfCancellationRequested(); mutationResult = await mutationEngine.ExecuteAsync(context).ConfigureAwait(false); } catch (DataApiBuilderException dabEx) { // Handle specific DAB exceptions logger?.LogError(dabEx, "Data API Builder error deleting record from {Entity}", entityName); string message = dabEx.Message; // Check for specific error patterns if (message.Contains("Could not find item with", StringComparison.OrdinalIgnoreCase)) { string keyDetails = McpJsonHelper.FormatKeyDetails(keys); return McpResponseBuilder.BuildErrorResult( toolName, "RecordNotFound", $"No record found with the specified primary key: {keyDetails}", logger); } else if (message.Contains("violates foreign key constraint", StringComparison.OrdinalIgnoreCase) || message.Contains("REFERENCE constraint", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( toolName, "ConstraintViolation", "Cannot delete record due to foreign key constraint. Other records depend on this record.", logger); } else if (message.Contains("permission", StringComparison.OrdinalIgnoreCase) || message.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( toolName, "PermissionDenied", "You do not have permission to delete this record.", logger); } else if (message.Contains("invalid", StringComparison.OrdinalIgnoreCase) && message.Contains("type", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( toolName, "InvalidArguments", "Invalid data type for one or more key values.", logger); } // For any other DAB exceptions, return the message as-is return McpResponseBuilder.BuildErrorResult( toolName, "DataApiBuilderError", dabEx.Message, logger); } catch (SqlException sqlEx) { // Handle SQL Server specific errors logger?.LogError(sqlEx, "SQL Server error deleting record from {Entity}", entityName); string errorMessage = sqlEx.Number switch { 547 => "Cannot delete record due to foreign key constraint. Other records depend on this record.", 2627 or 2601 => "Cannot delete record due to unique constraint violation.", 229 or 262 => $"Permission denied to delete from table '{dbObject.FullName}'.", 208 => $"Table '{dbObject.FullName}' not found in the database.", _ => $"Database error: {sqlEx.Message}" }; return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", errorMessage, logger); } catch (DbException dbEx) { // Handle generic database exceptions (works for PostgreSQL, MySQL, etc.) logger?.LogError(dbEx, "Database error deleting record from {Entity}", entityName); // Check for common patterns in error messages string errorMsg = dbEx.Message.ToLowerInvariant(); if (errorMsg.Contains("foreign key") || errorMsg.Contains("constraint")) { return McpResponseBuilder.BuildErrorResult( toolName, "ConstraintViolation", "Cannot delete record due to foreign key constraint. Other records depend on this record.", logger); } else if (errorMsg.Contains("not found") || errorMsg.Contains("does not exist")) { return McpResponseBuilder.BuildErrorResult( toolName, "RecordNotFound", "No record found with the specified primary key.", logger); } return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {dbEx.Message}", logger); } catch (InvalidOperationException ioEx) when (ioEx.Message.Contains("connection", StringComparison.OrdinalIgnoreCase)) { // Handle connection-related issues logger?.LogError(ioEx, "Database connection error"); return McpResponseBuilder.BuildErrorResult(toolName, "ConnectionError", "Failed to connect to the database.", logger); } catch (TimeoutException timeoutEx) { // Handle query timeout logger?.LogError(timeoutEx, "Delete operation timeout for {Entity}", entityName); return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", "The delete operation timed out.", logger); } catch (Exception ex) { string errorMsg = ex.Message ?? string.Empty; if (errorMsg.Contains("Could not find", StringComparison.OrdinalIgnoreCase) || errorMsg.Contains("record not found", StringComparison.OrdinalIgnoreCase)) { string keyDetails = McpJsonHelper.FormatKeyDetails(keys); return McpResponseBuilder.BuildErrorResult( toolName, "RecordNotFound", $"No entity found with the given key {keyDetails}.", logger); } else { // Re-throw unexpected exceptions throw; } } // 8) Build response // Based on SqlMutationEngine, delete operations typically return NoContentResult // We build a success response with just the operation details Dictionary responseData = new() { ["entity"] = entityName, ["keyDetails"] = McpJsonHelper.FormatKeyDetails(keys), ["message"] = "Record deleted successfully" }; // If the mutation result is OkObjectResult (which would be unusual for delete), // include the result value directly without re-serialization if (mutationResult is OkObjectResult okObjectResult && okObjectResult.Value is not null) { responseData["result"] = okObjectResult.Value; } return McpResponseBuilder.BuildSuccessResult( responseData, logger, $"DeleteRecordTool success for entity {entityName}." ); } catch (OperationCanceledException) { return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The delete operation was canceled.", logger); } catch (ArgumentException argEx) { return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { logger?.LogError(ex, "Unexpected error in DeleteRecordTool."); return McpResponseBuilder.BuildErrorResult( toolName, "UnexpectedError", "An unexpected error occurred during the delete operation.", logger); } } } }