Skip to content

Commit a9d8494

Browse files
anushakolansouvikghosh04Aniruddh25CopilotRubenCerna2079
authored
Dev/backport mcp entity level config (#3251)
## Why make this change? This backport fixes MCP behavior where create_record incorrectly rejected view-backed entities with the error that the tool is only available for tables. Views are a required workaround for unsupported SQL data types in some scenarios (e.g., vector columns), so this fix restores expected INSERT behavior for those entities. Note: This backport includes prerequisite PRs #2989 and #3084 which were not previously in release/1.7 but are required for the test infrastructure. ## What is this change? Cherry-picked PR: 1. Add entity-level MCP configuration support #2989 2. Entity level check if MCP Tool is enabled #3084 3. fix(mcp): allow create_record for views and add tests to verify behavior #3196 ## How was this tested? 1. Existing and newly added tests in the source PR. 2. Manual verification of `create_record` against view-backed entities. ## Sample Request(s) N/A --------- Co-authored-by: Souvik Ghosh <souvikofficial04@gmail.com> Co-authored-by: Aniruddh Munde <anmunde@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com>
1 parent 0f415ae commit a9d8494

4 files changed

Lines changed: 192 additions & 11 deletions

File tree

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

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,23 @@ public async Task<CallToolResult> ExecuteAsync(
117117
}
118118

119119
JsonElement insertPayloadRoot = dataElement.Clone();
120+
121+
// Validate it's a table or view - stored procedures use execute_entity
122+
if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
123+
{
124+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger);
125+
}
126+
120127
InsertRequestContext insertRequestContext = new(
121128
entityName,
122129
dbObject,
123130
insertPayloadRoot,
124131
EntityActionOperation.Insert);
125132

126-
RequestValidator requestValidator = serviceProvider.GetRequiredService<RequestValidator>();
127-
128-
// Only validate tables
133+
// Only validate tables. For views, skip validation and let the database handle any errors.
129134
if (dbObject.SourceType is EntitySourceType.Table)
130135
{
136+
RequestValidator requestValidator = serviceProvider.GetRequiredService<RequestValidator>();
131137
try
132138
{
133139
requestValidator.ValidateInsertRequestContext(insertRequestContext);
@@ -137,14 +143,6 @@ public async Task<CallToolResult> ExecuteAsync(
137143
return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger);
138144
}
139145
}
140-
else
141-
{
142-
return McpResponseBuilder.BuildErrorResult(
143-
toolName,
144-
"InvalidCreateTarget",
145-
"The create_record tool is only available for tables.",
146-
logger);
147-
}
148146

149147
IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService<IMutationEngineFactory>();
150148
DatabaseType databaseType = sqlMetadataProvider.GetDatabaseType();

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ public async Task<CallToolResult> ExecuteAsync(
151151
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
152152
}
153153

154+
// Validate it's a table or view
155+
if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
156+
{
157+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger);
158+
}
159+
154160
// Authorization check in the existing entity
155161
IAuthorizationResolver authResolver = serviceProvider.GetRequiredService<IAuthorizationResolver>();
156162
IAuthorizationService authorizationService = serviceProvider.GetRequiredService<IAuthorizationService>();

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ public async Task<CallToolResult> ExecuteAsync(
130130
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
131131
}
132132

