From 9ffa1771c68611751937d77c828edce76770dac3 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 14:19:30 -0800 Subject: [PATCH 1/3] refactor the responses to use common utility --- .../BuiltInTools/ReadRecordsTool.cs | 144 +++------------- .../BuiltInTools/UpdateRecordTool.cs | 163 ++++-------------- 2 files changed, 66 insertions(+), 241 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 42b1f41ea0..3791fd0bba 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -15,6 +15,7 @@ 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.Authorization; using Microsoft.AspNetCore.Http; @@ -85,7 +86,7 @@ public async Task ExecuteAsync( if (runtimeConfig.McpDmlTools?.ReadRecords is not true) { - return BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( "ToolDisabled", "The read_records tool is disabled in the configuration.", logger); @@ -105,14 +106,14 @@ public async Task ExecuteAsync( // Extract arguments if (arguments == null) { - return BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); } JsonElement root = arguments.RootElement; if (!root.TryGetProperty("entity", out JsonElement entityElement) || string.IsNullOrWhiteSpace(entityElement.GetString())) { - return BuildErrorResult("InvalidArguments", "Missing required argument 'entity'.", logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", "Missing required argument 'entity'.", logger); } entityName = entityElement.GetString()!; @@ -157,12 +158,12 @@ public async Task ExecuteAsync( } catch (Exception) { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } // Authorization check in the existing entity @@ -173,12 +174,12 @@ public async Task ExecuteAsync( if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) { - return BuildErrorResult("PermissionDenied", $"You do not have permission to read records for entity '{entityName}'.", logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"You do not have permission to read records for entity '{entityName}'.", logger); } if (!TryResolveAuthorizedRole(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) { - return BuildErrorResult("PermissionDenied", authError, logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); } // Build and validate Find context @@ -208,7 +209,7 @@ public async Task ExecuteAsync( { if (string.IsNullOrWhiteSpace(param)) { - return BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); } sortQueryString += $"{param}, "; @@ -230,7 +231,7 @@ public async Task ExecuteAsync( requirements: new[] { new ColumnsPermissionsRequirement() }); if (!authorizationResult.Succeeded) { - return BuildErrorResult("PermissionDenied", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); } // Execute @@ -240,34 +241,40 @@ public async Task ExecuteAsync( : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); // Normalize response - string rawPayloadJson = ExtractResultJson(actionResult); - JsonDocument result = JsonDocument.Parse(rawPayloadJson); + string rawPayloadJson = McpResponseBuilder.ExtractResultJson(actionResult); + using JsonDocument result = JsonDocument.Parse(rawPayloadJson); JsonElement queryRoot = result.RootElement; - return BuildSuccessResult( - entityName, - queryRoot.Clone(), - logger); + return McpResponseBuilder.BuildSuccessResult( + new Dictionary + { + ["entity"] = entityName, + ["result"] = queryRoot.Clone(), + ["message"] = $"Successfully read records for entity '{entityName}'" + }, + logger, + $"ReadRecordsTool success for entity {entityName}."); } catch (OperationCanceledException) { - return BuildErrorResult("OperationCanceled", "The read operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The read operation was canceled.", logger); } catch (DbException argEx) { - return BuildErrorResult("DatabaseOperationFailed", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult("DatabaseOperationFailed", argEx.Message, logger); } catch (ArgumentException argEx) { - return BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); } catch (DataApiBuilderException argEx) { - return BuildErrorResult(argEx.StatusCode.ToString(), argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(argEx.StatusCode.ToString(), argEx.Message, logger); } - catch (Exception) + catch (Exception ex) { - return BuildErrorResult("UnexpectedError", "Unexpected error occurred in ReadRecordsTool.", logger); + logger?.LogError(ex, "Unexpected error in ReadRecordsTool."); + return McpResponseBuilder.BuildErrorResult("UnexpectedError", "Unexpected error occurred in ReadRecordsTool.", logger); } } @@ -324,100 +331,5 @@ private static bool TryResolveAuthorizedRole( error = $"You do not have permission to read records for entity '{entityName}'."; return false; } - - /// - /// Returns a result from the query in the case that it was successfully ran. - /// - /// Name of the entity used in the request. - /// Query result from engine. - /// MCP logger that returns all logged events. - private static CallToolResult BuildSuccessResult( - string entityName, - JsonElement engineRootElement, - ILogger? logger) - { - // Build normalized response - Dictionary normalized = new() - { - ["status"] = "success", - ["result"] = engineRootElement // only requested values - }; - - string output = JsonSerializer.Serialize(normalized, new JsonSerializerOptions { WriteIndented = true }); - - logger?.LogInformation("ReadRecordsTool success for entity {Entity}.", entityName); - - return new CallToolResult - { - Content = new List - { - new TextContentBlock { Type = "text", Text = output } - } - }; - } - - /// - /// Returns an error if the query failed to run at any point. - /// - /// Type of error that is encountered. - /// Error message given to the user. - /// MCP logger that returns all logged events. - private static CallToolResult BuildErrorResult( - string errorType, - string message, - ILogger? logger) - { - Dictionary errorObj = new() - { - ["status"] = "error", - ["error"] = new Dictionary - { - ["type"] = errorType, - ["message"] = message - } - }; - - string output = JsonSerializer.Serialize(errorObj); - - logger?.LogError("ReadRecordsTool error {ErrorType}: {Message}", errorType, message); - - return new CallToolResult - { - Content = - [ - new TextContentBlock { Type = "text", Text = output } - ], - IsError = true - }; - } - - /// - /// Extracts a JSON string from a typical IActionResult. - /// Falls back to "{}" for unsupported/empty cases to avoid leaking internals. - /// - private static string ExtractResultJson(IActionResult? result) - { - switch (result) - { - case ObjectResult obj: - if (obj.Value is JsonElement je) - { - return je.GetRawText(); - } - - if (obj.Value is JsonDocument jd) - { - return jd.RootElement.GetRawText(); - } - - return JsonSerializer.Serialize(obj.Value ?? new object()); - - case ContentResult content: - return string.IsNullOrWhiteSpace(content.Content) ? "{}" : content.Content; - - default: - return "{}"; - } - } } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 9e7d101fe6..e58bea7e09 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -92,7 +93,7 @@ public async Task ExecuteAsync( // 2)Check if the tool is enabled in configuration before proceeding. if (config.McpDmlTools?.UpdateRecord != true) { - return BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( "ToolDisabled", "The update_record tool is disabled in the configuration.", logger); @@ -106,12 +107,12 @@ public async Task ExecuteAsync( // 3) Parsing & basic argument validation (entity, keys, fields) if (arguments is null) { - return BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); } if (!TryParseArguments(arguments.RootElement, out string entityName, out Dictionary keys, out Dictionary fields, out string parseError)) { - return BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); @@ -128,12 +129,12 @@ public async Task ExecuteAsync( } catch (Exception) { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } // 5) Authorization after we have a known entity @@ -143,12 +144,12 @@ public async Task ExecuteAsync( if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) { - return BuildErrorResult("PermissionDenied", "Permission denied: unable to resolve a valid role context for update operation.", logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: unable to resolve a valid role context for update operation.", logger); } if (!TryResolveAuthorizedRoleHasPermission(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) { - return BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); } // 6) Build and validate Upsert (UpdateIncremental) context @@ -165,7 +166,7 @@ public async Task ExecuteAsync( { if (kvp.Value is null) { - return BuildErrorResult("InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); } context.PrimaryKeyValuePairs[kvp.Key] = kvp.Value; @@ -193,7 +194,7 @@ public async Task ExecuteAsync( if (errorMsg.Contains("No Update could be performed, record not found", StringComparison.OrdinalIgnoreCase)) { - return BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( "InvalidArguments", "No record found with the given key.", logger); @@ -208,29 +209,46 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); // 8) Normalize response (success or engine error payload) - string rawPayloadJson = ExtractResultJson(mutationResult); + string rawPayloadJson = McpResponseBuilder.ExtractResultJson(mutationResult); using JsonDocument resultDoc = JsonDocument.Parse(rawPayloadJson); JsonElement root = resultDoc.RootElement; - return BuildSuccessResult( - entityName: entityName, - engineRootElement: root.Clone(), - logger: logger); + // Extract first item of value[] array (updated record) + Dictionary filteredResult = new(); + if (root.TryGetProperty("value", out JsonElement valueArray) && + valueArray.ValueKind == JsonValueKind.Array && + valueArray.GetArrayLength() > 0) + { + JsonElement firstItem = valueArray[0]; + foreach (JsonProperty prop in firstItem.EnumerateObject()) + { + filteredResult[prop.Name] = GetJsonValue(prop.Value); + } + } + + return McpResponseBuilder.BuildSuccessResult( + new Dictionary + { + ["entity"] = entityName, + ["result"] = filteredResult, + ["message"] = $"Successfully updated record in entity '{entityName}'" + }, + logger, + $"UpdateRecordTool success for entity {entityName}."); } catch (OperationCanceledException) { - return BuildErrorResult("OperationCanceled", "The update operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The update operation was canceled.", logger); } catch (ArgumentException argEx) { - return BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); } catch (Exception ex) { ILogger? innerLogger = serviceProvider.GetService>(); innerLogger?.LogError(ex, "Unexpected error in UpdateRecordTool."); - - return BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( "UnexpectedError", ex.Message ?? "An unexpected error occurred during the update operation.", logger); @@ -349,53 +367,7 @@ private static bool TryResolveAuthorizedRoleHasPermission( #endregion - #region Response Builders & Utilities - - private static CallToolResult BuildSuccessResult( - string entityName, - JsonElement engineRootElement, - ILogger? logger) - { - // Extract only requested keys and updated fields from engineRootElement - Dictionary filteredResult = new(); - - // Navigate to "value" array in the engine result - if (engineRootElement.TryGetProperty("value", out JsonElement valueArray) && - valueArray.ValueKind == JsonValueKind.Array && - valueArray.GetArrayLength() > 0) - { - JsonElement firstItem = valueArray[0]; - - // Include all properties from the result - foreach (JsonProperty prop in firstItem.EnumerateObject()) - { - filteredResult[prop.Name] = GetJsonValue(prop.Value); - } - } - - // Build normalized response - Dictionary normalized = new() - { - ["status"] = "success", - ["result"] = filteredResult - }; - - string output = JsonSerializer.Serialize(normalized, new JsonSerializerOptions { WriteIndented = true }); - - logger?.LogInformation("UpdateRecordTool success for entity {Entity}.", entityName); - - return new CallToolResult - { - Content = new List - { - new TextContentBlock { Type = "text", Text = output } - } - }; - } - - /// - /// Converts JsonElement to .NET object dynamically. - /// + #region Utilities private static object? GetJsonValue(JsonElement element) { return element.ValueKind switch @@ -405,68 +377,9 @@ private static CallToolResult BuildSuccessResult( JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, - _ => element.GetRawText() // fallback for arrays/objects + _ => element.GetRawText() }; } - - private static CallToolResult BuildErrorResult( - string errorType, - string message, - ILogger? logger) - { - Dictionary errorObj = new() - { - ["status"] = "error", - ["error"] = new Dictionary - { - ["type"] = errorType, - ["message"] = message - } - }; - - string output = JsonSerializer.Serialize(errorObj); - - logger?.LogWarning("UpdateRecordTool error {ErrorType}: {Message}", errorType, message); - - return new CallToolResult - { - Content = - [ - new TextContentBlock { Type = "text", Text = output } - ], - IsError = true - }; - } - - /// - /// Extracts a JSON string from a typical IActionResult. - /// Falls back to "{}" for unsupported/empty cases to avoid leaking internals. - /// - private static string ExtractResultJson(IActionResult? result) - { - switch (result) - { - case ObjectResult obj: - if (obj.Value is JsonElement je) - { - return je.GetRawText(); - } - - if (obj.Value is JsonDocument jd) - { - return jd.RootElement.GetRawText(); - } - - return JsonSerializer.Serialize(obj.Value ?? new object()); - - case ContentResult content: - return string.IsNullOrWhiteSpace(content.Content) ? "{}" : content.Content; - - default: - return "{}"; - } - } - #endregion } } From 3eb7075d201e82a8ff5055e0a81227da9e3659a1 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 20 Nov 2025 15:28:36 -0800 Subject: [PATCH 2/3] include toolname in shared error response --- .../BuiltInTools/CreateRecordTool.cs | 25 ++++++---- .../BuiltInTools/DeleteRecordTool.cs | 39 +++++++++------ .../BuiltInTools/DescribeEntitiesTool.cs | 9 ++++ .../BuiltInTools/ExecuteEntityTool.cs | 47 +++++++++++-------- .../BuiltInTools/ReadRecordsTool.cs | 28 ++++++----- .../BuiltInTools/UpdateRecordTool.cs | 24 +++++----- .../Utils/McpResponseBuilder.cs | 2 + 7 files changed, 107 insertions(+), 67 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 68447f16f4..9d64fd4cd7 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -57,20 +57,22 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; if (arguments == null) { - return Utils.McpResponseBuilder.BuildErrorResult("Invalid Arguments", "No arguments provided", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Invalid Arguments", "No arguments provided", logger); } RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - return Utils.McpResponseBuilder.BuildErrorResult("Invalid Configuration", "Runtime configuration not available", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Invalid Configuration", "Runtime configuration not available", logger); } if (runtimeConfig.McpDmlTools?.CreateRecord != true) { return Utils.McpResponseBuilder.BuildErrorResult( + toolName, "ToolDisabled", "The create_record tool is disabled in the configuration.", logger); @@ -84,13 +86,13 @@ public async Task ExecuteAsync( if (!root.TryGetProperty("entity", out JsonElement entityElement) || !root.TryGetProperty("data", out JsonElement dataElement)) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", "Missing required arguments 'entity' or 'data'", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Missing required arguments 'entity' or 'data'", logger); } string entityName = entityElement.GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(entityName)) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", "Entity name cannot be empty", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity name cannot be empty", logger); } string dataSourceName; @@ -100,7 +102,7 @@ public async Task ExecuteAsync( } catch (Exception) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", $"Entity '{entityName}' not found in configuration", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", $"Entity '{entityName}' not found in configuration", logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); @@ -113,7 +115,7 @@ public async Task ExecuteAsync( } catch (Exception) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", $"Database object for entity '{entityName}' not found", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", $"Database object for entity '{entityName}' not found", logger); } // Create an HTTP context for authorization @@ -123,13 +125,13 @@ public async Task ExecuteAsync( if (httpContext is null || !authorizationResolver.IsValidRoleContext(httpContext)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: Unable to resolve a valid role context for update operation.", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", "Permission denied: Unable to resolve a valid role context for update operation.", logger); } // Validate that we have at least one role authorized for create if (!TryResolveAuthorizedRole(httpContext, authorizationResolver, entityName, out string authError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", authError, logger); } JsonElement insertPayloadRoot = dataElement.Clone(); @@ -150,12 +152,13 @@ public async Task ExecuteAsync( } catch (Exception ex) { - return Utils.McpResponseBuilder.BuildErrorResult("ValidationFailed", $"Request validation failed: {ex.Message}", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); } } else { return Utils.McpResponseBuilder.BuildErrorResult( + toolName, "InvalidCreateTarget", "The create_record tool is only available for tables.", logger); @@ -185,6 +188,7 @@ public async Task ExecuteAsync( if (isError) { return Utils.McpResponseBuilder.BuildErrorResult( + toolName, "CreateFailed", $"Failed to create record in entity '{entityName}'. Error: {JsonSerializer.Serialize(objectResult.Value)}", logger); @@ -207,6 +211,7 @@ public async Task ExecuteAsync( if (result is null) { return Utils.McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", $"Mutation engine returned null result for entity '{entityName}'", logger); @@ -226,7 +231,7 @@ public async Task ExecuteAsync( } catch (Exception ex) { - return Utils.McpResponseBuilder.BuildErrorResult("Error", $"Error: {ex.Message}", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger); } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 7abac888c5..eb310ae364 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -73,6 +73,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; try { @@ -87,6 +88,7 @@ public async Task ExecuteAsync( if (config.McpDmlTools?.DeleteRecord != true) { return McpResponseBuilder.BuildErrorResult( + toolName, "ToolDisabled", $"The {this.GetToolMetadata().Name} tool is disabled in the configuration.", logger); @@ -95,12 +97,12 @@ public async Task ExecuteAsync( // 3) Parsing & basic argument validation if (arguments is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + 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("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); @@ -117,18 +119,18 @@ public async Task ExecuteAsync( } catch (Exception) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } // Validate it's a table or view if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) { - return McpResponseBuilder.BuildErrorResult("InvalidEntity", $"Entity '{entityName}' is not a table or view. Use 'execute-entity' for stored procedures.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. Use 'execute-entity' for stored procedures.", logger); } // 5) Authorization @@ -138,7 +140,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleError}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", $"Permission denied: {roleError}", logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -149,7 +151,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", $"Permission denied: {authError}", logger); } // 6) Build and validate Delete context @@ -164,7 +166,7 @@ public async Task ExecuteAsync( { if (kvp.Value is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); } context.PrimaryKeyValuePairs[kvp.Key] = kvp.Value; @@ -195,6 +197,7 @@ public async Task ExecuteAsync( { string keyDetails = McpJsonHelper.FormatKeyDetails(keys); return McpResponseBuilder.BuildErrorResult( + toolName, "RecordNotFound", $"No record found with the specified primary key: {keyDetails}", logger); @@ -203,6 +206,7 @@ public async Task ExecuteAsync( 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); @@ -211,6 +215,7 @@ public async Task ExecuteAsync( message.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "PermissionDenied", "You do not have permission to delete this record.", logger); @@ -219,6 +224,7 @@ public async Task ExecuteAsync( message.Contains("type", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", "Invalid data type for one or more key values.", logger); @@ -226,6 +232,7 @@ public async Task ExecuteAsync( // For any other DAB exceptions, return the message as-is return McpResponseBuilder.BuildErrorResult( + toolName, "DataApiBuilderError", dabEx.Message, logger); @@ -242,7 +249,7 @@ public async Task ExecuteAsync( 208 => $"Table '{dbObject.FullName}' not found in the database.", _ => $"Database error: {sqlEx.Message}" }; - return McpResponseBuilder.BuildErrorResult("DatabaseError", errorMessage, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", errorMessage, logger); } catch (DbException dbEx) { @@ -254,6 +261,7 @@ public async Task ExecuteAsync( 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); @@ -261,24 +269,25 @@ public async Task ExecuteAsync( 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("DatabaseError", $"Database error: {dbEx.Message}", 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("ConnectionError", "Failed to connect to the database.", logger); + 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("TimeoutError", "The delete operation timed out.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", "The delete operation timed out.", logger); } catch (Exception ex) { @@ -289,6 +298,7 @@ public async Task ExecuteAsync( { string keyDetails = McpJsonHelper.FormatKeyDetails(keys); return McpResponseBuilder.BuildErrorResult( + toolName, "RecordNotFound", $"No entity found with the given key {keyDetails}.", logger); @@ -325,11 +335,11 @@ public async Task ExecuteAsync( } catch (OperationCanceledException) { - return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The delete operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The delete operation was canceled.", logger); } catch (ArgumentException argEx) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { @@ -337,6 +347,7 @@ public async Task ExecuteAsync( innerLogger?.LogError(ex, "Unexpected error in DeleteRecordTool."); return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", "An unexpected error occurred during the delete operation.", logger); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 154b37ee80..b8c7d975a2 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -67,6 +67,7 @@ public Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; try { @@ -78,6 +79,7 @@ public Task ExecuteAsync( if (!IsToolEnabled(runtimeConfig)) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "ToolDisabled", $"The {GetToolMetadata().Name} tool is disabled in the configuration.", logger)); @@ -158,6 +160,7 @@ public Task ExecuteAsync( if (entityFilter != null && entityFilter.Count > 0) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "EntitiesNotFound", $"No entities found matching the filter: {string.Join(", ", entityFilter)}", logger)); @@ -165,6 +168,7 @@ public Task ExecuteAsync( else { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "NoEntitiesConfigured", "No entities are configured in the runtime configuration.", logger)); @@ -197,6 +201,7 @@ public Task ExecuteAsync( catch (OperationCanceledException) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "OperationCanceled", "The describe operation was canceled.", logger)); @@ -205,6 +210,7 @@ public Task ExecuteAsync( { logger?.LogError(dabEx, "Data API Builder error in DescribeEntitiesTool"); return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "DataApiBuilderError", dabEx.Message, logger)); @@ -212,6 +218,7 @@ public Task ExecuteAsync( catch (ArgumentException argEx) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", argEx.Message, logger)); @@ -220,6 +227,7 @@ public Task ExecuteAsync( { logger?.LogError(ioEx, "Invalid operation in DescribeEntitiesTool"); return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "InvalidOperation", "Failed to retrieve entity metadata: " + ioEx.Message, logger)); @@ -228,6 +236,7 @@ public Task ExecuteAsync( { logger?.LogError(ex, "Unexpected error in DescribeEntitiesTool"); return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", "An unexpected error occurred while describing entities.", logger)); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index be2fa7af36..6b0bc28383 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -73,6 +73,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; try { @@ -87,26 +88,27 @@ public async Task ExecuteAsync( if (config.McpDmlTools?.ExecuteEntity != true) { return McpResponseBuilder.BuildErrorResult( + toolName, "ToolDisabled", - $"The {this.GetToolMetadata().Name} tool is disabled in the configuration.", + $"The {toolName} tool is disabled in the configuration.", logger); } // 3) Parsing & basic argument validation if (arguments is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } if (!TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary parameters, out string parseError)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } // Entity is required if (string.IsNullOrWhiteSpace(entity)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "Entity is required", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity is required", logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); @@ -115,12 +117,12 @@ public async Task ExecuteAsync( // 4) Validate entity exists and is a stored procedure if (!config.Entities.TryGetValue(entity, out Entity? entityConfig)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); } if (entityConfig.Source.Type != EntitySourceType.StoredProcedure) { - return McpResponseBuilder.BuildErrorResult("InvalidEntity", $"Entity {entity} cannot be executed.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {entity} cannot be executed.", logger); } // 5) Resolve metadata @@ -134,12 +136,12 @@ public async Task ExecuteAsync( } catch (Exception) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Failed to resolve entity metadata for '{entity}'.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Failed to resolve entity metadata for '{entity}'.", logger); } if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entity, out DatabaseObject? dbObject) || dbObject is null) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Failed to resolve database object for entity '{entity}'.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Failed to resolve database object for entity '{entity}'.", logger); } // 6) Authorization - Never bypass permissions @@ -149,7 +151,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", roleError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -160,7 +162,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", authError, logger); } // 7) Validate parameters against metadata @@ -171,7 +173,7 @@ public async Task ExecuteAsync( { if (!entityConfig.Source.Parameters.Any(p => p.Name == param.Key)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", $"Invalid parameter: {param.Key}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Invalid parameter: {param.Key}", logger); } } } @@ -241,6 +243,7 @@ public async Task ExecuteAsync( message.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "PermissionDenied", "You do not have permission to execute this stored procedure.", logger); @@ -249,6 +252,7 @@ public async Task ExecuteAsync( message.Contains("type", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", "Invalid data type for one or more parameters.", logger); @@ -256,6 +260,7 @@ public async Task ExecuteAsync( // For any other DAB exceptions, return the message as-is return McpResponseBuilder.BuildErrorResult( + toolName, "DataApiBuilderError", dabEx.Message, logger); @@ -273,48 +278,49 @@ public async Task ExecuteAsync( 229 or 262 => $"Permission denied to execute stored procedure '{entityConfig.Source.Object}'.", _ => $"Database error: {sqlEx.Message}" }; - return McpResponseBuilder.BuildErrorResult("DatabaseError", errorMessage, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", errorMessage, logger); } catch (DbException dbEx) { // Handle generic database exceptions (works for PostgreSQL, MySQL, etc.) logger?.LogError(dbEx, "Database error executing stored procedure {StoredProcedure}", entity); - return McpResponseBuilder.BuildErrorResult("DatabaseError", $"Database error: {dbEx.Message}", 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("ConnectionError", "Failed to connect to the database.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "ConnectionError", "Failed to connect to the database.", logger); } catch (TimeoutException timeoutEx) { // Handle query timeout logger?.LogError(timeoutEx, "Stored procedure execution timeout for {StoredProcedure}", entity); - return McpResponseBuilder.BuildErrorResult("TimeoutError", "The stored procedure execution timed out.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", "The stored procedure execution timed out.", logger); } catch (Exception ex) { // Generic database/execution errors logger?.LogError(ex, "Unexpected error executing stored procedure {StoredProcedure}", entity); - return McpResponseBuilder.BuildErrorResult("DatabaseError", "An error occurred while executing the stored procedure.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", "An error occurred while executing the stored procedure.", logger); } // 11) Build response with execution result - return BuildExecuteSuccessResponse(entity, parameters, queryResult, logger); + return BuildExecuteSuccessResponse(toolName, entity, parameters, queryResult, logger); } catch (OperationCanceledException) { - return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The execute operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The execute operation was canceled.", logger); } catch (ArgumentException argEx) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { logger?.LogError(ex, "Unexpected error in ExecuteEntityTool."); return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", "An unexpected error occurred during the execute operation.", logger); @@ -386,6 +392,7 @@ private static bool TryParseExecuteArguments( /// Builds a successful response for the execute operation. /// private static CallToolResult BuildExecuteSuccessResponse( + string toolName, string entityName, Dictionary? parameters, IActionResult? queryResult, @@ -426,6 +433,7 @@ private static CallToolResult BuildExecuteSuccessResponse( else if (queryResult is BadRequestObjectResult badRequest) { return McpResponseBuilder.BuildErrorResult( + toolName, "BadRequest", badRequest.Value?.ToString() ?? "Bad request", logger); @@ -433,6 +441,7 @@ private static CallToolResult BuildExecuteSuccessResponse( else if (queryResult is UnauthorizedObjectResult) { return McpResponseBuilder.BuildErrorResult( + toolName, "PermissionDenied", "You do not have permission to execute this entity", logger); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 3791fd0bba..7561e1ae54 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -79,6 +79,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; // Get runtime config RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); @@ -87,6 +88,7 @@ public async Task ExecuteAsync( if (runtimeConfig.McpDmlTools?.ReadRecords is not true) { return McpResponseBuilder.BuildErrorResult( + toolName, "ToolDisabled", "The read_records tool is disabled in the configuration.", logger); @@ -106,14 +108,14 @@ public async Task ExecuteAsync( // Extract arguments if (arguments == null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } JsonElement root = arguments.RootElement; if (!root.TryGetProperty("entity", out JsonElement entityElement) || string.IsNullOrWhiteSpace(entityElement.GetString())) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "Missing required argument 'entity'.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Missing required argument 'entity'.", logger); } entityName = entityElement.GetString()!; @@ -158,12 +160,12 @@ public async Task ExecuteAsync( } catch (Exception) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } // Authorization check in the existing entity @@ -174,12 +176,12 @@ public async Task ExecuteAsync( if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"You do not have permission to read records for entity '{entityName}'.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", $"You do not have permission to read records for entity '{entityName}'.", logger); } if (!TryResolveAuthorizedRole(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", authError, logger); } // Build and validate Find context @@ -209,7 +211,7 @@ public async Task ExecuteAsync( { if (string.IsNullOrWhiteSpace(param)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); } sortQueryString += $"{param}, "; @@ -231,7 +233,7 @@ public async Task ExecuteAsync( requirements: new[] { new ColumnsPermissionsRequirement() }); if (!authorizationResult.Succeeded) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); } // Execute @@ -257,24 +259,24 @@ public async Task ExecuteAsync( } catch (OperationCanceledException) { - return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The read operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The read operation was canceled.", logger); } catch (DbException argEx) { - return McpResponseBuilder.BuildErrorResult("DatabaseOperationFailed", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseOperationFailed", argEx.Message, logger); } catch (ArgumentException argEx) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (DataApiBuilderException argEx) { - return McpResponseBuilder.BuildErrorResult(argEx.StatusCode.ToString(), argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, argEx.StatusCode.ToString(), argEx.Message, logger); } catch (Exception ex) { logger?.LogError(ex, "Unexpected error in ReadRecordsTool."); - return McpResponseBuilder.BuildErrorResult("UnexpectedError", "Unexpected error occurred in ReadRecordsTool.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "Unexpected error occurred in ReadRecordsTool.", logger); } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index e58bea7e09..05e66a7fd7 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -84,8 +84,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); - - // 1) Resolve required services & configuration + string toolName = GetToolMetadata().Name; RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); RuntimeConfig config = runtimeConfigProvider.GetConfig(); @@ -94,6 +93,7 @@ public async Task ExecuteAsync( if (config.McpDmlTools?.UpdateRecord != true) { return McpResponseBuilder.BuildErrorResult( + toolName, "ToolDisabled", "The update_record tool is disabled in the configuration.", logger); @@ -107,12 +107,12 @@ public async Task ExecuteAsync( // 3) Parsing & basic argument validation (entity, keys, fields) if (arguments is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } if (!TryParseArguments(arguments.RootElement, out string entityName, out Dictionary keys, out Dictionary fields, out string parseError)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); @@ -129,12 +129,12 @@ public async Task ExecuteAsync( } catch (Exception) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); } // 5) Authorization after we have a known entity @@ -144,12 +144,12 @@ public async Task ExecuteAsync( if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: unable to resolve a valid role context for update operation.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", "Permission denied: unable to resolve a valid role context for update operation.", logger); } if (!TryResolveAuthorizedRoleHasPermission(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", $"Permission denied: {authError}", logger); } // 6) Build and validate Upsert (UpdateIncremental) context @@ -166,7 +166,7 @@ public async Task ExecuteAsync( { if (kvp.Value is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); } context.PrimaryKeyValuePairs[kvp.Key] = kvp.Value; @@ -195,6 +195,7 @@ public async Task ExecuteAsync( if (errorMsg.Contains("No Update could be performed, record not found", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", "No record found with the given key.", logger); @@ -238,17 +239,18 @@ public async Task ExecuteAsync( } catch (OperationCanceledException) { - return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The update operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The update operation was canceled.", logger); } catch (ArgumentException argEx) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { ILogger? innerLogger = serviceProvider.GetService>(); innerLogger?.LogError(ex, "Unexpected error in UpdateRecordTool."); return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", ex.Message ?? "An unexpected error occurred during the update operation.", logger); diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs index afbccbda38..cfd8739c30 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs @@ -43,12 +43,14 @@ public static CallToolResult BuildSuccessResult( /// Builds an error response for MCP tools. /// public static CallToolResult BuildErrorResult( + string toolName, string errorType, string message, ILogger? logger = null) { Dictionary errorObj = new() { + ["toolName"] = toolName, ["status"] = "error", ["error"] = new Dictionary { From 4ddac324000fbd07552c67a9747f74579d1439e2 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 25 Nov 2025 15:01:40 -0800 Subject: [PATCH 3/3] factor out getJson() --- .../BuiltInTools/UpdateRecordTool.cs | 17 +---------------- .../Utils/McpResponseBuilder.cs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 05e66a7fd7..ab8956b618 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -223,7 +223,7 @@ public async Task ExecuteAsync( JsonElement firstItem = valueArray[0]; foreach (JsonProperty prop in firstItem.EnumerateObject()) { - filteredResult[prop.Name] = GetJsonValue(prop.Value); + filteredResult[prop.Name] = McpResponseBuilder.GetJsonValue(prop.Value); } } @@ -368,20 +368,5 @@ private static bool TryResolveAuthorizedRoleHasPermission( } #endregion - - #region Utilities - private static object? GetJsonValue(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.TryGetInt64(out long l) ? l : element.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => element.GetRawText() - }; - } - #endregion } } diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs index cfd8739c30..49cacef2c3 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs @@ -101,5 +101,21 @@ public static string ExtractResultJson(IActionResult? result) return "{}"; } } + + /// + /// Extracts value from a JsonElement. + /// + public static object? GetJsonValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out long l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } } }