From 580053107c52840c56afcb1f96f63567ef5ca8d4 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Fri, 6 Mar 2026 12:10:45 -0800 Subject: [PATCH 1/3] fix(mcp): allow create_record for views and added tests to verify the same. (#3196) Closes #3194 - MCP `create_record` tool incorrectly blocks views with error `"The create_record tool is only available for tables."` 1. Views are the required workaround for unsupported SQL data types (e.g., vector columns). Users could create views that omit unsupported columns and perform DML operations against those views. 2. This bug prevents INSERT operations via MCP on view entities, breaking a critical workflow for vector database scenarios. 1. Modified `CreateRecordTool.cs` to allow both tables and views to pass source type validation 2. Changed `else` block (which caught views) to`else if (EntitySourceType.StoredProcedure)` so only stored procedures are blocked 3. Views now fall through to the mutation engine, which already supports INSERT on updateable views 4. Updated error message to `"The create_record tool is only available for tables and views."` 5. Added 8 unit tests validating all DML tools (CreateRecord, ReadRecords, UpdateRecord, DeleteRecord) work with both Table and View source types - [ ] Integration Tests - [X] Unit Tests 1. `DmlTool_AllowsTablesAndViews` - 8 DataRow test cases verifying no `InvalidCreateTarget` error for views 2. Existing REST integration tests `InsertOneInViewTest` already validate view INSERT via same mutation engine Manually tested via MCP Inspector, to verify `create_record` calls succeeds on a view. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh Munde (cherry picked from commit e5bf26fdc43f06de985cf10e935e6f1eea0c85c1) --- .../BuiltInTools/CreateRecordTool.cs | 20 +-- .../BuiltInTools/ReadRecordsTool.cs | 6 + .../BuiltInTools/UpdateRecordTool.cs | 6 + .../McpRuntimeOptionsSerializationTests.cs | 157 ++++++++++++++++++ 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 1a944d115b..b3d40018c9 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -117,17 +117,23 @@ public async Task ExecuteAsync( } JsonElement insertPayloadRoot = dataElement.Clone(); + + // Validate it's a table or view - stored procedures use execute_entity + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + InsertRequestContext insertRequestContext = new( entityName, dbObject, insertPayloadRoot, EntityActionOperation.Insert); - RequestValidator requestValidator = serviceProvider.GetRequiredService(); - - // Only validate tables + // Only validate tables. For views, skip validation and let the database handle any errors. if (dbObject.SourceType is EntitySourceType.Table) { + RequestValidator requestValidator = serviceProvider.GetRequiredService(); try { requestValidator.ValidateInsertRequestContext(insertRequestContext); @@ -137,14 +143,6 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); } } - else - { - return McpResponseBuilder.BuildErrorResult( - toolName, - "InvalidCreateTarget", - "The create_record tool is only available for tables.", - logger); - } IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType databaseType = sqlMetadataProvider.GetDatabaseType(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 1ed91c30a8..3621b3a5d3 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -151,6 +151,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } + // Validate it's a table or view + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + // Authorization check in the existing entity IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); IAuthorizationService authorizationService = serviceProvider.GetRequiredService(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 195e27a0cd..2a9aa0624d 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -130,6 +130,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } + // Validate it's a table or view + if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. For stored procedures, use the execute_entity tool instead.", logger); + } + // 5) Authorization after we have a known entity IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs index eefa1f08f4..60171b8d0c 100644 --- a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -2,9 +2,24 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; namespace Azure.DataApiBuilder.Service.Tests.Configuration { @@ -191,6 +206,38 @@ public void TestBackwardCompatibilityDeserializationWithoutDescriptionField() Assert.IsNull(deserializedConfig.Runtime.Mcp.Description, "Description should be null when not present in JSON"); } + [DataTestMethod] + [DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")] + [DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")] + [DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")] + [DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")] + [DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")] + [DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")] + [DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")] + [DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")] + public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments) + { + RuntimeConfig config = sourceType == "View" + ? CreateRuntimeConfigWithSourceType("BookView", EntitySourceType.View, "dbo.vBooks") + : CreateRuntimeConfigWithSourceType("Book", EntitySourceType.Table, "books"); + + IServiceProvider serviceProvider = CreateMcpToolServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + if (result.IsError == true) + { + JsonElement content = ParseResultContent(result); + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + Assert.AreNotEqual("InvalidEntity", errorType.GetString() ?? string.Empty, + $"{sourceType} entities should not be blocked with InvalidEntity"); + } + } + } + /// /// Creates a minimal RuntimeConfig with the specified MCP options for testing. /// @@ -216,5 +263,115 @@ private static RuntimeConfig CreateMinimalConfigWithMcp(McpRuntimeOptions mcpOpt Entities: new RuntimeEntities(new Dictionary()) ); } + + private static RuntimeConfig CreateRuntimeConfigWithSourceType(string entityName, EntitySourceType sourceType, string sourceObject) + { + Dictionary entities = new() + { + [entityName] = new Entity( + Source: new EntitySource( + Object: sourceObject, + Type: sourceType, + Parameters: null, + KeyFields: new[] { "id" } + ), + GraphQL: new(entityName, entityName + "s"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "anonymous", Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) + }) + }, + Mappings: null, + Relationships: null + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true)), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)), + Entities: new RuntimeEntities(entities)); + } + + private static IServiceProvider CreateMcpToolServiceProvider(RuntimeConfig config) + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(configProvider); + + Mock mockAuthResolver = new(); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + services.AddSingleton(mockAuthResolver.Object); + + Mock mockHttpContext = new(); + Mock mockRequest = new(); + mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); + mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); + + Mock mockHttpContextAccessor = new(); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); + services.AddSingleton(mockHttpContextAccessor.Object); + + Mock mockSqlMetadataProvider = new(); + Dictionary entityToDatabaseObject = new(); + foreach (KeyValuePair entry in config.Entities) + { + EntitySourceType mappedSourceType = entry.Value.Source.Type ?? EntitySourceType.Table; + DatabaseObject dbObject = mappedSourceType == EntitySourceType.View + ? new DatabaseView("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.View } + : new DatabaseTable("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.Table }; + + entityToDatabaseObject[entry.Key] = dbObject; + } + + mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject); + mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL); + + Mock mockMetadataProviderFactory = new(); + mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockSqlMetadataProvider.Object); + services.AddSingleton(mockMetadataProviderFactory.Object); + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + private static JsonElement ParseResultContent(CallToolResult result) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + + private static IMcpTool CreateTool(string toolType) + { + return toolType switch + { + "ReadRecords" => new ReadRecordsTool(), + "CreateRecord" => new CreateRecordTool(), + "UpdateRecord" => new UpdateRecordTool(), + "DeleteRecord" => new DeleteRecordTool(), + _ => throw new System.ArgumentException($"Unknown tool type: {toolType}", nameof(toolType)) + }; + } } } From e08ced3a56cb1114652145ebd81a3f26d348df0d Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 18 Mar 2026 13:59:23 -0700 Subject: [PATCH 2/3] Moved tests to aMcpRuntimeOptionsSerializationTests --- .../Configuration/McpRuntimeOptionsSerializationTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs index 60171b8d0c..fa0b165756 100644 --- a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; -using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; From 9cca66afc5960b9f316e76636b15f579e1a995a8 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 18 Mar 2026 14:54:50 -0700 Subject: [PATCH 3/3] Moved the tests to a new test file. --- .../McpDmlToolViewSupportTests.cs | 171 ++++++++++++++++++ .../McpRuntimeOptionsSerializationTests.cs | 158 ---------------- 2 files changed, 171 insertions(+), 158 deletions(-) create mode 100644 src/Service.Tests/Configuration/McpDmlToolViewSupportTests.cs diff --git a/src/Service.Tests/Configuration/McpDmlToolViewSupportTests.cs b/src/Service.Tests/Configuration/McpDmlToolViewSupportTests.cs new file mode 100644 index 0000000000..892babee39 --- /dev/null +++ b/src/Service.Tests/Configuration/McpDmlToolViewSupportTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +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.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration +{ + [TestClass] + public class McpDmlToolViewSupportTests + { + [DataTestMethod] + [DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")] + [DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")] + [DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")] + [DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")] + [DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")] + [DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")] + [DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")] + [DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")] + public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments) + { + RuntimeConfig config = sourceType == "View" + ? CreateRuntimeConfigWithSourceType("BookView", EntitySourceType.View, "dbo.vBooks") + : CreateRuntimeConfigWithSourceType("Book", EntitySourceType.Table, "books"); + + IServiceProvider serviceProvider = CreateMcpToolServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + if (result.IsError == true) + { + JsonElement content = ParseResultContent(result); + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + Assert.AreNotEqual("InvalidEntity", errorType.GetString() ?? string.Empty, + $"{sourceType} entities should not be blocked with InvalidEntity"); + } + } + } + + private static RuntimeConfig CreateRuntimeConfigWithSourceType(string entityName, EntitySourceType sourceType, string sourceObject) + { + Dictionary entities = new() + { + [entityName] = new Entity( + Source: new EntitySource( + Object: sourceObject, + Type: sourceType, + Parameters: null, + KeyFields: new[] { "id" } + ), + GraphQL: new(entityName, entityName + "s"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission(Role: "anonymous", Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), + new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) + }) + }, + Mappings: null, + Relationships: null + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new( + Enabled: true, + Path: "/mcp", + DmlTools: new( + describeEntities: true, + readRecords: true, + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true)), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)), + Entities: new RuntimeEntities(entities)); + } + + private static IServiceProvider CreateMcpToolServiceProvider(RuntimeConfig config) + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(configProvider); + + Mock mockAuthResolver = new(); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + services.AddSingleton(mockAuthResolver.Object); + + Mock mockHttpContext = new(); + Mock mockRequest = new(); + mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); + mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); + + Mock mockHttpContextAccessor = new(); + mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); + services.AddSingleton(mockHttpContextAccessor.Object); + + Mock mockSqlMetadataProvider = new(); + Dictionary entityToDatabaseObject = new(); + foreach (KeyValuePair entry in config.Entities) + { + EntitySourceType mappedSourceType = entry.Value.Source.Type ?? EntitySourceType.Table; + DatabaseObject dbObject = mappedSourceType == EntitySourceType.View + ? new DatabaseView("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.View } + : new DatabaseTable("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.Table }; + + entityToDatabaseObject[entry.Key] = dbObject; + } + + mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject); + mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL); + + Mock mockMetadataProviderFactory = new(); + mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockSqlMetadataProvider.Object); + services.AddSingleton(mockMetadataProviderFactory.Object); + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + private static JsonElement ParseResultContent(CallToolResult result) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + + private static IMcpTool CreateTool(string toolType) + { + return toolType switch + { + "ReadRecords" => new ReadRecordsTool(), + "CreateRecord" => new CreateRecordTool(), + "UpdateRecord" => new UpdateRecordTool(), + "DeleteRecord" => new DeleteRecordTool(), + _ => throw new ArgumentException($"Unknown tool type: {toolType}", nameof(toolType)) + }; + } + } +} diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs index fa0b165756..eefa1f08f4 100644 --- a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -1,26 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; -using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Authorization; -using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Core.Services; -using Azure.DataApiBuilder.Core.Services.MetadataProviders; -using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Mcp.Model; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; -using ModelContextProtocol.Protocol; -using Moq; namespace Azure.DataApiBuilder.Service.Tests.Configuration { @@ -207,38 +191,6 @@ public void TestBackwardCompatibilityDeserializationWithoutDescriptionField() Assert.IsNull(deserializedConfig.Runtime.Mcp.Description, "Description should be null when not present in JSON"); } - [DataTestMethod] - [DataRow("CreateRecord", "Table", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows Table")] - [DataRow("CreateRecord", "View", "{\"entity\": \"BookView\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", DisplayName = "CreateRecord allows View")] - [DataRow("ReadRecords", "Table", "{\"entity\": \"Book\"}", DisplayName = "ReadRecords allows Table")] - [DataRow("ReadRecords", "View", "{\"entity\": \"BookView\"}", DisplayName = "ReadRecords allows View")] - [DataRow("UpdateRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows Table")] - [DataRow("UpdateRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", DisplayName = "UpdateRecord allows View")] - [DataRow("DeleteRecord", "Table", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows Table")] - [DataRow("DeleteRecord", "View", "{\"entity\": \"BookView\", \"keys\": {\"id\": 1}}", DisplayName = "DeleteRecord allows View")] - public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceType, string jsonArguments) - { - RuntimeConfig config = sourceType == "View" - ? CreateRuntimeConfigWithSourceType("BookView", EntitySourceType.View, "dbo.vBooks") - : CreateRuntimeConfigWithSourceType("Book", EntitySourceType.Table, "books"); - - IServiceProvider serviceProvider = CreateMcpToolServiceProvider(config); - IMcpTool tool = CreateTool(toolType); - JsonDocument arguments = JsonDocument.Parse(jsonArguments); - - CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - if (result.IsError == true) - { - JsonElement content = ParseResultContent(result); - if (content.TryGetProperty("error", out JsonElement error) && - error.TryGetProperty("type", out JsonElement errorType)) - { - Assert.AreNotEqual("InvalidEntity", errorType.GetString() ?? string.Empty, - $"{sourceType} entities should not be blocked with InvalidEntity"); - } - } - } - /// /// Creates a minimal RuntimeConfig with the specified MCP options for testing. /// @@ -264,115 +216,5 @@ private static RuntimeConfig CreateMinimalConfigWithMcp(McpRuntimeOptions mcpOpt Entities: new RuntimeEntities(new Dictionary()) ); } - - private static RuntimeConfig CreateRuntimeConfigWithSourceType(string entityName, EntitySourceType sourceType, string sourceObject) - { - Dictionary entities = new() - { - [entityName] = new Entity( - Source: new EntitySource( - Object: sourceObject, - Type: sourceType, - Parameters: null, - KeyFields: new[] { "id" } - ), - GraphQL: new(entityName, entityName + "s"), - Fields: null, - Rest: new(Enabled: true), - Permissions: new[] - { - new EntityPermission(Role: "anonymous", Actions: new[] - { - new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null), - new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null), - new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null), - new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null) - }) - }, - Mappings: null, - Relationships: null - ) - }; - - return new RuntimeConfig( - Schema: "test-schema", - DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new( - Enabled: true, - Path: "/mcp", - DmlTools: new( - describeEntities: true, - readRecords: true, - createRecord: true, - updateRecord: true, - deleteRecord: true, - executeEntity: true)), - Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)), - Entities: new RuntimeEntities(entities)); - } - - private static IServiceProvider CreateMcpToolServiceProvider(RuntimeConfig config) - { - ServiceCollection services = new(); - - RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); - services.AddSingleton(configProvider); - - Mock mockAuthResolver = new(); - mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); - services.AddSingleton(mockAuthResolver.Object); - - Mock mockHttpContext = new(); - Mock mockRequest = new(); - mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous"); - mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object); - - Mock mockHttpContextAccessor = new(); - mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object); - services.AddSingleton(mockHttpContextAccessor.Object); - - Mock mockSqlMetadataProvider = new(); - Dictionary entityToDatabaseObject = new(); - foreach (KeyValuePair entry in config.Entities) - { - EntitySourceType mappedSourceType = entry.Value.Source.Type ?? EntitySourceType.Table; - DatabaseObject dbObject = mappedSourceType == EntitySourceType.View - ? new DatabaseView("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.View } - : new DatabaseTable("dbo", entry.Value.Source.Object) { SourceType = EntitySourceType.Table }; - - entityToDatabaseObject[entry.Key] = dbObject; - } - - mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityToDatabaseObject); - mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL); - - Mock mockMetadataProviderFactory = new(); - mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockSqlMetadataProvider.Object); - services.AddSingleton(mockMetadataProviderFactory.Object); - services.AddLogging(); - - return services.BuildServiceProvider(); - } - - private static JsonElement ParseResultContent(CallToolResult result) - { - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - return JsonDocument.Parse(firstContent.Text).RootElement; - } - - private static IMcpTool CreateTool(string toolType) - { - return toolType switch - { - "ReadRecords" => new ReadRecordsTool(), - "CreateRecord" => new CreateRecordTool(), - "UpdateRecord" => new UpdateRecordTool(), - "DeleteRecord" => new DeleteRecordTool(), - _ => throw new System.ArgumentException($"Unknown tool type: {toolType}", nameof(toolType)) - }; - } } }