133+
// Validate it's a table or view
134+
if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
135+
{
136+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger);
137+
}
138+
133139
// 5) Authorization after we have a known entity
134140
IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
135141
HttpContext? httpContext = httpContextAccessor.HttpContext;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Text.Json;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Azure.DataApiBuilder.Auth;
10+
using Azure.DataApiBuilder.Config.DatabasePrimitives;
11+
using Azure.DataApiBuilder.Config.ObjectModel;
12+
using Azure.DataApiBuilder.Core.Authorization;
13+
using Azure.DataApiBuilder.Core.Configurations;
14+
using Azure.DataApiBuilder.Core.Services;
15+
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
16+
using Azure.DataApiBuilder.Mcp.BuiltInTools;
17+
using Azure.DataApiBuilder.Mcp.Model;
18+
using Microsoft.AspNetCore.Http;
19+
using Microsoft.Extensions.DependencyInjection;
20+
using Microsoft.VisualStudio.TestTools.UnitTesting;
21+
using ModelContextProtocol.Protocol;
22+
using Moq;
23+
24+
namespace Azure.DataApiBuilder.Service.Tests.Configuration
25+
{
26+
[TestClass]
27+
public class McpDmlToolViewSupportTests
28+
{
29+
[DataTestMethod]
30+
[DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")]
31+
[DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")]
32+
[DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")]
33+
[DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")]
34+
[DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")]
35+
[DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")]
36+
[DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")]
37+
[DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")]
38+
public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments)
39+
{
40+
RuntimeConfig config = sourceType == "View"
41+
? CreateRuntimeConfigWithSourceType("BookView", EntitySourceType.View, "dbo.vBooks")
42+
: CreateRuntimeConfigWithSourceType("Book", EntitySourceType.Table, "books");
43+
44+
IServiceProvider serviceProvider = CreateMcpToolServiceProvider(config);
45+
IMcpTool tool = CreateTool(toolType);
46+
JsonDocument arguments = JsonDocument.Parse(jsonArguments);
47+
48+
CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None);
49+
if (result.IsError == true)
50+
{
51+
JsonElement content = ParseResultContent(result);
52+
if (content.TryGetProperty("error", out JsonElement error) &&
53+
error.TryGetProperty("type", out JsonElement errorType))
54+
{
55+
Assert.AreNotEqual("InvalidEntity", errorType.GetString() ?? string.Empty,
56+
$"{sourceType} entities should not be blocked with InvalidEntity");
57+
}
58+
}
59+
}
60+
61+
private static RuntimeConfig CreateRuntimeConfigWithSourceType(string entityName, EntitySourceType sourceType, string sourceObject)
62+
{
63+
Dictionary<string, Entity> entities = new()
64+
{
65+
[entityName] = new Entity(
66+
Source: new EntitySource(
67+
Object: sourceObject,
68+
Type: sourceType,
69+
Parameters: null,
70+
KeyFields: new[] { "id" }
71+
),
72+
GraphQL: new(entityName, entityName + "s"),
73+
Fields: null,
74+
Rest: new(Enabled: true),
75+
Permissions: new[]
76+
{
77+
new EntityPermission(Role: "anonymous", Actions: new[]
78+
{
79+
new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null),
80+
new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null),
81+
new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null),
82+
new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null)
83+
})
84+
},
85+
Mappings: null,
86+
Relationships: null
87+
)
88+
};
89+
90+
return new RuntimeConfig(
91+
Schema: "test-schema",
92+
DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
93+
Runtime: new(
94+
Rest: new(),
95+
GraphQL: new(),
96+
Mcp: new(
97+
Enabled: true,
98+
Path: "/mcp",
99+
DmlTools: new(
100+
describeEntities: true,
101+
readRecords: true,
102+
createRecord: true,
103+
updateRecord: true,
104+
deleteRecord: true,
105+
executeEntity: true)),
106+
Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)),
107+
Entities: new RuntimeEntities(entities));
108+
}
109+
110+
private static IServiceProvider CreateMcpToolServiceProvider(RuntimeConfig config)
111+
{
112+
ServiceCollection services = new();
113+
114+
RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
115+
services.AddSingleton(configProvider);
116+
117+
Mock<IAuthorizationResolver> mockAuthResolver = new();
118+
mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny<HttpContext>())).Returns(true);
119+
services.AddSingleton(mockAuthResolver.Object);
120+
121+
Mock<HttpContext> mockHttpContext = new();
122+
Mock<HttpRequest> mockRequest = new();
123+
mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous");
124+
mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object);
125+
126+
Mock<IHttpContextAccessor> mockHttpContextAccessor = new();
127+
mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object);
128+
services.AddSingleton(mockHttpContextAccessor.Object);
129+
130+
Mock<ISqlMetadataProvider> mockSqlMetadataProvider = new();
131+
Dictionary<string, DatabaseObject> entityToDatabaseObject = new();
132+
foreach (KeyValuePair<string, Entity> entry in config.Entities)
133+
{
134+
EntitySourceType mappedSourceType = entry.Value.Source.Type ?? EntitySourceType.Table;
135+
DatabaseObject dbObject = mappedSourceType == EntitySourceType.View
136+
? new DatabaseView("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.View }
137+
: new DatabaseTable("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.Table };
138+
139+
entityToDatabaseObject[entry.Key] = dbObject;
140+
}
141+
142+
mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject);
143+
mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL);
144+
145+
Mock<IMetadataProviderFactory> mockMetadataProviderFactory = new();
146+
mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny<string>())).Returns(mockSqlMetadataProvider.Object);
147+
services.AddSingleton(mockMetadataProviderFactory.Object);
148+
services.AddLogging();
149+
150+
return services.BuildServiceProvider();
151+
}
152+
153+
private static JsonElement ParseResultContent(CallToolResult result)
154+
{
155+
TextContentBlock firstContent = (TextContentBlock)result.Content[0];
156+
return JsonDocument.Parse(firstContent.Text).RootElement;
157+
}
158+
159+
private static IMcpTool CreateTool(string toolType)
160+
{
161+
return toolType switch
162+
{
163+
"ReadRecords" => new ReadRecordsTool(),
164+
"CreateRecord" => new CreateRecordTool(),
165+
"UpdateRecord" => new UpdateRecordTool(),
166+
"DeleteRecord" => new DeleteRecordTool(),
167+
_ => throw new ArgumentException($"Unknown tool type: {toolType}", nameof(toolType))
168+
};
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)