Skip to content

Commit 505e0c2

Browse files
[MCP] Add the Create_Record Built in tool (#2906)
## Why make this change? Closes #2828 By adding the `Create_Record` tool to the MCP endpoint. ## What is this change? Add a built in tool, `Create_Record` which uses the `rest` infrastructure to form and validate the query needed to complete a create action. Behavior is at parity with the rest equivalent create operation. ## How was this tested? Manually tested Insomnia using post. <img width="904" height="382" alt="image" src="https://github.com/user-attachments/assets/691de33e-f948-445d-84b7-390136813d31" /> <img width="231" height="93" alt="image" src="https://github.com/user-attachments/assets/af97c5c0-f175-4786-9b6b-aaca8ebf3925" /> <img width="233" height="110" alt="image" src="https://github.com/user-attachments/assets/57e2f72f-ae0e-48f8-bf23-f0f258086dad" /> ## Sample Request(s) ``` { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "create_record", "arguments": { "entity": "Broker", "data": { "ID Number": 3, "First Name": "Michael", "Last Name": "Jordan" } } } } ``` --------- Co-authored-by: Aniruddh Munde <[email protected]>
1 parent a090e37 commit 505e0c2

File tree

1 file changed

+208
-20
lines changed

1 file changed

+208
-20
lines changed

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

Lines changed: 208 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@
22
// Licensed under the MIT License.
33

44
using System.Text.Json;
5+
using Azure.DataApiBuilder.Auth;
6+
using Azure.DataApiBuilder.Config.DatabasePrimitives;
7+
using Azure.DataApiBuilder.Config.ObjectModel;
8+
using Azure.DataApiBuilder.Core.Authorization;
9+
using Azure.DataApiBuilder.Core.Configurations;
10+
using Azure.DataApiBuilder.Core.Models;
11+
using Azure.DataApiBuilder.Core.Resolvers;
12+
using Azure.DataApiBuilder.Core.Resolvers.Factories;
13+
using Azure.DataApiBuilder.Core.Services;
14+
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
515
using Azure.DataApiBuilder.Mcp.Model;
16+
using Microsoft.AspNetCore.Http;
17+
using Microsoft.AspNetCore.Mvc;
18+
using Microsoft.Extensions.DependencyInjection;
19+
using Microsoft.Extensions.Logging;
620
using ModelContextProtocol.Protocol;
721
using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
822

@@ -16,7 +30,7 @@ public Tool GetToolMetadata()
1630
{
1731
return new Tool
1832
{
19-
Name = "create-record",
33+
Name = "create_record",
2034
Description = "Creates a new record in the specified entity.",
2135
InputSchema = JsonSerializer.Deserialize<JsonElement>(
2236
@"{
@@ -37,51 +51,225 @@ public Tool GetToolMetadata()
3751
};
3852
}
3953

40-
public Task<CallToolResult> ExecuteAsync(
54+
public async Task<CallToolResult> ExecuteAsync(
4155
JsonDocument? arguments,
4256
IServiceProvider serviceProvider,
4357
CancellationToken cancellationToken = default)
4458
{
59+
ILogger<CreateRecordTool>? logger = serviceProvider.GetService<ILogger<CreateRecordTool>>();
4560
if (arguments == null)
4661
{
47-
return Task.FromResult(new CallToolResult
48-
{
49-
Content = [new TextContentBlock { Type = "text", Text = "Error: No arguments provided" }]
50-
});
62+
return Utils.McpResponseBuilder.BuildErrorResult("Invalid Arguments", "No arguments provided", logger);
63+
}
64+
65+
RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
66+
if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig))
67+
{
68+
return Utils.McpResponseBuilder.BuildErrorResult("Invalid Configuration", "Runtime configuration not available", logger);
69+
}
70+
71+
if (runtimeConfig.McpDmlTools?.CreateRecord != true)
72+
{
73+
return Utils.McpResponseBuilder.BuildErrorResult(
74+
"ToolDisabled",
75+
"The create_record tool is disabled in the configuration.",
76+
logger);
5177
}
5278

5379
try
5480
{
55-
// Extract arguments
81+
cancellationToken.ThrowIfCancellationRequested();
5682
JsonElement root = arguments.RootElement;
5783

5884
if (!root.TryGetProperty("entity", out JsonElement entityElement) ||
5985
!root.TryGetProperty("data", out JsonElement dataElement))
6086
{
61-
return Task.FromResult(new CallToolResult
62-
{
63-
Content = [new TextContentBlock { Type = "text", Text = "Error: Missing required arguments 'entity' or 'data'" }]
64-
});
87+
return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", "Missing required arguments 'entity' or 'data'", logger);
6588
}
6689

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

69-
// TODO: Implement actual create logic using DAB's internal services
70-
// For now, return a placeholder response
71-
string result = $"Would create record in entity '{entityName}' with data: {dataElement.GetRawText()}";
119+
// Create an HTTP context for authorization
120+
IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
121+
HttpContext httpContext = httpContextAccessor.HttpContext ?? new DefaultHttpContext();
122+
IAuthorizationResolver authorizationResolver = serviceProvider.GetRequiredService<IAuthorizationResolver>();
72123

73-
return Task.FromResult(new CallToolResult
124+
if (httpContext is null || !authorizationResolver.IsValidRoleContext(httpContext))
74125
{
75-
Content = [new TextContentBlock { Type = "text", Text = result }]
76-
});
126+
return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: Unable to resolve a valid role context for update operation.", logger);
127+
}
128+
129+
// Validate that we have at least one role authorized for create
130+
if (!TryResolveAuthorizedRole(httpContext, authorizationResolver, entityName, out string authError))
131+
{
132+
return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger);
133+
}
134+
135+
JsonElement insertPayloadRoot = dataElement.Clone();
136+
InsertRequestContext insertRequestContext = new(
137+
entityName,
138+
dbObject,
139+
insertPayloadRoot,
140+
EntityActionOperation.Insert);
141+
142+
RequestValidator requestValidator = serviceProvider.GetRequiredService<RequestValidator>();
143+
144+
// Only validate tables
145+
if (dbObject.SourceType is EntitySourceType.Table)
146+
{
147+
try
148+
{
149+
requestValidator.ValidateInsertRequestContext(insertRequestContext);
150+
}
151+
catch (Exception ex)
152+
{
153+
return Utils.McpResponseBuilder.BuildErrorResult("ValidationFailed", $"Request validation failed: {ex.Message}", logger);
154+
}
155+
}
156+
else
157+
{
158+
return Utils.McpResponseBuilder.BuildErrorResult(
159+
"InvalidCreateTarget",
160+
"The create_record tool is only available for tables.",
161+
logger);
162+
}
163+
164+
IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService<IMutationEngineFactory>();
165+
DatabaseType databaseType = sqlMetadataProvider.GetDatabaseType();
166+
IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(databaseType);
167+
168+
IActionResult? result = await mutationEngine.ExecuteAsync(insertRequestContext);
169+
170+
if (result is CreatedResult createdResult)
171+
{
172+
return Utils.McpResponseBuilder.BuildSuccessResult(
173+
new Dictionary<string, object?>
174+
{
175+
["entity"] = entityName,
176+
["result"] = createdResult.Value,
177+
["message"] = $"Successfully created record in entity '{entityName}'"
178+
},
179+
logger,
180+
$"Successfully created record in entity '{entityName}'");
181+
}
182+
else if (result is ObjectResult objectResult)
183+
{
184+
bool isError = objectResult.StatusCode.HasValue && objectResult.StatusCode.Value >= 400 && objectResult.StatusCode.Value != 403;
185+
if (isError)
186+
{
187+
return Utils.McpResponseBuilder.BuildErrorResult(
188+
"CreateFailed",
189+
$"Failed to create record in entity '{entityName}'. Error: {JsonSerializer.Serialize(objectResult.Value)}",
190+
logger);
191+
}
192+
else
193+
{
194+
return Utils.McpResponseBuilder.BuildSuccessResult(
195+
new Dictionary<string, object?>
196+
{
197+
["entity"] = entityName,
198+
["result"] = objectResult.Value,
199+
["message"] = $"Successfully created record in entity '{entityName}'. Unable to perform read-back of inserted records."
200+
},
201+
logger,
202+
$"Successfully created record in entity '{entityName}'. Unable to perform read-back of inserted records.");
203+
}
204+
}
205+
else
206+
{
207+
if (result is null)
208+
{
209+
return Utils.McpResponseBuilder.BuildErrorResult(
210+
"UnexpectedError",
211+
$"Mutation engine returned null result for entity '{entityName}'",
212+
logger);
213+
}
214+
else
215+
{
216+
return Utils.McpResponseBuilder.BuildSuccessResult(
217+
new Dictionary<string, object?>
218+
{
219+
["entity"] = entityName,
220+
["message"] = $"Create operation completed with unexpected result type: {result.GetType().Name}"
221+
},
222+
logger,
223+
$"Create operation completed for entity '{entityName}' with unexpected result type: {result.GetType().Name}");
224+
}
225+
}
77226
}
78227
catch (Exception ex)
79228
{
80-
return Task.FromResult(new CallToolResult
229+
return Utils.McpResponseBuilder.BuildErrorResult("Error", $"Error: {ex.Message}", logger);
230+
}
231+
}
232+
233+
private static bool TryResolveAuthorizedRole(
234+
HttpContext httpContext,
235+
IAuthorizationResolver authorizationResolver,
236+
string entityName,
237+
out string error)
238+
{
239+
error = string.Empty;
240+
241+
string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString();
242+
243+
if (string.IsNullOrWhiteSpace(roleHeader))
244+
{
245+
error = "Client role header is missing or empty.";
246+
return false;
247+
}
248+
249+
string[] roles = roleHeader
250+
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
251+
.Distinct(StringComparer.OrdinalIgnoreCase)
252+
.ToArray();
253+
254+
if (roles.Length == 0)
255+
{
256+
error = "Client role header is missing or empty.";
257+
return false;
258+
}
259+
260+
foreach (string role in roles)
261+
{
262+
bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity(
263+
entityName, role, EntityActionOperation.Create);
264+
265+
if (allowed)
81266
{
82-
Content = [new TextContentBlock { Type = "text", Text = $"Error: {ex.Message}" }]
83-
});
267+
return true;
268+
}
84269
}
270+
271+
error = "You do not have permission to create records for this entity.";
272+
return false;
85273
}
86274
}
87275
}

0 commit comments

Comments
 (0)