Skip to content

Commit 555faaa

Browse files
aaronburtleCopilotsouvikghosh04
authored
Refactor common code shared amongst built in MCP tools (#2986)
## Why make this change? Closes #2932 ## What is this change? Add helper class `McpMetadataHelper`, extend `McpArgumentParser`, and utilize `McpAuthorizationHelper` to factor out common code. We now do the initialization of the metadata, the parsing of arguments, and the authorization checks in these shared helper classes. ## How was this tested? With MCP Inspector and against the normal test suite. * DESCRIBE_ENTITIES <img width="427" height="653" alt="image" src="https://github.com/user-attachments/assets/7ba74cfb-5a71-402b-afd2-17f7a24d0295" /> * CREATE <img width="1435" height="655" alt="image" src="https://github.com/user-attachments/assets/f189bb22-6f25-46ef-b2f0-20e80bc2850f" /> * READ <img width="1131" height="651" alt="image" src="https://github.com/user-attachments/assets/16f3e6f6-24e9-4613-a8fd-61546b199305" /> * UPDATE <img width="1083" height="292" alt="image" src="https://github.com/user-attachments/assets/ce284b6f-1f2f-4dc4-b73d-8242605ee20a" /> * DELETE <img width="1425" height="648" alt="image" src="https://github.com/user-attachments/assets/8768baf6-96f3-441c-b47d-c17e5fae9300" /> ## Sample Request(s) N/A --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Souvik Ghosh <[email protected]>
1 parent 499cd24 commit 555faaa

File tree

10 files changed

+386
-448
lines changed

10 files changed

+386
-448
lines changed

src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs

Lines changed: 33 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
using Azure.DataApiBuilder.Auth;
66
using Azure.DataApiBuilder.Config.DatabasePrimitives;
77
using Azure.DataApiBuilder.Config.ObjectModel;
8-
using Azure.DataApiBuilder.Core.Authorization;
98
using Azure.DataApiBuilder.Core.Configurations;
109
using Azure.DataApiBuilder.Core.Models;
1110
using Azure.DataApiBuilder.Core.Resolvers;
1211
using Azure.DataApiBuilder.Core.Resolvers.Factories;
1312
using Azure.DataApiBuilder.Core.Services;
14-
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
1513
using Azure.DataApiBuilder.Mcp.Model;
14+
using Azure.DataApiBuilder.Mcp.Utils;
1615
using Microsoft.AspNetCore.Http;
1716
using Microsoft.AspNetCore.Mvc;
1817
using Microsoft.Extensions.DependencyInjection;
@@ -60,78 +59,61 @@ public async Task<CallToolResult> ExecuteAsync(
6059
string toolName = GetToolMetadata().Name;
6160
if (arguments == null)
6261
{
63-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Invalid Arguments", "No arguments provided", logger);
62+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger);
6463
}
6564

6665
RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
6766
if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig))
6867
{
69-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Invalid Configuration", "Runtime configuration not available", logger);
68+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", "Runtime configuration not available.", logger);
7069
}
7170

7271
if (runtimeConfig.McpDmlTools?.CreateRecord != true)
7372
{
74-
return Utils.McpResponseBuilder.BuildErrorResult(
75-
toolName,
76-
"ToolDisabled",
77-
"The create_record tool is disabled in the configuration.",
78-
logger);
73+
return McpErrorHelpers.ToolDisabled(toolName, logger);
7974
}
8075

8176
try
8277
{
8378
cancellationToken.ThrowIfCancellationRequested();
8479
JsonElement root = arguments.RootElement;
8580

86-
if (!root.TryGetProperty("entity", out JsonElement entityElement) ||
87-
!root.TryGetProperty("data", out JsonElement dataElement))
81+
if (!McpArgumentParser.TryParseEntityAndData(root, out string entityName, out JsonElement dataElement, out string parseError))
8882
{
89-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Missing required arguments 'entity' or 'data'", logger);
83+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger);
9084
}
9185

92-
string entityName = entityElement.GetString() ?? string.Empty;
93-
if (string.IsNullOrWhiteSpace(entityName))
86+
if (!McpMetadataHelper.TryResolveMetadata(
87+
entityName,
88+
runtimeConfig,
89+
serviceProvider,
90+
out ISqlMetadataProvider sqlMetadataProvider,
91+
out DatabaseObject dbObject,
92+
out string dataSourceName,
93+
out string metadataError))
9494
{
95-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity name cannot be empty", logger);
96-
}
97-
98-
string dataSourceName;
99-
try
100-
{
101-
dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName);
102-
}
103-
catch (Exception)
104-
{
105-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", $"Entity '{entityName}' not found in configuration", logger);
106-
}
107-
108-
IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService<IMetadataProviderFactory>();
109-
ISqlMetadataProvider sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName);
110-
111-
DatabaseObject dbObject;
112-
try
113-
{
114-
dbObject = sqlMetadataProvider.GetDatabaseObjectByKey(entityName);
115-
}
116-
catch (Exception)
117-
{
118-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", $"Database object for entity '{entityName}' not found", logger);
95+
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
11996
}
12097

