From ed51e3b9da0e7e15cc3051f894b01e3c6e1fcfc6 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 16:09:13 -0800 Subject: [PATCH 01/32] Refactor argument parsing --- .../BuiltInTools/CreateRecordTool.cs | 13 ++-- .../BuiltInTools/ReadRecordsTool.cs | 8 +-- .../Utils/McpArgumentParser.cs | 67 ++++++++++++++++--- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 68447f16f4..b9a59aa500 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.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; @@ -81,16 +82,10 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); JsonElement root = arguments.RootElement; - if (!root.TryGetProperty("entity", out JsonElement entityElement) || - !root.TryGetProperty("data", out JsonElement dataElement)) + // Use common parser + if (!McpArgumentParser.TryParseEntityAndData(root, out string entityName, out JsonElement dataElement, out string parseError)) { - return Utils.McpResponseBuilder.BuildErrorResult("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("InvalidArguments", parseError, logger); } string dataSourceName; diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 42b1f41ea0..0bd59a8bc6 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; @@ -110,13 +111,12 @@ public async Task ExecuteAsync( JsonElement root = arguments.RootElement; - if (!root.TryGetProperty("entity", out JsonElement entityElement) || string.IsNullOrWhiteSpace(entityElement.GetString())) + // Use common parser + if (!McpArgumentParser.TryParseEntity(root, out entityName, out string parseError)) { - return BuildErrorResult("InvalidArguments", "Missing required argument 'entity'.", logger); + return BuildErrorResult("InvalidArguments", parseError, logger); } - entityName = entityElement.GetString()!; - if (root.TryGetProperty("select", out JsonElement selectElement)) { select = selectElement.GetString(); diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs index 04d14eb5d6..ccec602356 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs @@ -11,26 +11,22 @@ namespace Azure.DataApiBuilder.Mcp.Utils public static class McpArgumentParser { /// - /// Parses entity and keys arguments for delete/update operations. + /// Parses only the entity name from arguments. /// - public static bool TryParseEntityAndKeys( + public static bool TryParseEntity( JsonElement root, out string entityName, - out Dictionary keys, out string error) { entityName = string.Empty; - keys = new Dictionary(); error = string.Empty; - if (!root.TryGetProperty("entity", out JsonElement entityEl) || - !root.TryGetProperty("keys", out JsonElement keysEl)) + if (!root.TryGetProperty("entity", out JsonElement entityEl)) { - error = "Missing required arguments 'entity' or 'keys'."; + error = "Missing required argument 'entity'."; return false; } - // Parse and validate entity name entityName = entityEl.GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(entityName)) { @@ -38,7 +34,60 @@ public static bool TryParseEntityAndKeys( return false; } - // Parse and validate keys + return true; + } + + /// + /// Parses entity and data arguments for create operations. + /// + public static bool TryParseEntityAndData( + JsonElement root, + out string entityName, + out JsonElement dataElement, + out string error) + { + dataElement = default; + if (!TryParseEntity(root, out entityName, out error)) + { + return false; + } + + if (!root.TryGetProperty("data", out dataElement)) + { + error = "Missing required argument 'data'."; + return false; + } + + if (dataElement.ValueKind != JsonValueKind.Object) + { + error = "'data' must be a JSON object."; + return false; + } + + return true; + } + + /// + /// Parses entity and keys arguments for delete/update operations. + /// + public static bool TryParseEntityAndKeys( + JsonElement root, + out string entityName, + out Dictionary keys, + out string error) + { + keys = new Dictionary(); + if (!TryParseEntity(root, out entityName, out error)) + { + return false; + } + + if (!root.TryGetProperty("keys", out JsonElement keysEl)) + { + error = "Missing required argument 'keys'."; + return false; + } + if (keysEl.ValueKind != JsonValueKind.Object) { error = "'keys' must be a JSON object."; From 4f40315d608c320180e65e082f6a9e0396f54b12 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 16:54:02 -0800 Subject: [PATCH 02/32] add metadata helper and use in delete and read --- .../BuiltInTools/DeleteRecordTool.cs | 59 ++++++++----------- .../BuiltInTools/ReadRecordsTool.cs | 36 ++++------- .../Utils/McpMetadataHelper.cs | 57 ++++++++++++++++++ 3 files changed, 94 insertions(+), 58 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 7abac888c5..1fbacd2c66 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -9,9 +9,7 @@ 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; @@ -39,14 +37,12 @@ public class DeleteRecordTool : IMcpTool /// /// Gets the metadata for the delete-record tool, including its name, description, and input schema. /// - public Tool GetToolMetadata() + public Tool GetToolMetadata() => new() { - 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( - @"{ + 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"": { @@ -59,10 +55,8 @@ public Tool GetToolMetadata() } }, ""required"": [""entity"", ""keys""] - }" - ) - }; - } + }") + }; /// /// Executes the delete-record tool, deleting an existing record in the specified entity using provided keys. @@ -88,7 +82,7 @@ public async Task ExecuteAsync( { return McpResponseBuilder.BuildErrorResult( "ToolDisabled", - $"The {this.GetToolMetadata().Name} tool is disabled in the configuration.", + $"The {GetToolMetadata().Name} tool is disabled in the configuration.", logger); } @@ -103,26 +97,20 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); } - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); - // 4) Resolve metadata for entity existence check string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try - { - dataSourceName = config.GetDataSourceNameFromEntityName(entityName); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - } - catch (Exception) - { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); - } - - if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) + Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider; + + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + config, + serviceProvider, + out sqlMetadataProvider, + out DatabaseObject dbObject, + out dataSourceName, + out string metadataError)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult("EntityNotFound", metadataError, logger); } // Validate it's a table or view @@ -152,8 +140,9 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); } - // 6) Build and validate Delete context - RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); + // Need MetadataProviderFactory for RequestValidator; resolve here. + var metadataProviderFactory = serviceProvider.GetRequiredService(); + Azure.DataApiBuilder.Core.Services.RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); DeleteRequestContext context = new( entityName: entityName, @@ -172,7 +161,7 @@ public async Task ExecuteAsync( requestValidator.ValidatePrimaryKey(context); - // 7) Execute + var mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(dbType); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 0bd59a8bc6..00f2da5116 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -142,27 +142,16 @@ public async Task ExecuteAsync( after = afterElement.GetString(); } - // Get required services & configuration - IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - - // Check metadata for entity exists - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try - { - dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - } - catch (Exception) + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + runtimeConfig, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - return 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 BuildErrorResult("EntityNotFound", metadataError, logger); } // Authorization check in the existing entity @@ -182,7 +171,7 @@ public async Task ExecuteAsync( } // Build and validate Find context - RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); + RequestValidator requestValidator = new(serviceProvider.GetRequiredService(), runtimeConfigProvider); FindRequestContext context = new(entityName, dbObject, true); httpContext.Request.Method = "GET"; @@ -234,10 +223,11 @@ public async Task ExecuteAsync( } // Execute + IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(sqlMetadataProvider.GetDatabaseType()); JsonDocument? queryResult = await queryEngine.ExecuteAsync(context); - IActionResult actionResult = queryResult is null ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) - : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); + IActionResult actionResult = queryResult is null ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) + : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); // Normalize response string rawPayloadJson = ExtractResultJson(actionResult); diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs new file mode 100644 index 0000000000..d1dfa23de8 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#pragma warning disable IDE0005 // Using directive is unnecessary (analyzer noise) +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0005 + +namespace Azure.DataApiBuilder.Mcp.Utils +{ + public static class McpMetadataHelper + { + public static bool TryResolveMetadata( + string entityName, + RuntimeConfig config, + IServiceProvider serviceProvider, + out Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string error) + { + sqlMetadataProvider = default!; + dbObject = default!; + dataSourceName = string.Empty; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(entityName)) + { + error = "Entity name cannot be null or empty."; + return false; + } + + var metadataProviderFactory = serviceProvider.GetRequiredService(); + + try + { + dataSourceName = config.GetDataSourceNameFromEntityName(entityName); + sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); + } + catch (Exception) + { + error = $"Entity '{entityName}' is not defined in the configuration."; + return false; + } + + if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? temp) || temp is null) + { + error = $"Entity '{entityName}' is not defined in the configuration."; + return false; + } + + dbObject = temp!; + return true; + } + } +} From a0a38fecf424df678a917c5418011d819d66a497 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 20:09:35 -0800 Subject: [PATCH 03/32] factor out auth --- .../BuiltInTools/CreateRecordTool.cs | 88 ++++-------------- .../BuiltInTools/ReadRecordsTool.cs | 91 +++++-------------- 2 files changed, 41 insertions(+), 138 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index b9a59aa500..491443511e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -5,13 +5,12 @@ 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.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 Microsoft.AspNetCore.Http; @@ -88,27 +87,17 @@ public async Task ExecuteAsync( return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); } - string dataSourceName; - try - { - dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); - } - catch (Exception) - { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", $"Entity '{entityName}' not found in configuration", logger); - } - - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - ISqlMetadataProvider sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - - DatabaseObject dbObject; - try + // Use metadata helper for data source/provider/dbObject resolution. + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + runtimeConfig, + serviceProvider, + out Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - dbObject = sqlMetadataProvider.GetDatabaseObjectByKey(entityName); - } - catch (Exception) - { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", $"Database object for entity '{entityName}' not found", logger); + return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", metadataError, logger); } // Create an HTTP context for authorization @@ -116,13 +105,18 @@ public async Task ExecuteAsync( HttpContext httpContext = httpContextAccessor.HttpContext ?? new DefaultHttpContext(); IAuthorizationResolver authorizationResolver = serviceProvider.GetRequiredService(); - if (httpContext is null || !authorizationResolver.IsValidRoleContext(httpContext)) + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authorizationResolver, out string roleCtxError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: Unable to resolve a valid role context for update operation.", logger); + return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: Unable to resolve a valid role context for create operation.", logger); } - // Validate that we have at least one role authorized for create - if (!TryResolveAuthorizedRole(httpContext, authorizationResolver, entityName, out string authError)) + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext, + authorizationResolver, + entityName, + EntityActionOperation.Create, + out string? effectiveRole, + out string authError)) { return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); } @@ -224,47 +218,5 @@ public async Task ExecuteAsync( return Utils.McpResponseBuilder.BuildErrorResult("Error", $"Error: {ex.Message}", logger); } } - - private static bool TryResolveAuthorizedRole( - HttpContext httpContext, - IAuthorizationResolver authorizationResolver, - string entityName, - out string error) - { - error = string.Empty; - - string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); - - if (string.IsNullOrWhiteSpace(roleHeader)) - { - error = "Client role header is missing or empty."; - return false; - } - - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (roles.Length == 0) - { - error = "Client role header is missing or empty."; - return false; - } - - foreach (string role in roles) - { - bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( - entityName, role, EntityActionOperation.Create); - - if (allowed) - { - return true; - } - } - - error = "You do not have permission to create records for this entity."; - return false; - } } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 00f2da5116..f6ff5bb276 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -160,20 +160,30 @@ public async Task ExecuteAsync( IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; - if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleCtxError)) { return 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)) + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + entityName, + EntityActionOperation.Read, + out string? effectiveRole, + out string readAuthError)) { - return BuildErrorResult("PermissionDenied", authError, logger); + // Provide tool-specific message rather than generic helper message. + string finalError = readAuthError.StartsWith("You do not have permission", StringComparison.OrdinalIgnoreCase) + ? $"You do not have permission to read records for entity '{entityName}'." + : readAuthError; + return BuildErrorResult("PermissionDenied", finalError, logger); } // Build and validate Find context RequestValidator requestValidator = new(serviceProvider.GetRequiredService(), runtimeConfigProvider); FindRequestContext context = new(entityName, dbObject, true); - httpContext.Request.Method = "GET"; + httpContext!.Request.Method = "GET"; requestValidator.ValidateEntity(entityName); @@ -190,20 +200,14 @@ public async Task ExecuteAsync( context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}"); } - if (orderby is not null && orderby.Count() != 0) + if (orderby is not null && orderby.Any()) { - string sortQueryString = $"?{RequestParser.SORT_URL}="; - foreach (string param in orderby) + string sortQueryString = $"?{RequestParser.SORT_URL}=" + string.Join(", ", orderby.Where(p => !string.IsNullOrWhiteSpace(p))); + if (sortQueryString.EndsWith(", ")) { - if (string.IsNullOrWhiteSpace(param)) - { - return BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); - } - - sortQueryString += $"{param}, "; + sortQueryString = sortQueryString[..^2]; } - sortQueryString = sortQueryString.Substring(0, sortQueryString.Length - 2); (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = RequestParser.GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); } @@ -226,8 +230,9 @@ public async Task ExecuteAsync( IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(sqlMetadataProvider.GetDatabaseType()); JsonDocument? queryResult = await queryEngine.ExecuteAsync(context); - IActionResult actionResult = queryResult is null ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) - : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); + IActionResult actionResult = queryResult is null + ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) + : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); // Normalize response string rawPayloadJson = ExtractResultJson(actionResult); @@ -261,60 +266,6 @@ public async Task ExecuteAsync( } } - /// - /// Ensures that the role used on the request has the necessary authorizations. - /// - /// Contains request headers and metadata of the user. - /// Resolver used to check if role has necessary authorizations. - /// Name of the entity used in the request. - /// Role defined in client role header. - /// Error message given to the user. - /// True if the user role is authorized, along with the role. - private static bool TryResolveAuthorizedRole( - HttpContext httpContext, - IAuthorizationResolver authorizationResolver, - string entityName, - out string? effectiveRole, - out string error) - { - effectiveRole = null; - error = string.Empty; - - string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); - - if (string.IsNullOrWhiteSpace(roleHeader)) - { - error = $"Client role header '{AuthorizationResolver.CLIENT_ROLE_HEADER}' is missing or empty."; - return false; - } - - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (roles.Length == 0) - { - error = $"Client role header '{AuthorizationResolver.CLIENT_ROLE_HEADER}' is missing or empty."; - return false; - } - - foreach (string role in roles) - { - bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( - entityName, role, EntityActionOperation.Read); - - if (allowed) - { - effectiveRole = role; - return true; - } - } - - 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. /// From 7f3f78695ba75f00cb3543125b954edb59e40b20 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 20:19:32 -0800 Subject: [PATCH 04/32] factor out metadata --- .../BuiltInTools/ExecuteEntityTool.cs | 26 +++++++---------- .../BuiltInTools/UpdateRecordTool.cs | 29 ++++++++----------- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index be2fa7af36..c661560db5 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -123,23 +123,17 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult("InvalidEntity", $"Entity {entity} cannot be executed.", logger); } - // 5) Resolve metadata - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try - { - dataSourceName = config.GetDataSourceNameFromEntityName(entity); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - } - catch (Exception) - { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Failed to resolve entity metadata for '{entity}'.", logger); - } - - if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entity, out DatabaseObject? dbObject) || dbObject is null) + // Use shared metadata helper. + if (!McpMetadataHelper.TryResolveMetadata( + entity, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Failed to resolve database object for entity '{entity}'.", logger); + return McpResponseBuilder.BuildErrorResult("EntityNotFound", metadataError, logger); } // 6) Authorization - Never bypass permissions diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 9e7d101fe6..65c76277c1 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; // added metadata helper using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -114,26 +115,20 @@ public async Task ExecuteAsync( return BuildErrorResult("InvalidArguments", parseError, logger); } - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - // 4) Resolve metadata for entity existence check - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try - { - dataSourceName = config.GetDataSourceNameFromEntityName(entityName); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - } - catch (Exception) - { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); - } - - if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) + // Use metadata helper to resolve metadata instead of manual resolution. + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return BuildErrorResult("EntityNotFound", metadataError, logger); } // 5) Authorization after we have a known entity From dec52914b6bda07aab734f5678e26cfad651deb1 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 20:39:13 -0800 Subject: [PATCH 05/32] factor out the auth usage in update tool --- .../BuiltInTools/UpdateRecordTool.cs | 55 +++---------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 65c76277c1..456049aec2 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -5,7 +5,7 @@ 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.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; @@ -141,7 +141,13 @@ public async Task ExecuteAsync( return 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)) + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + entityName, + EntityActionOperation.Update, + out string? effectiveRole, + out string authError)) { return BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); } @@ -297,51 +303,6 @@ private static bool TryParseArguments( return true; } - private static bool TryResolveAuthorizedRoleHasPermission( - HttpContext httpContext, - IAuthorizationResolver authorizationResolver, - string entityName, - out string? effectiveRole, - out string error) - { - effectiveRole = null; - error = string.Empty; - - string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); - - if (string.IsNullOrWhiteSpace(roleHeader)) - { - error = "Client role header is missing or empty."; - return false; - } - - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (roles.Length == 0) - { - error = "Client role header is missing or empty."; - return false; - } - - foreach (string role in roles) - { - bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( - entityName, role, EntityActionOperation.Update); - - if (allowed) - { - effectiveRole = role; - return true; - } - } - - error = "You do not have permission to update records for this entity."; - return false; - } - #endregion #region Response Builders & Utilities From 9ec2e66bee2039aa218df4cbff5e5f1ea6b4b232 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 21:08:49 -0800 Subject: [PATCH 06/32] factor out argument parsing from update record tool --- .../BuiltInTools/UpdateRecordTool.cs | 115 ++++-------------- 1 file changed, 24 insertions(+), 91 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 456049aec2..017c9ec766 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -5,7 +5,6 @@ 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; @@ -49,24 +48,24 @@ public Tool GetToolMetadata() Name = "update_record", Description = "STEP 1: describe_entities -> find entities with UPDATE permission and their key fields. STEP 2: call this tool with keys and new field values.", InputSchema = JsonSerializer.Deserialize( - @"{ - ""type"": ""object"", - ""properties"": { - ""entity"": { - ""type"": ""string"", - ""description"": ""Entity name with UPDATE permission."" - }, - ""keys"": { - ""type"": ""object"", - ""description"": ""Primary or composite keys identifying the record."" - }, - ""fields"": { - ""type"": ""object"", - ""description"": ""Fields and their new values."" - } - }, - ""required"": [""entity"", ""keys"", ""fields""] - }" +@"{ + ""type"": ""object"", + ""properties"": { + ""entity"": { + ""type"": ""string"", + ""description"": ""Entity name with UPDATE permission."" + }, + ""keys"": { + ""type"": ""object"", + ""description"": ""Primary or composite keys identifying the record."" + }, + ""fields"": { + ""type"": ""object"", + ""description"": ""Fields and their new values."" + } + }, + ""required"": [""entity"", ""keys"", ""fields""] +}" ) }; } @@ -110,7 +109,12 @@ public async Task ExecuteAsync( return BuildErrorResult("InvalidArguments", "No arguments provided.", logger); } - if (!TryParseArguments(arguments.RootElement, out string entityName, out Dictionary keys, out Dictionary fields, out string parseError)) + if (!McpArgumentParser.TryParseEntityKeysAndFields( + arguments.RootElement, + out string entityName, + out Dictionary keys, + out Dictionary fields, + out string parseError)) { return BuildErrorResult("InvalidArguments", parseError, logger); } @@ -118,7 +122,6 @@ public async Task ExecuteAsync( IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - // Use metadata helper to resolve metadata instead of manual resolution. if (!McpMetadataHelper.TryResolveMetadata( entityName, config, @@ -238,75 +241,6 @@ public async Task ExecuteAsync( } } - #region Parsing & Authorization - - private static bool TryParseArguments( - JsonElement root, - out string entityName, - out Dictionary keys, - out Dictionary fields, - out string error) - { - entityName = string.Empty; - keys = new Dictionary(); - fields = new Dictionary(); - error = string.Empty; - - if (!root.TryGetProperty("entity", out JsonElement entityEl) || - !root.TryGetProperty("keys", out JsonElement keysEl) || - !root.TryGetProperty("fields", out JsonElement fieldsEl)) - { - error = "Missing required arguments 'entity', 'keys', or 'fields'."; - return false; - } - - // Parse and validate required arguments: entity, keys, fields - entityName = entityEl.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(entityName)) - { - throw new ArgumentException("Entity is required", nameof(entityName)); - } - - if (keysEl.ValueKind != JsonValueKind.Object || fieldsEl.ValueKind != JsonValueKind.Object) - { - throw new ArgumentException("'keys' and 'fields' must be JSON objects."); - } - - try - { - keys = JsonSerializer.Deserialize>(keysEl.GetRawText()) ?? new Dictionary(); - fields = JsonSerializer.Deserialize>(fieldsEl.GetRawText()) ?? new Dictionary(); - } - catch (Exception ex) - { - throw new ArgumentException("Failed to parse 'keys' or 'fields'", ex); - } - - if (keys.Count == 0) - { - throw new ArgumentException("Keys are required to update an entity"); - } - - if (fields.Count == 0) - { - throw new ArgumentException("At least one field must be provided to update an entity", nameof(fields)); - } - - foreach (KeyValuePair kv in keys) - { - if (kv.Value is null || (kv.Value is string str && string.IsNullOrWhiteSpace(str))) - { - throw new ArgumentException($"Key value for '{kv.Key}' cannot be null or empty."); - } - } - - return true; - } - - #endregion - - #region Response Builders & Utilities - private static CallToolResult BuildSuccessResult( string entityName, JsonElement engineRootElement, @@ -423,6 +357,5 @@ private static string ExtractResultJson(IActionResult? result) } } - #endregion } } From f93205a1d5016969b96268f284af175fff1e9e85 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 21:37:34 -0800 Subject: [PATCH 07/32] erroneus warning ignore --- src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index d1dfa23de8..bb55a1f08c 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -1,11 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma warning disable IDE0005 // Using directive is unnecessary (analyzer noise) using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Microsoft.Extensions.DependencyInjection; -#pragma warning restore IDE0005 namespace Azure.DataApiBuilder.Mcp.Utils { From 268fc8e06af584d06dbc1922a826c53a1338bb39 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 21:42:22 -0800 Subject: [PATCH 08/32] import ordering? --- src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index bb55a1f08c..d4ef643eba 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Extensions.DependencyInjection; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Config.DatabasePrimitives; -using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Utils { From b3e1b99bf95df3e999597fb8062e68fe974b28f2 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 21:49:24 -0800 Subject: [PATCH 09/32] import ordering? --- src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index d4ef643eba..e50b959d1d 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -2,8 +2,8 @@ // Licensed under the MIT License. using Microsoft.Extensions.DependencyInjection; -using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; namespace Azure.DataApiBuilder.Mcp.Utils { From 827e3a8432d008eb056856ec94468764fb86d4e7 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 21:55:34 -0800 Subject: [PATCH 10/32] import ordering? --- src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index e50b959d1d..51988d902c 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Extensions.DependencyInjection; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Utils { From 02ad83af53cd40313c8905262c9e64f058323804 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:14:31 -0800 Subject: [PATCH 11/32] Update src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index f6ff5bb276..83dc23fc09 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -230,9 +230,10 @@ public async Task ExecuteAsync( IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(sqlMetadataProvider.GetDatabaseType()); JsonDocument? queryResult = await queryEngine.ExecuteAsync(context); + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IActionResult actionResult = queryResult is null - ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) - : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, serviceProvider.GetRequiredService().GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); + ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) + : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); // Normalize response string rawPayloadJson = ExtractResultJson(actionResult); From 92665c65ba11499db4d59e818385b832efe5345d Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:16:01 -0800 Subject: [PATCH 12/32] co-pilot comments --- .../BuiltInTools/CreateRecordTool.cs | 2 +- .../BuiltInTools/DeleteRecordTool.cs | 11 ++++------- .../BuiltInTools/ReadRecordsTool.cs | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 491443511e..f539e71a92 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -107,7 +107,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authorizationResolver, out string roleCtxError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: Unable to resolve a valid role context for create operation.", logger); + return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleCtxError} for create operation for entity: {entityName}.", logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 1fbacd2c66..92e7899bfa 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -9,7 +9,7 @@ using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; - +using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Mcp.Model; using Azure.DataApiBuilder.Mcp.Utils; using Azure.DataApiBuilder.Service.Exceptions; @@ -97,17 +97,14 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); } - // 4) Resolve metadata for entity existence check - string dataSourceName; - Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider; - + // 4) Resolve metadata for entity existence if (!McpMetadataHelper.TryResolveMetadata( entityName, config, serviceProvider, - out sqlMetadataProvider, + out ISqlMetadataProvider sqlMetadataProvider, out DatabaseObject dbObject, - out dataSourceName, + out string dataSourceName, out string metadataError)) { return McpResponseBuilder.BuildErrorResult("EntityNotFound", metadataError, logger); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index f6ff5bb276..ddd74ff61b 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -162,7 +162,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleCtxError)) { - return BuildErrorResult("PermissionDenied", $"You do not have permission to read records for entity '{entityName}'.", logger); + return BuildErrorResult("PermissionDenied", $"Permission denied: {roleCtxError} for read operation for entity: '{entityName}'.", logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( From b2974afe4b9fc08b5d09d8bd8b6a5c33cca2718e Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:26:37 -0800 Subject: [PATCH 13/32] remove comment --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index f539e71a92..059712fc01 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -81,7 +81,6 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); JsonElement root = arguments.RootElement; - // Use common parser if (!McpArgumentParser.TryParseEntityAndData(root, out string entityName, out JsonElement dataElement, out string parseError)) { return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); From ca56271fc32f36d1ea109130188e944635b70692 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:27:49 -0800 Subject: [PATCH 14/32] revert arbitrary change to style --- .../BuiltInTools/DeleteRecordTool.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 92e7899bfa..79710ef922 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -37,12 +37,14 @@ public class DeleteRecordTool : IMcpTool /// /// Gets the metadata for the delete-record tool, including its name, description, and input schema. /// - public Tool GetToolMetadata() => new() + public Tool GetToolMetadata() { - 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( - @"{ + 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"": { @@ -55,8 +57,10 @@ public class DeleteRecordTool : IMcpTool } }, ""required"": [""entity"", ""keys""] - }") - }; + }" + ) + }; + } /// /// Executes the delete-record tool, deleting an existing record in the specified entity using provided keys. From b983456742ab78392606e05c6b7cabdbe4d5f415 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:30:32 -0800 Subject: [PATCH 15/32] remove comment --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 70d4af467d..a111e27200 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -173,7 +173,6 @@ public async Task ExecuteAsync( out string? effectiveRole, out string readAuthError)) { - // Provide tool-specific message rather than generic helper message. string finalError = readAuthError.StartsWith("You do not have permission", StringComparison.OrdinalIgnoreCase) ? $"You do not have permission to read records for entity '{entityName}'." : readAuthError; From 346111cccdd60928f71e589c426097811b1edaa0 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:33:31 -0800 Subject: [PATCH 16/32] revert arbitrary change to style --- .../BuiltInTools/ReadRecordsTool.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index a111e27200..b127e41c6a 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -199,14 +199,20 @@ public async Task ExecuteAsync( context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}"); } - if (orderby is not null && orderby.Any()) + if (orderby is not null && orderby.Count() != 0) { - string sortQueryString = $"?{RequestParser.SORT_URL}=" + string.Join(", ", orderby.Where(p => !string.IsNullOrWhiteSpace(p))); - if (sortQueryString.EndsWith(", ")) + string sortQueryString = $"?{RequestParser.SORT_URL}="; + foreach (string param in orderby) { - sortQueryString = sortQueryString[..^2]; + if (string.IsNullOrWhiteSpace(param)) + { + return BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); + } + + sortQueryString += $"{param}, "; } + sortQueryString = sortQueryString.Substring(0, sortQueryString.Length - 2); (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = RequestParser.GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); } From 3b8a74db1863b58e820974e434ffdd30b1b695b2 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:38:48 -0800 Subject: [PATCH 17/32] use metadata objects from initialization --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index b127e41c6a..21b4967df5 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -237,8 +237,8 @@ public async Task ExecuteAsync( JsonDocument? queryResult = await queryEngine.ExecuteAsync(context); IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IActionResult actionResult = queryResult is null - ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) - : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); + ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, sqlMetadataProvider, runtimeConfig, httpContext, true) + : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, sqlMetadataProvider, runtimeConfig, httpContext, true); // Normalize response string rawPayloadJson = ExtractResultJson(actionResult); From de3fae36d24e99ce89eb86f25b5b62d01227a8fd Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:40:11 -0800 Subject: [PATCH 18/32] remove redundant comment --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 017c9ec766..c9f713f17c 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -12,7 +12,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; -using Azure.DataApiBuilder.Mcp.Utils; // added metadata helper +using Azure.DataApiBuilder.Mcp.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; From 8ffb753d7c07ade27348531fd35d9193f6c04cde Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:41:17 -0800 Subject: [PATCH 19/32] revert arbitrary style change --- .../BuiltInTools/UpdateRecordTool.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index c9f713f17c..179a8c530c 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -48,24 +48,24 @@ public Tool GetToolMetadata() Name = "update_record", Description = "STEP 1: describe_entities -> find entities with UPDATE permission and their key fields. STEP 2: call this tool with keys and new field values.", InputSchema = JsonSerializer.Deserialize( -@"{ - ""type"": ""object"", - ""properties"": { - ""entity"": { - ""type"": ""string"", - ""description"": ""Entity name with UPDATE permission."" - }, - ""keys"": { - ""type"": ""object"", - ""description"": ""Primary or composite keys identifying the record."" - }, - ""fields"": { - ""type"": ""object"", - ""description"": ""Fields and their new values."" - } - }, - ""required"": [""entity"", ""keys"", ""fields""] -}" + @"{ + ""type"": ""object"", + ""properties"": { + ""entity"": { + ""type"": ""string"", + ""description"": ""Entity name with UPDATE permission."" + }, + ""keys"": { + ""type"": ""object"", + ""description"": ""Primary or composite keys identifying the record."" + }, + ""fields"": { + ""type"": ""object"", + ""description"": ""Fields and their new values."" + } + }, + ""required"": [""entity"", ""keys"", ""fields""] + }" ) }; } From e5f945f1bb9a62521974d40edd815b859e922fd7 Mon Sep 17 00:00:00 2001 From: aaronburtle <93220300+aaronburtle@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:43:05 -0800 Subject: [PATCH 20/32] Update src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 179a8c530c..e699075673 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -119,8 +119,8 @@ public async Task ExecuteAsync( return BuildErrorResult("InvalidArguments", parseError, logger); } - IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); + IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); if (!McpMetadataHelper.TryResolveMetadata( entityName, From f9d0b75ea311fd7351f0619fc867a119fa124a66 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:43:57 -0800 Subject: [PATCH 21/32] remove redundant comment --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 21b4967df5..0fe93559b2 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -111,7 +111,6 @@ public async Task ExecuteAsync( JsonElement root = arguments.RootElement; - // Use common parser if (!McpArgumentParser.TryParseEntity(root, out entityName, out string parseError)) { return BuildErrorResult("InvalidArguments", parseError, logger); From 20a71add39c86054cd1b2b46af6ce29968f8d604 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:51:46 -0800 Subject: [PATCH 22/32] improved exception handling --- .../Utils/McpMetadataHelper.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index 51988d902c..ea4bb9b9e6 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -3,6 +3,7 @@ using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Service.Exceptions; // Added for DataApiBuilderException using Microsoft.Extensions.DependencyInjection; namespace Azure.DataApiBuilder.Mcp.Utils @@ -31,17 +32,41 @@ public static bool TryResolveMetadata( var metadataProviderFactory = serviceProvider.GetRequiredService(); + // Resolve datasource name for the entity. try { dataSourceName = config.GetDataSourceNameFromEntityName(entityName); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); } - catch (Exception) + catch (DataApiBuilderException dabEx) when (dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.EntityNotFound) { error = $"Entity '{entityName}' is not defined in the configuration."; return false; } + catch (DataApiBuilderException dabEx) + { + // Other DAB exceptions during entity->datasource resolution. + error = dabEx.Message; + return false; + } + + // Resolve metadata provider for the datasource. + try + { + sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); + } + catch (DataApiBuilderException dabEx) when (dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.DataSourceNotFound) + { + error = $"Data source '{dataSourceName}' for entity '{entityName}' is not defined in the configuration."; + return false; + } + catch (DataApiBuilderException dabEx) + { + // Other DAB exceptions during metadata provider resolution. + error = dabEx.Message; + return false; + } + // Validate entity exists in metadata mapping. if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? temp) || temp is null) { error = $"Entity '{entityName}' is not defined in the configuration."; From 0880293aa9d7c1537a33cf555e7f0906ac731545 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 18 Nov 2025 22:52:39 -0800 Subject: [PATCH 23/32] removed unused null forgiving op --- src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index ea4bb9b9e6..8be1ffdd78 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -73,7 +73,7 @@ public static bool TryResolveMetadata( return false; } - dbObject = temp!; + dbObject = temp; return true; } } From 1871f206b884670721a5f6c5f1c77e7b923b99a6 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 20 Nov 2025 15:41:40 -0800 Subject: [PATCH 24/32] remove redundant inner loggers --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs | 3 +-- src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 79710ef922..0d216417a4 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -323,8 +323,7 @@ public async Task ExecuteAsync( } catch (Exception ex) { - ILogger? innerLogger = serviceProvider.GetService>(); - innerLogger?.LogError(ex, "Unexpected error in DeleteRecordTool."); + logger?.LogError(ex, "Unexpected error in DeleteRecordTool."); return McpResponseBuilder.BuildErrorResult( "UnexpectedError", diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index e699075673..645a95e817 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -231,8 +231,7 @@ public async Task ExecuteAsync( } catch (Exception ex) { - ILogger? innerLogger = serviceProvider.GetService>(); - innerLogger?.LogError(ex, "Unexpected error in UpdateRecordTool."); + logger?.LogError(ex, "Unexpected error in UpdateRecordTool."); return BuildErrorResult( "UnexpectedError", From 40249c991e188454a51d3d1db062c30f56058acc Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 20 Nov 2025 16:10:13 -0800 Subject: [PATCH 25/32] addressing comments, cleaner namespace usage --- .../BuiltInTools/CreateRecordTool.cs | 3 +-- .../BuiltInTools/DeleteRecordTool.cs | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 059712fc01..21c2d8a31c 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -5,7 +5,6 @@ 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; @@ -91,7 +90,7 @@ public async Task ExecuteAsync( entityName, runtimeConfig, serviceProvider, - out Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider, + out ISqlMetadataProvider sqlMetadataProvider, out DatabaseObject dbObject, out string dataSourceName, out string metadataError)) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 0d216417a4..2b32f166af 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -10,6 +10,7 @@ using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; 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; @@ -142,8 +143,8 @@ public async Task ExecuteAsync( } // Need MetadataProviderFactory for RequestValidator; resolve here. - var metadataProviderFactory = serviceProvider.GetRequiredService(); - Azure.DataApiBuilder.Core.Services.RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); + RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); DeleteRequestContext context = new( entityName: entityName, From 57a51610fb1971cb76cbdc553e7a52bf433e3600 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 20 Nov 2025 16:31:15 -0800 Subject: [PATCH 26/32] move execute logic to helpers --- .../BuiltInTools/ExecuteEntityTool.cs | 44 +-------------- .../Utils/McpArgumentParser.cs | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 43 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index c661560db5..4803294376 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -98,7 +98,7 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); } - if (!TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary parameters, out string parseError)) + if (!McpArgumentParser.TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary parameters, out string parseError)) { return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); } @@ -315,48 +315,6 @@ public async Task ExecuteAsync( } } - /// - /// Parses the execute arguments from the JSON input. - /// - private static bool TryParseExecuteArguments( - JsonElement rootElement, - out string entity, - out Dictionary parameters, - out string parseError) - { - entity = string.Empty; - parameters = new Dictionary(); - parseError = string.Empty; - - if (rootElement.ValueKind != JsonValueKind.Object) - { - parseError = "Arguments must be an object"; - return false; - } - - // Extract entity name (required) - if (!rootElement.TryGetProperty("entity", out JsonElement entityElement) || - entityElement.ValueKind != JsonValueKind.String) - { - parseError = "Missing or invalid 'entity' parameter"; - return false; - } - - entity = entityElement.GetString() ?? string.Empty; - - // Extract parameters if provided (optional) - if (rootElement.TryGetProperty("parameters", out JsonElement parametersElement) && - parametersElement.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty property in parametersElement.EnumerateObject()) - { - parameters[property.Name] = GetParameterValue(property.Value); - } - } - - return true; - } - /// /// Converts a JSON element to its appropriate CLR type matching GraphQL data types. /// diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs index ccec602356..df170a4331 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs @@ -47,6 +47,7 @@ public static bool TryParseEntityAndData( out string error) { dataElement = default; + if (!TryParseEntity(root, out entityName, out error)) { return false; @@ -172,5 +173,58 @@ public static bool TryParseEntityKeysAndFields( return true; } + + /// + /// Parses the execute arguments from the JSON input. + /// + public static bool TryParseExecuteArguments( + JsonElement rootElement, + out string entity, + out Dictionary parameters, + out string parseError) + { + entity = string.Empty; + parameters = new Dictionary(); + + if (rootElement.ValueKind != JsonValueKind.Object) + { + parseError = "Arguments must be an object"; + return false; + } + + if (!TryParseEntity(rootElement, out entity, out parseError)) + { + return false; + } + + // Extract parameters if provided (optional) + if (rootElement.TryGetProperty("parameters", out JsonElement parametersElement) && + parametersElement.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in parametersElement.EnumerateObject()) + { + parameters[property.Name] = GetExecuteParameterValue(property.Value); + } + } + + return true; + } + + // Local helper replicating ExecuteEntityTool.GetParameterValue without refactoring other tools. + private static object? GetExecuteParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => + element.TryGetInt64(out long longValue) ? longValue : + element.TryGetDecimal(out decimal decimalValue) ? decimalValue : + element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } } } From 3d351c06afd81d77886f363d6815f1e487539c32 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 20 Nov 2025 16:37:29 -0800 Subject: [PATCH 27/32] Class summary for metadatahelper --- src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index 8be1ffdd78..e6ab33f540 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -8,6 +8,9 @@ namespace Azure.DataApiBuilder.Mcp.Utils { + /// + /// Utility class for resolving metadata and datasource information for MCP tools. + /// public static class McpMetadataHelper { public static bool TryResolveMetadata( From 9a85e4821ab48a9c3088496a182a45f1f320e5c2 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 25 Nov 2025 16:38:52 -0800 Subject: [PATCH 28/32] add cancelation tokens to helpers --- .../BuiltInTools/CreateRecordTool.cs | 6 ++-- .../Utils/McpArgumentParser.cs | 31 +++++++++++++------ .../Utils/McpMetadataHelper.cs | 8 ++++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 21c2d8a31c..58f0692495 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -95,7 +95,7 @@ public async Task ExecuteAsync( out string dataSourceName, out string metadataError)) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", metadataError, logger); + return McpResponseBuilder.BuildErrorResult("InvalidConfiguration", metadataError, logger); } // Create an HTTP context for authorization @@ -105,7 +105,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authorizationResolver, out string roleCtxError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleCtxError} for create operation for entity: {entityName}.", logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleCtxError} for create operation for entity: {entityName}.", logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -116,7 +116,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); } JsonElement insertPayloadRoot = dataElement.Clone(); diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs index df170a4331..02344c2956 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs @@ -16,8 +16,10 @@ public static class McpArgumentParser public static bool TryParseEntity( JsonElement root, out string entityName, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); entityName = string.Empty; error = string.Empty; @@ -44,11 +46,13 @@ public static bool TryParseEntityAndData( JsonElement root, out string entityName, out JsonElement dataElement, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); dataElement = default; - if (!TryParseEntity(root, out entityName, out error)) + if (!TryParseEntity(root, out entityName, out error, cancellationToken)) { return false; } @@ -75,10 +79,12 @@ public static bool TryParseEntityAndKeys( JsonElement root, out string entityName, out Dictionary keys, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); keys = new Dictionary(); - if (!TryParseEntity(root, out entityName, out error)) + if (!TryParseEntity(root, out entityName, out error, cancellationToken)) { return false; } @@ -114,6 +120,8 @@ public static bool TryParseEntityAndKeys( // Validate key values foreach (KeyValuePair kv in keys) { + cancellationToken.ThrowIfCancellationRequested(); + if (kv.Value is null || (kv.Value is string str && string.IsNullOrWhiteSpace(str))) { error = $"Primary key value for '{kv.Key}' cannot be null or empty"; @@ -132,12 +140,14 @@ public static bool TryParseEntityKeysAndFields( out string entityName, out Dictionary keys, out Dictionary fields, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); fields = new Dictionary(); // First parse entity and keys - if (!TryParseEntityAndKeys(root, out entityName, out keys, out error)) + if (!TryParseEntityAndKeys(root, out entityName, out keys, out error, cancellationToken)) { return false; } @@ -181,8 +191,10 @@ public static bool TryParseExecuteArguments( JsonElement rootElement, out string entity, out Dictionary parameters, - out string parseError) + out string parseError, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); entity = string.Empty; parameters = new Dictionary(); @@ -192,7 +204,7 @@ public static bool TryParseExecuteArguments( return false; } - if (!TryParseEntity(rootElement, out entity, out parseError)) + if (!TryParseEntity(rootElement, out entity, out parseError, cancellationToken)) { return false; } @@ -203,6 +215,7 @@ public static bool TryParseExecuteArguments( { foreach (JsonProperty property in parametersElement.EnumerateObject()) { + cancellationToken.ThrowIfCancellationRequested(); parameters[property.Name] = GetExecuteParameterValue(property.Value); } } diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs index e6ab33f540..1e79e86b15 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -20,8 +20,10 @@ public static bool TryResolveMetadata( out Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider, out DatabaseObject dbObject, out string dataSourceName, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); sqlMetadataProvider = default!; dbObject = default!; dataSourceName = string.Empty; @@ -38,6 +40,7 @@ public static bool TryResolveMetadata( // Resolve datasource name for the entity. try { + cancellationToken.ThrowIfCancellationRequested(); dataSourceName = config.GetDataSourceNameFromEntityName(entityName); } catch (DataApiBuilderException dabEx) when (dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.EntityNotFound) @@ -55,6 +58,7 @@ public static bool TryResolveMetadata( // Resolve metadata provider for the datasource. try { + cancellationToken.ThrowIfCancellationRequested(); sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); } catch (DataApiBuilderException dabEx) when (dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.DataSourceNotFound) @@ -69,6 +73,8 @@ public static bool TryResolveMetadata( return false; } + cancellationToken.ThrowIfCancellationRequested(); + // Validate entity exists in metadata mapping. if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? temp) || temp is null) { From db6d601b0b673d8f143d58ba553bd7f7336a8243 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 25 Nov 2025 16:41:02 -0800 Subject: [PATCH 29/32] replace var --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 2b32f166af..7d40c4f203 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -9,6 +9,7 @@ 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; @@ -163,7 +164,7 @@ public async Task ExecuteAsync( requestValidator.ValidatePrimaryKey(context); - var mutationEngineFactory = serviceProvider.GetRequiredService(); + IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(dbType); From e530e3c5cb9100aad11f1f2c5a036c7557eb9d02 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 25 Nov 2025 20:21:19 -0800 Subject: [PATCH 30/32] factor out common error paterns, use enums --- .../BuiltInTools/CreateRecordTool.cs | 5 +--- .../BuiltInTools/DeleteRecordTool.cs | 9 ++---- .../BuiltInTools/DescribeEntitiesTool.cs | 5 +--- .../BuiltInTools/ExecuteEntityTool.cs | 14 +++------- .../BuiltInTools/ReadRecordsTool.cs | 11 +++----- .../BuiltInTools/UpdateRecordTool.cs | 9 ++---- .../Model/McpErrorCode.cs | 14 ++++++++++ .../Utils/McpErrorHelpers.cs | 28 +++++++++++++++++++ 8 files changed, 58 insertions(+), 37 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs create mode 100644 src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 58f0692495..c4eae62f94 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -69,10 +69,7 @@ public async Task ExecuteAsync( if (runtimeConfig.McpDmlTools?.CreateRecord != true) { - return Utils.McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - "The create_record tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } try diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 7d40c4f203..e7ae31abb4 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -86,10 +86,7 @@ public async Task ExecuteAsync( // 2) Check if the tool is enabled in configuration before proceeding if (config.McpDmlTools?.DeleteRecord != true) { - return McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - $"The {GetToolMetadata().Name} tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } // 3) Parsing & basic argument validation @@ -129,7 +126,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleError}", logger); + return McpErrorHelpers.PermissionDenied(entityName, "delete", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -140,7 +137,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpErrorHelpers.PermissionDenied(entityName, "delete", authError, logger); } // Need MetadataProviderFactory for RequestValidator; resolve here. diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 154b37ee80..713c7eef05 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -77,10 +77,7 @@ public Task ExecuteAsync( if (!IsToolEnabled(runtimeConfig)) { - return Task.FromResult(McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - $"The {GetToolMetadata().Name} tool is disabled in the configuration.", - logger)); + return Task.FromResult(McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger)); } // Get authorization services to determine current user's role diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index 4803294376..98910b66ca 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -86,10 +86,7 @@ public async Task ExecuteAsync( // 2) Check if the tool is enabled in configuration before proceeding if (config.McpDmlTools?.ExecuteEntity != true) { - return McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - $"The {this.GetToolMetadata().Name} tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(this.GetToolMetadata().Name, logger); } // 3) Parsing & basic argument validation @@ -143,7 +140,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", roleError, logger); + return McpErrorHelpers.PermissionDenied(entity, "execute", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -154,7 +151,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpErrorHelpers.PermissionDenied(entity, "execute", authError, logger); } // 7) Validate parameters against metadata @@ -384,10 +381,7 @@ private static CallToolResult BuildExecuteSuccessResponse( } else if (queryResult is UnauthorizedObjectResult) { - return McpResponseBuilder.BuildErrorResult( - "PermissionDenied", - "You do not have permission to execute this entity", - logger); + return McpErrorHelpers.PermissionDenied(entityName, "execute", "You do not have permission to execute this entity", logger); } else { diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 0fe93559b2..578bda9dca 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -86,10 +86,7 @@ public async Task ExecuteAsync( if (runtimeConfig.McpDmlTools?.ReadRecords is not true) { - return BuildErrorResult( - "ToolDisabled", - "The read_records tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } try @@ -161,7 +158,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleCtxError)) { - return BuildErrorResult("PermissionDenied", $"Permission denied: {roleCtxError} for read operation for entity: '{entityName}'.", logger); + return McpErrorHelpers.PermissionDenied(entityName, "read", roleCtxError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -175,7 +172,7 @@ public async Task ExecuteAsync( string finalError = readAuthError.StartsWith("You do not have permission", StringComparison.OrdinalIgnoreCase) ? $"You do not have permission to read records for entity '{entityName}'." : readAuthError; - return BuildErrorResult("PermissionDenied", finalError, logger); + return McpErrorHelpers.PermissionDenied(entityName, "read", finalError, logger); } // Build and validate Find context @@ -227,7 +224,7 @@ public async Task ExecuteAsync( requirements: new[] { new ColumnsPermissionsRequirement() }); if (!authorizationResult.Succeeded) { - return BuildErrorResult("PermissionDenied", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); + return McpErrorHelpers.PermissionDenied(entityName, "read", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); } // Execute diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 645a95e817..5665bb2a91 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -92,10 +92,7 @@ public async Task ExecuteAsync( // 2)Check if the tool is enabled in configuration before proceeding. if (config.McpDmlTools?.UpdateRecord != true) { - return BuildErrorResult( - "ToolDisabled", - "The update_record tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } try @@ -141,7 +138,7 @@ 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 McpErrorHelpers.PermissionDenied(entityName, "update", "unable to resolve a valid role context for update operation.", logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -152,7 +149,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpErrorHelpers.PermissionDenied(entityName, "update", authError, logger); } // 6) Build and validate Upsert (UpdateIncremental) context diff --git a/src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs b/src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs new file mode 100644 index 0000000000..ed13f62783 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Mcp.Model +{ + /// + /// MCP error codes standardized for built-in tools. + /// + public enum McpErrorCode + { + ToolDisabled, + PermissionDenied + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs new file mode 100644 index 0000000000..f9923af4cc --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Utils +{ + /// + /// Helper utilities for creating standardized MCP error responses. + /// Only includes helpers currently being centralized. + /// + public static class McpErrorHelpers + { + public static CallToolResult PermissionDenied(string entityName, string operation, string detail, ILogger? logger) + { + string message = $"Permission denied for {operation} on entity '{entityName}'. {detail}"; + return McpResponseBuilder.BuildErrorResult(Model.McpErrorCode.PermissionDenied.ToString(), message, logger); + } + + // Centralized language for 'tool disabled' errors. Pass the tool name, e.g. "read_records". + public static CallToolResult ToolDisabled(string toolName, ILogger? logger) + { + string message = $"The {toolName} tool is disabled in the configuration."; + return McpResponseBuilder.BuildErrorResult(Model.McpErrorCode.ToolDisabled.ToString(), message, logger); + } + } +} From f372a5060a621e4083db0b962dc75790ee97fdff Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 25 Nov 2025 22:10:51 -0800 Subject: [PATCH 31/32] merging with main, resolving conflicts --- .../BuiltInTools/CreateRecordTool.cs | 30 ++-- .../BuiltInTools/DeleteRecordTool.cs | 6 +- .../BuiltInTools/ExecuteEntityTool.cs | 8 +- .../BuiltInTools/ReadRecordsTool.cs | 12 +- .../BuiltInTools/UpdateRecordTool.cs | 132 +----------------- .../Utils/McpErrorHelpers.cs | 6 +- 6 files changed, 37 insertions(+), 157 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 330abb858c..c689246e93 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -59,18 +59,18 @@ public async Task ExecuteAsync( string toolName = GetToolMetadata().Name; if (arguments == null) { - return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Invalid Arguments", "No arguments provided", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Invalid Configuration", "Runtime configuration not available", logger); + return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", "Runtime configuration not available.", logger); } if (runtimeConfig.McpDmlTools?.CreateRecord != true) { - return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); + return McpErrorHelpers.ToolDisabled(toolName, logger); } try @@ -80,7 +80,7 @@ public async Task ExecuteAsync( if (!McpArgumentParser.TryParseEntityAndData(root, out string entityName, out JsonElement dataElement, out string parseError)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } if (!McpMetadataHelper.TryResolveMetadata( @@ -92,7 +92,7 @@ public async Task ExecuteAsync( out string dataSourceName, out string metadataError)) { - return McpResponseBuilder.BuildErrorResult("InvalidConfiguration", metadataError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Create an HTTP context for authorization @@ -102,7 +102,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authorizationResolver, out string roleCtxError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleCtxError} for create operation for entity: {entityName}.", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", roleCtxError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -113,7 +113,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", authError, logger); } JsonElement insertPayloadRoot = dataElement.Clone(); @@ -134,12 +134,12 @@ public async Task ExecuteAsync( } catch (Exception ex) { - return Utils.McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); } } else { - return Utils.McpResponseBuilder.BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( toolName, "InvalidCreateTarget", "The create_record tool is only available for tables.", @@ -154,7 +154,7 @@ public async Task ExecuteAsync( if (result is CreatedResult createdResult) { - return Utils.McpResponseBuilder.BuildSuccessResult( + return McpResponseBuilder.BuildSuccessResult( new Dictionary { ["entity"] = entityName, @@ -169,7 +169,7 @@ public async Task ExecuteAsync( bool isError = objectResult.StatusCode.HasValue && objectResult.StatusCode.Value >= 400 && objectResult.StatusCode.Value != 403; if (isError) { - return Utils.McpResponseBuilder.BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( toolName, "CreateFailed", $"Failed to create record in entity '{entityName}'. Error: {JsonSerializer.Serialize(objectResult.Value)}", @@ -177,7 +177,7 @@ public async Task ExecuteAsync( } else { - return Utils.McpResponseBuilder.BuildSuccessResult( + return McpResponseBuilder.BuildSuccessResult( new Dictionary { ["entity"] = entityName, @@ -192,7 +192,7 @@ public async Task ExecuteAsync( { if (result is null) { - return Utils.McpResponseBuilder.BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( toolName, "UnexpectedError", $"Mutation engine returned null result for entity '{entityName}'", @@ -200,7 +200,7 @@ public async Task ExecuteAsync( } else { - return Utils.McpResponseBuilder.BuildSuccessResult( + return McpResponseBuilder.BuildSuccessResult( new Dictionary { ["entity"] = entityName, @@ -213,7 +213,7 @@ public async Task ExecuteAsync( } catch (Exception ex) { - return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger); + return 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 d171f0acc6..d7837c0103 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -111,7 +111,7 @@ public async Task ExecuteAsync( out string dataSourceName, out string metadataError)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", metadataError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Validate it's a table or view @@ -127,7 +127,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpErrorHelpers.PermissionDenied(entityName, "delete", roleError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -138,7 +138,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpErrorHelpers.PermissionDenied(entityName, "delete", authError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", authError, logger); } // Need MetadataProviderFactory for RequestValidator; resolve here. diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index e0c9f08f75..e780c8ddeb 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -131,7 +131,7 @@ public async Task ExecuteAsync( out string dataSourceName, out string metadataError)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", metadataError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // 6) Authorization - Never bypass permissions @@ -141,7 +141,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpErrorHelpers.PermissionDenied(entity, "execute", roleError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entity, "execute", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -152,7 +152,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpErrorHelpers.PermissionDenied(entity, "execute", authError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entity, "execute", authError, logger); } // 7) Validate parameters against metadata @@ -388,7 +388,7 @@ private static CallToolResult BuildExecuteSuccessResponse( } else if (queryResult is UnauthorizedObjectResult) { - return McpErrorHelpers.PermissionDenied(entityName, "execute", "You do not have permission to execute this entity", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "execute", "You do not have permission to execute this entity", logger); } else { diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index ea9fc26ddb..1ed91c30a8 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -87,7 +87,7 @@ public async Task ExecuteAsync( if (runtimeConfig.McpDmlTools?.ReadRecords is not true) { - return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); + return McpErrorHelpers.ToolDisabled(toolName, logger); } try @@ -111,7 +111,7 @@ public async Task ExecuteAsync( if (!McpArgumentParser.TryParseEntity(root, out entityName, out string parseError)) { - return BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } if (root.TryGetProperty("select", out JsonElement selectElement)) @@ -148,7 +148,7 @@ public async Task ExecuteAsync( out string dataSourceName, out string metadataError)) { - return BuildErrorResult("EntityNotFound", metadataError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Authorization check in the existing entity @@ -159,7 +159,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleCtxError)) { - return McpErrorHelpers.PermissionDenied(entityName, "read", roleCtxError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "read", roleCtxError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -173,7 +173,7 @@ public async Task ExecuteAsync( string finalError = readAuthError.StartsWith("You do not have permission", StringComparison.OrdinalIgnoreCase) ? $"You do not have permission to read records for entity '{entityName}'." : readAuthError; - return McpErrorHelpers.PermissionDenied(entityName, "read", finalError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "read", finalError, logger); } // Build and validate Find context @@ -225,7 +225,7 @@ public async Task ExecuteAsync( requirements: new[] { new ColumnsPermissionsRequirement() }); if (!authorizationResult.Succeeded) { - return McpErrorHelpers.PermissionDenied(entityName, "read", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "read", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); } // Execute diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index afa8d5c887..195e27a0cd 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -127,7 +127,7 @@ public async Task ExecuteAsync( out string dataSourceName, out string metadataError)) { - return BuildErrorResult("EntityNotFound", metadataError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // 5) Authorization after we have a known entity @@ -137,7 +137,7 @@ public async Task ExecuteAsync( if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) { - return McpErrorHelpers.PermissionDenied(entityName, "update", "unable to resolve a valid role context for update operation.", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", "unable to resolve a valid role context for update operation.", logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -148,7 +148,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpErrorHelpers.PermissionDenied(entityName, "update", authError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", authError, logger); } // 6) Build and validate Upsert (UpdateIncremental) context @@ -193,11 +193,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); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No record found with the given key.", logger); } else { @@ -248,128 +244,12 @@ public async Task ExecuteAsync( { logger?.LogError(ex, "Unexpected error in UpdateRecordTool."); - return BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", ex.Message ?? "An unexpected error occurred during the update operation.", logger); } } - - 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. - /// - 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() // fallback for arrays/objects - }; - } - - 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 "{}"; - } - } - } } diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs index f9923af4cc..75335b2db1 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs @@ -12,17 +12,17 @@ namespace Azure.DataApiBuilder.Mcp.Utils /// public static class McpErrorHelpers { - public static CallToolResult PermissionDenied(string entityName, string operation, string detail, ILogger? logger) + public static CallToolResult PermissionDenied(string toolName, string entityName, string operation, string detail, ILogger? logger) { string message = $"Permission denied for {operation} on entity '{entityName}'. {detail}"; - return McpResponseBuilder.BuildErrorResult(Model.McpErrorCode.PermissionDenied.ToString(), message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.PermissionDenied.ToString(), message, logger); } // Centralized language for 'tool disabled' errors. Pass the tool name, e.g. "read_records". public static CallToolResult ToolDisabled(string toolName, ILogger? logger) { string message = $"The {toolName} tool is disabled in the configuration."; - return McpResponseBuilder.BuildErrorResult(Model.McpErrorCode.ToolDisabled.ToString(), message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.ToolDisabled.ToString(), message, logger); } } } From 77ea79bc168ab16c16c88ab0e93121b2e3168ad5 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 25 Nov 2025 22:12:19 -0800 Subject: [PATCH 32/32] cleanup function call --- src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index c689246e93..1a944d115b 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -59,13 +59,13 @@ public async Task ExecuteAsync( string toolName = GetToolMetadata().Name; if (arguments == null) { - return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", "Runtime configuration not available.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", "Runtime configuration not available.", logger); } if (runtimeConfig.McpDmlTools?.CreateRecord != true)