Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ed51e3b
Refactor argument parsing
aaronburtle Nov 19, 2025
4f40315
add metadata helper and use in delete and read
aaronburtle Nov 19, 2025
a0a38fe
factor out auth
aaronburtle Nov 19, 2025
7f3f786
factor out metadata
aaronburtle Nov 19, 2025
dec5291
factor out the auth usage in update tool
aaronburtle Nov 19, 2025
9ec2e66
factor out argument parsing from update record tool
aaronburtle Nov 19, 2025
f93205a
erroneus warning ignore
aaronburtle Nov 19, 2025
268fc8e
import ordering?
aaronburtle Nov 19, 2025
b3e1b99
import ordering?
aaronburtle Nov 19, 2025
827e3a8
import ordering?
aaronburtle Nov 19, 2025
02ad83a
Update src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs
aaronburtle Nov 19, 2025
92665c6
co-pilot comments
aaronburtle Nov 19, 2025
f436184
Merge branch 'dev/aaronburtle/RefactorCommonCodeBuiltInMCPtools' of g…
aaronburtle Nov 19, 2025
b2974af
remove comment
aaronburtle Nov 19, 2025
ca56271
revert arbitrary change to style
aaronburtle Nov 19, 2025
b983456
remove comment
aaronburtle Nov 19, 2025
346111c
revert arbitrary change to style
aaronburtle Nov 19, 2025
3b8a74d
use metadata objects from initialization
aaronburtle Nov 19, 2025
de3fae3
remove redundant comment
aaronburtle Nov 19, 2025
8ffb753
revert arbitrary style change
aaronburtle Nov 19, 2025
e5f945f
Update src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs
aaronburtle Nov 19, 2025
f9d0b75
remove redundant comment
aaronburtle Nov 19, 2025
20a71ad
improved exception handling
aaronburtle Nov 19, 2025
0880293
removed unused null forgiving op
aaronburtle Nov 19, 2025
1871f20
remove redundant inner loggers
aaronburtle Nov 20, 2025
40249c9
addressing comments, cleaner namespace usage
aaronburtle Nov 21, 2025
57a5161
move execute logic to helpers
aaronburtle Nov 21, 2025
3d351c0
Class summary for metadatahelper
aaronburtle Nov 21, 2025
f13da80
Merge branch 'main' into dev/aaronburtle/RefactorCommonCodeBuiltInMCP…
aaronburtle Nov 21, 2025
c306d75
Merge branch 'main' into dev/aaronburtle/RefactorCommonCodeBuiltInMCP…
souvikghosh04 Nov 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 22 additions & 77 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
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;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -81,53 +80,41 @@ public async Task<CallToolResult> ExecuteAsync(
cancellationToken.ThrowIfCancellationRequested();
JsonElement root = arguments.RootElement;

if (!root.TryGetProperty("entity", out JsonElement entityElement) ||
!root.TryGetProperty("data", out JsonElement dataElement))
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);
return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger);
}

string entityName = entityElement.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(entityName))
// Use metadata helper for data source/provider/dbObject resolution.
if (!McpMetadataHelper.TryResolveMetadata(
entityName,
runtimeConfig,
serviceProvider,
out ISqlMetadataProvider sqlMetadataProvider,
out DatabaseObject dbObject,
out string dataSourceName,
out string metadataError))
{
return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", "Entity name cannot be empty", 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<IMetadataProviderFactory>();
ISqlMetadataProvider sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName);

DatabaseObject dbObject;
try
{
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
IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
HttpContext httpContext = httpContextAccessor.HttpContext ?? new DefaultHttpContext();
IAuthorizationResolver authorizationResolver = serviceProvider.GetRequiredService<IAuthorizationResolver>();

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: {roleCtxError} for create operation for entity: {entityName}.", logger);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could probably move these also into reusable functions or enums for permission denied type of errors in MCP.

}

// 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);
}
Expand Down Expand Up @@ -229,47 +216,5 @@ public async Task<CallToolResult> 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;
}
}
}
40 changes: 15 additions & 25 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
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;
Expand Down Expand Up @@ -88,7 +87,7 @@ public async Task<CallToolResult> ExecuteAsync(
{
return McpResponseBuilder.BuildErrorResult(
"ToolDisabled",
$"The {this.GetToolMetadata().Name} tool is disabled in the configuration.",
$"The {GetToolMetadata().Name} tool is disabled in the configuration.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can refactor these too since its same text in other tools as well ensuring these are always uniform.

logger);
}

Expand All @@ -103,26 +102,17 @@ public async Task<CallToolResult> ExecuteAsync(
return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger);
}

IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService<IMetadataProviderFactory>();
IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService<IMutationEngineFactory>();

// 4) Resolve metadata for entity existence check
string dataSourceName;
ISqlMetadataProvider sqlMetadataProvider;

try
{
dataSourceName = config.GetDataSourceNameFromEntityName(entityName);
sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName);
}
catch (Exception)
// 4) Resolve metadata for entity existence
if (!McpMetadataHelper.TryResolveMetadata(
entityName,
config,
serviceProvider,
out ISqlMetadataProvider sqlMetadataProvider,
out DatabaseObject dbObject,
out string dataSourceName,
out string metadataError))
{
return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger);
}

if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null)
{
return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger);
return McpResponseBuilder.BuildErrorResult("EntityNotFound", metadataError, logger);
}

// Validate it's a table or view
Expand Down Expand Up @@ -152,7 +142,8 @@ public async Task<CallToolResult> ExecuteAsync(
return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger);
}

// 6) Build and validate Delete context
// Need MetadataProviderFactory for RequestValidator; resolve here.
IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService<IMetadataProviderFactory>();
RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider);

DeleteRequestContext context = new(
Expand All @@ -172,7 +163,7 @@ public async Task<CallToolResult> ExecuteAsync(

requestValidator.ValidatePrimaryKey(context);

// 7) Execute
var mutationEngineFactory = serviceProvider.GetRequiredService<Azure.DataApiBuilder.Core.Resolvers.Factories.IMutationEngineFactory>();
DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(dbType);

Expand Down Expand Up @@ -333,8 +324,7 @@ public async Task<CallToolResult> ExecuteAsync(
}
catch (Exception ex)
{
ILogger<DeleteRecordTool>? innerLogger = serviceProvider.GetService<ILogger<DeleteRecordTool>>();
innerLogger?.LogError(ex, "Unexpected error in DeleteRecordTool.");
logger?.LogError(ex, "Unexpected error in DeleteRecordTool.");

return McpResponseBuilder.BuildErrorResult(
"UnexpectedError",
Expand Down
70 changes: 11 additions & 59 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public async Task<CallToolResult> ExecuteAsync(
return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger);
}

if (!TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary<string, object?> parameters, out string parseError))
if (!McpArgumentParser.TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary<string, object?> parameters, out string parseError))
{
return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger);
}
Expand All @@ -123,23 +123,17 @@ public async Task<CallToolResult> 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
Expand Down Expand Up @@ -321,48 +315,6 @@ public async Task<CallToolResult> ExecuteAsync(
}
}

/// <summary>
/// Parses the execute arguments from the JSON input.
/// </summary>
private static bool TryParseExecuteArguments(
JsonElement rootElement,
out string entity,
out Dictionary<string, object?> parameters,
out string parseError)
{
entity = string.Empty;
parameters = new Dictionary<string, object?>();
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;
}

/// <summary>
/// Converts a JSON element to its appropriate CLR type matching GraphQL data types.
/// </summary>
Expand Down
Loading