Skip to content

Commit 00e0b63

Browse files
authored
Merge branch 'main' into copilot/add-role-inheritance-permissions
2 parents 3f8b2ff + b5841fd commit 00e0b63

4 files changed

Lines changed: 194 additions & 13 deletions

File tree

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

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

126126
JsonElement insertPayloadRoot = dataElement.Clone();
127+
128+
// Validate it's a table or view - stored procedures use execute_entity
129+
if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
130+
{
131+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger);
132+
}
133+
127134
InsertRequestContext insertRequestContext = new(
128135
entityName,
129136
dbObject,
130137
insertPayloadRoot,
131138
EntityActionOperation.Insert);
132139

133-
RequestValidator requestValidator = serviceProvider.GetRequiredService<RequestValidator>();
134-
135-
// Only validate tables
140+
// Only validate tables. For views, skip validation and let the database handle any errors.
136141
if (dbObject.SourceType is EntitySourceType.Table)
137142
{
143+
RequestValidator requestValidator = serviceProvider.GetRequiredService<RequestValidator>();
138144
try
139145
{
140146
requestValidator.ValidateInsertRequestContext(insertRequestContext);
@@ -144,14 +150,6 @@ public async Task<CallToolResult> ExecuteAsync(
144150
return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger);
145151
}
146152
}
147-
else
148-
{
149-
return McpResponseBuilder.BuildErrorResult(
150-
toolName,
151-
"InvalidCreateTarget",
152-
"The create_record tool is only available for tables.",
153-
logger);
154-
}
155153

156154
IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService<IMutationEngineFactory>();
157155
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
@@ -158,6 +158,12 @@ public async Task<CallToolResult> ExecuteAsync(
158158
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
159159
}
160160

161+
// Validate it's a table or view
162+
if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
163+
{
164+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger);
165+
}
166+
161167
// Authorization check in the existing entity
162168
IAuthorizationResolver authResolver = serviceProvider.GetRequiredService<IAuthorizationResolver>();
163169
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
@@ -137,6 +137,12 @@ public async Task<CallToolResult> ExecuteAsync(
137137
return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
138138
}
139139

140+
// Validate it's a table or view
141+
if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
142+
{
143+
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger);
144+
}
145+
140146
// 5) Authorization after we have a known entity
141147
IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
142148
HttpContext? httpContext = httpContextAccessor.HttpContext;

src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
using System.Threading;
88
using System.Threading.Tasks;
99
using Azure.DataApiBuilder.Auth;
10+
using Azure.DataApiBuilder.Config.DatabasePrimitives;
1011
using Azure.DataApiBuilder.Config.ObjectModel;
1112
using Azure.DataApiBuilder.Core.Authorization;
1213
using Azure.DataApiBuilder.Core.Configurations;
14+
using Azure.DataApiBuilder.Core.Services;
15+
using Azure.DataApiBuilder.Core.Services.MetadataProviders;
1316
using Azure.DataApiBuilder.Mcp.BuiltInTools;
1417
using Azure.DataApiBuilder.Mcp.Model;
1518
using Microsoft.AspNetCore.Http;
@@ -188,8 +191,74 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled()
188191
AssertToolDisabledError(content, "Custom tool is disabled for entity 'GetBook'");
189192
}
190193