12198
// Create an HTTP context for authorization
12299
IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
123100
HttpContext httpContext = httpContextAccessor.HttpContext ?? new DefaultHttpContext();
124101
IAuthorizationResolver authorizationResolver = serviceProvider.GetRequiredService<IAuthorizationResolver>();
125102

126-
if (httpContext is null || !authorizationResolver.IsValidRoleContext(httpContext))
103+
if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authorizationResolver, out string roleCtxError))
127104
{
128-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", "Permission denied: Unable to resolve a valid role context for update operation.", logger);
105+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", roleCtxError, logger);
129106
}
130107

131-
// Validate that we have at least one role authorized for create
132-
if (!TryResolveAuthorizedRole(httpContext, authorizationResolver, entityName, out string authError))
108+
if (!McpAuthorizationHelper.TryResolveAuthorizedRole(
109+
httpContext,
110+
authorizationResolver,
111+
entityName,
112+
EntityActionOperation.Create,
113+
out string? effectiveRole,
114+
out string authError))
133115
{
134-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", authError, logger);
116+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", authError, logger);
135117
}
136118

137119
JsonElement insertPayloadRoot = dataElement.Clone();
@@ -152,12 +134,12 @@ public async Task<CallToolResult> ExecuteAsync(
152134
}
153135
catch (Exception ex)
154136
{
155-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger);
137+
return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger);
156138
}
157139
}
158140
else
159141
{
160-
return Utils.McpResponseBuilder.BuildErrorResult(
142+
return McpResponseBuilder.BuildErrorResult(
161143
toolName,
162144
"InvalidCreateTarget",
163145
"The create_record tool is only available for tables.",
@@ -172,7 +154,7 @@ public async Task<CallToolResult> ExecuteAsync(
172154

173155
if (result is CreatedResult createdResult)
174156
{
175-
return Utils.McpResponseBuilder.BuildSuccessResult(
157+
return McpResponseBuilder.BuildSuccessResult(
176158
new Dictionary<string, object?>
177159
{
178160
["entity"] = entityName,
@@ -187,15 +169,15 @@ public async Task<CallToolResult> ExecuteAsync(
187169
bool isError = objectResult.StatusCode.HasValue && objectResult.StatusCode.Value >= 400 && objectResult.StatusCode.Value != 403;
188170
if (isError)
189171
{
190-
return Utils.McpResponseBuilder.BuildErrorResult(
172+
return McpResponseBuilder.BuildErrorResult(
191173
toolName,
192174
"CreateFailed",
193175
$"Failed to create record in entity '{entityName}'. Error: {JsonSerializer.Serialize(objectResult.Value)}",
194176
logger);
195177
}
196178
else
197179
{
198-
return Utils.McpResponseBuilder.BuildSuccessResult(
180+
return McpResponseBuilder.BuildSuccessResult(
199181
new Dictionary<string, object?>
200182
{
201183
["entity"] = entityName,
@@ -210,15 +192,15 @@ public async Task<CallToolResult> ExecuteAsync(
210192
{
211193
if (result is null)
212194
{
213-
return Utils.McpResponseBuilder.BuildErrorResult(
195+
return McpResponseBuilder.BuildErrorResult(
214196
toolName,
215197
"UnexpectedError",
216198
$"Mutation engine returned null result for entity '{entityName}'",
217199
logger);
218200
}
219201
else
220202
{
221-
return Utils.McpResponseBuilder.BuildSuccessResult(
203+
return McpResponseBuilder.BuildSuccessResult(
222204
new Dictionary<string, object?>
223205
{
224206
["entity"] = entityName,
@@ -231,50 +213,8 @@ public async Task<CallToolResult> ExecuteAsync(
231213
}
232214
catch (Exception ex)
233215
{
234-
return Utils.McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger);
216+
return McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger);
235217
}
236218
}
237-
238-
private static bool TryResolveAuthorizedRole(
239-
HttpContext httpContext,
240-
IAuthorizationResolver authorizationResolver,
241-
string entityName,
242-
out string error)
243-
{
244-
error = string.Empty;
245-
246-
string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString();
247-
248-
if (string.IsNullOrWhiteSpace(roleHeader))
249-
{
250-
error = "Client role header is missing or empty.";
251-
return false;
252-
}
253-
254-
string[] roles = roleHeader
255-
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
256-
.Distinct(StringComparer.OrdinalIgnoreCase)
257-
.ToArray();
258-
259-
if (roles.Length == 0)
260-
{
261-
error = "Client role header is missing or empty.";
262-
return false;
263-
}
264-
265-
foreach (string role in roles)
266-
{
267-
bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity(
268-
entityName, role, EntityActionOperation.Create);
269-
270-
if (allowed)
271-
{
272-
return true;
273-
}
274-
}
275-
276-
error = "You do not have permission to create records for this entity.";
277-
return false;
278-
}
279219
}
280220
}

src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,7 @@ public async Task<CallToolResult> ExecuteAsync(
8787
// 2) Check if the tool is enabled in configuration before proceeding
8888
if (config.McpDmlTools?.DeleteRecord != true)
8989
{
90-
return McpResponseBuilder.BuildErrorResult(
91-
toolName,
92-
"ToolDisabled",
93-
$"The {this.GetToolMetadata().Name} tool is disabled in the configuration.",
94-
logger);
90+
return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger);
9591
}
9692

9793
// 3) Parsing & basic argument validation
@@ -105,26 +101,17 @@ public async Task<CallToolResult> ExecuteAsync(
105101
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger);
106102
}
107103

108-
IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService<IMetadataProviderFactory>();
109-
IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService<IMutationEngineFactory>();
110-
111-
// 4) Resolve metadata for entity existence check
112-
string dataSourceName;
113-
ISqlMetadataProvider sqlMetadataProvider;
114-
115-
try
104+
// 4) Resolve metadata for entity existence
105+
if (!McpMetadataHelper.TryResolveMetadata(
106+
entityName,
107+
config,
108+
serviceProvider,
109+
out ISqlMetadataProvider sqlMetadataProvider,
110+
out DatabaseObject dbObject,
111+
out string dataSourceName,
112+
out string metadataError))
116113
{
117-
dataSourceName = config.GetDataSourceNameFromEntityName(entityName);
118-
sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName);
119-
}
120-
catch (Exception)
121-
{
122-
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger);
123-
}
124-
125-
if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null)
126-
{
127-
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger);
114+
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
128115
}
129116

130117
// Validate it's a table or view
@@ -140,7 +127,7 @@ public async Task<CallToolResult> ExecuteAsync(
140127

141128
if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError))
142129
{
143-
return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", $"Permission denied: {roleError}", logger);
130+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", roleError, logger);
144131
}
145132

146133
if (!McpAuthorizationHelper.TryResolveAuthorizedRole(
@@ -151,10 +138,11 @@ public async Task<CallToolResult> ExecuteAsync(
151138
out string? effectiveRole,
152139
out string authError))
153140
{
154-
return McpResponseBuilder.BuildErrorResult(toolName, "PermissionDenied", $"Permission denied: {authError}", logger);
141+
return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", authError, logger);
155142
}
156143

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

160148
DeleteRequestContext context = new(
@@ -174,7 +162,7 @@ public async Task<CallToolResult> ExecuteAsync(
174162

175163
requestValidator.ValidatePrimaryKey(context);
176164

177-
// 7) Execute
165+
IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService<IMutationEngineFactory>();
178166
DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
179167
IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(dbType);
180168

@@ -343,8 +331,7 @@ public async Task<CallToolResult> ExecuteAsync(
343331
}
344332
catch (Exception ex)
345333
{
346-
ILogger<DeleteRecordTool>? innerLogger = serviceProvider.GetService<ILogger<DeleteRecordTool>>();
347-
innerLogger?.LogError(ex, "Unexpected error in DeleteRecordTool.");
334+
logger?.LogError(ex, "Unexpected error in DeleteRecordTool.");
348335

349336
return McpResponseBuilder.BuildErrorResult(
350337
toolName,

src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,7 @@ public Task<CallToolResult> ExecuteAsync(
7878

7979
if (!IsToolEnabled(runtimeConfig))
8080
{
81-
return Task.FromResult(McpResponseBuilder.BuildErrorResult(
82-
toolName,
83-
"ToolDisabled",
84-
$"The {GetToolMetadata().Name} tool is disabled in the configuration.",
85-
logger));
81+
return Task.FromResult(McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger));
8682
}
8783

8884
// Get authorization services to determine current user's role

0 commit comments

Comments
 (0)