// 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);
}
}
}
}