194+
#region View Support Tests
195+
196+
/// <summary>
197+
/// Data-driven test to verify all DML tools allow both table and view entities.
198+
/// This is critical for scenarios like vector data type support, where users must:
199+
/// - Create a view that omits unsupported columns (e.g., vector columns)
200+
/// - Perform DML operations against that view
201+
/// </summary>
202+
/// <param name="toolType">The tool type to test.</param>
203+
/// <param name="sourceType">The entity source type (Table or View).</param>
204+
/// <param name="entityName">The entity name to use.</param>
205+
/// <param name="jsonArguments">The JSON arguments for the tool.</param>
206+
[DataTestMethod]
207+
[DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")]
208+
[DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")]
209+
[DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")]
210+
[DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")]
211+
[DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")]
212+
[DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")]
213+
[DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")]
214+
[DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")]
215+
public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments)
216+
{
217+
// Arrange
218+
RuntimeConfig config = sourceType == "View"
219+
? CreateConfigWithViewEntity()
220+
: CreateConfigWithDmlToolEnabledEntity();
221+
IServiceProvider serviceProvider = CreateServiceProvider(config);
222+
IMcpTool tool = CreateTool(toolType);
223+
224+
JsonDocument arguments = JsonDocument.Parse(jsonArguments);
225+
226+
// Act
227+
CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None);
228+
229+
// Assert - Should NOT be a source type blocking error (InvalidEntity)
230+
// Other errors like missing metadata are acceptable since we're testing source type validation
231+
if (result.IsError == true)
232+
{
233+
JsonElement content = ParseResultContent(result);
234+
235+
if (content.TryGetProperty("error", out JsonElement error) &&
236+
error.TryGetProperty("type", out JsonElement errorType))
237+
{
238+
string errorTypeValue = errorType.GetString() ?? string.Empty;
239+
240+
// This error type indicates the tool is blocking based on source type
241+
Assert.AreNotEqual("InvalidEntity", errorTypeValue,
242+
$"{sourceType} entities should not be blocked with InvalidEntity");
243+
}
244+
}
245+
}
246+
247+
#endregion
248+
191249
#region Helper Methods
192250

251+
/// <summary>
252+
/// Helper method to parse the JSON content from a CallToolResult without re-executing the tool.
253+
/// </summary>
254+
/// <param name="result">The result from executing an MCP tool.</param>
255+
/// <returns>The parsed JsonElement from the result's content.</returns>
256+
private static JsonElement ParseResultContent(CallToolResult result)
257+
{
258+
TextContentBlock firstContent = (TextContentBlock)result.Content[0];
259+
return JsonDocument.Parse(firstContent.Text).RootElement;
260+
}
261+
193262
/// <summary>
194263
/// Helper method to execute an MCP tool and return the parsed JsonElement from the result.
195264
/// </summary>
@@ -200,8 +269,7 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled()
200269
private static async Task<JsonElement> RunToolAsync(IMcpTool tool, JsonDocument arguments, IServiceProvider serviceProvider)
201270
{
202271
CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None);
203-
TextContentBlock firstContent = (TextContentBlock)result.Content[0];
204-
return JsonDocument.Parse(firstContent.Text).RootElement;
272+
return ParseResultContent(result);
205273
}
206274

207275
/// <summary>
@@ -517,8 +585,63 @@ private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled()
517585
);
518586
}
519587

588+
/// <summary>
589+
/// Creates a runtime config with a view entity.
590+
/// This is the key scenario for vector data type support.
591+
/// </summary>
592+
private static RuntimeConfig CreateConfigWithViewEntity()
593+
{
594+
Dictionary<string, Entity> entities = new()
595+
{
596+
["BookView"] = new Entity(
597+
Source: new EntitySource(
598+
Object: "dbo.vBooks",
599+
Type: EntitySourceType.View,
600+
Parameters: null,
601+
KeyFields: new[] { "id" }
602+
),
603+
GraphQL: new("BookView", "BookViews"),
604+
Fields: null,
605+
Rest: new(Enabled: true),
606+
Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
607+
new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null),
608+
new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null),
609+
new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null),
610+
new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null)
611+
}) },
612+
Mappings: null,
613+
Relationships: null,
614+
Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true)
615+
)
616+
};
617+
618+
return new RuntimeConfig(
619+
Schema: "test-schema",
620+
DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
621+
Runtime: new(
622+
Rest: new(),
623+
GraphQL: new(),
624+
Mcp: new(
625+
Enabled: true,
626+
Path: "/mcp",
627+
DmlTools: new(
628+
describeEntities: true,
629+
readRecords: true,
630+
createRecord: true,
631+
updateRecord: true,
632+
deleteRecord: true,
633+
executeEntity: true
634+
)
635+
),
636+
Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
637+
),
638+
Entities: new(entities)
639+
);
640+
}
641+
520642
/// <summary>
521643
/// Creates a service provider with mocked dependencies for testing MCP tools.
644+
/// Includes metadata provider mocks so tests can reach source type validation.
522645
/// </summary>
523646
private static IServiceProvider CreateServiceProvider(RuntimeConfig config)
524647
{
@@ -540,6 +663,54 @@ private static IServiceProvider CreateServiceProvider(RuntimeConfig config)
540663
mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object);
541664
services.AddSingleton(mockHttpContextAccessor.Object);
542665

666+
// Add metadata provider mocks so tests can reach source type validation.
667+
// This is required for DmlTool_AllowsTablesAndViews to actually test the source type behavior.
668+
Mock<ISqlMetadataProvider> mockSqlMetadataProvider = new();
669+
Dictionary<string, DatabaseObject> entityToDatabaseObject = new();
670+
671+
// Add database objects for each entity in the config
672+
if (config.Entities != null)
673+
{
674+
foreach (KeyValuePair<string, Entity> kvp in config.Entities)
675+
{
676+
string entityName = kvp.Key;
677+
Entity entity = kvp.Value;
678+
EntitySourceType sourceType = entity.Source.Type ?? EntitySourceType.Table;
679+
680+
DatabaseObject dbObject;
681+
if (sourceType == EntitySourceType.View)
682+
{
683+
dbObject = new DatabaseView("dbo", entity.Source.Object)
684+
{
685+
SourceType = EntitySourceType.View
686+
};
687+
}
688+
else if (sourceType == EntitySourceType.StoredProcedure)
689+
{
690+
dbObject = new DatabaseStoredProcedure("dbo", entity.Source.Object)
691+
{
692+
SourceType = EntitySourceType.StoredProcedure
693+
};
694+
}
695+
else
696+
{
697+
dbObject = new DatabaseTable("dbo", entity.Source.Object)
698+
{
699+
SourceType = EntitySourceType.Table
700+
};
701+
}
702+
703+
entityToDatabaseObject[entityName] = dbObject;
704+
}
705+
}
706+
707+
mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject);
708+
mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL);
709+
710+
Mock<IMetadataProviderFactory> mockMetadataProviderFactory = new();
711+
mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny<string>())).Returns(mockSqlMetadataProvider.Object);
712+
services.AddSingleton(mockMetadataProviderFactory.Object);
713+
543714
services.AddLogging();
544715

545716
return services.BuildServiceProvider();

0 commit comments

Comments
 (0)