From b6df6ed4e7bb38d9cc9e4c7a11f4c4e775298fe5 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Fri, 6 Mar 2026 12:10:45 -0800 Subject: [PATCH] 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 b5841fdc3861a8f6930bc86e34b954797a28b3e3) --- .../BuiltInTools/CreateRecordTool.cs | 20 +- .../BuiltInTools/ReadRecordsTool.cs | 6 + .../BuiltInTools/UpdateRecordTool.cs | 6 + .../EntityLevelDmlToolConfigurationTests.cs | 721 ++++++++++++++++++ 4 files changed, 742 insertions(+), 11 deletions(-) create mode 100644 src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs 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/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs new file mode 100644 index 0000000000..278bc95cfb --- /dev/null +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -0,0 +1,721 @@ +// 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.Mcp +{ + /// + /// Tests for entity-level DML tool configuration (GitHub issue #3017). + /// Ensures that DML tools respect the entity-level Mcp.DmlToolEnabled property + /// in addition to the runtime-level configuration. + /// + /// Coverage: + /// - Entity with DmlToolEnabled=false (tool disabled at entity level) + /// - Entity with DmlToolEnabled=true (tool enabled at entity level) + /// - Entity with no MCP configuration (defaults to enabled) + /// - Custom tool with CustomToolEnabled=false (runtime validation) + /// + [TestClass] + public class EntityLevelDmlToolConfigurationTests + { + /// + /// Verifies that DML tools respect entity-level DmlToolEnabled=false. + /// When an entity has DmlToolEnabled explicitly set to false, the tool should + /// return a ToolDisabled error even if the runtime-level tool is enabled. + /// + /// The type of tool to test (ReadRecords, CreateRecord, UpdateRecord, DeleteRecord, ExecuteEntity). + /// The JSON arguments for the tool. + /// Whether the entity is a stored procedure (uses different config). + [DataTestMethod] + [DataRow("ReadRecords", "{\"entity\": \"Book\"}", false, DisplayName = "ReadRecords respects entity-level DmlToolEnabled=false")] + [DataRow("CreateRecord", "{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}", false, DisplayName = "CreateRecord respects entity-level DmlToolEnabled=false")] + [DataRow("UpdateRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", false, DisplayName = "UpdateRecord respects entity-level DmlToolEnabled=false")] + [DataRow("DeleteRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", false, DisplayName = "DeleteRecord respects entity-level DmlToolEnabled=false")] + [DataRow("ExecuteEntity", "{\"entity\": \"GetBook\"}", true, DisplayName = "ExecuteEntity respects entity-level DmlToolEnabled=false")] + public async Task DmlTool_RespectsEntityLevelDmlToolDisabled(string toolType, string jsonArguments, bool isStoredProcedure) + { + // Arrange + RuntimeConfig config = isStoredProcedure + ? CreateConfigWithDmlToolDisabledStoredProcedure() + : CreateConfigWithDmlToolDisabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); + } + + /// + /// Verifies that DML tools work normally when entity-level DmlToolEnabled is not set to false. + /// This test ensures the entity-level check doesn't break the normal flow when either: + /// - DmlToolEnabled=true (explicitly enabled) + /// - entity.Mcp is null (defaults to enabled) + /// + /// The test scenario description. + /// Whether to include MCP config with DmlToolEnabled=true (false means no MCP config). + [DataTestMethod] + [DataRow("DmlToolEnabled=true", true, DisplayName = "ReadRecords works when entity has DmlToolEnabled=true")] + [DataRow("No MCP config", false, DisplayName = "ReadRecords works when entity has no MCP config")] + public async Task ReadRecords_WorksWhenNotDisabledAtEntityLevel(string scenario, bool useMcpConfig) + { + // Arrange + RuntimeConfig config = useMcpConfig + ? CreateConfigWithDmlToolEnabledEntity() + : CreateConfigWithEntityWithoutMcpConfig(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ReadRecordsTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + // Should not be a ToolDisabled error - might be other errors (e.g., database connection) + // but that's OK for this test. We just want to ensure it passes the entity-level check. + if (result.IsError == true) + { + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + string errorTypeValue = errorType.GetString(); + Assert.AreNotEqual("ToolDisabled", errorTypeValue, + $"Should not get ToolDisabled error for scenario: {scenario}"); + } + } + } + + /// + /// Verifies the precedence of runtime-level vs entity-level configuration. + /// When runtime-level tool is disabled, entity-level DmlToolEnabled=true should NOT override it. + /// This validates that runtime-level acts as a global gate that takes precedence. + /// + [TestMethod] + public async Task ReadRecords_RuntimeDisabledTakesPrecedenceOverEntityEnabled() + { + // Arrange - Runtime has readRecords=false, but entity has DmlToolEnabled=true + RuntimeConfig config = CreateConfigWithRuntimeDisabledButEntityEnabled(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ReadRecordsTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when runtime-level tool is disabled"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); + + // Verify the error is due to runtime-level, not entity-level + // (The error message should NOT mention entity-specific disabling) + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("message", out JsonElement errorMessage)) + { + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsFalse(message.Contains("entity"), + "Error should be from runtime-level check, not entity-level check"); + } + } + + /// + /// Verifies that DynamicCustomTool respects entity-level CustomToolEnabled configuration. + /// If CustomToolEnabled becomes false (e.g., after config hot-reload), ExecuteAsync should + /// return a ToolDisabled error. This ensures runtime validation even though tool instances + /// are created at startup. + /// + [TestMethod] + public async Task DynamicCustomTool_RespectsCustomToolDisabled() + { + // Arrange - Create a stored procedure entity with CustomToolEnabled=false + RuntimeConfig config = CreateConfigWithCustomToolDisabled(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + + // Create the DynamicCustomTool with the entity that has CustomToolEnabled initially true + // (simulating tool created at startup, then config changed) + Entity initialEntity = new( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: true) + ); + + Azure.DataApiBuilder.Mcp.Core.DynamicCustomTool tool = new("GetBook", initialEntity); + + JsonDocument arguments = JsonDocument.Parse("{}"); + + // Act - Execute with config that has CustomToolEnabled=false + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when CustomToolEnabled=false in runtime config"); + + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content, "Custom tool is disabled for entity 'GetBook'"); + } + + #region View Support Tests + + /// + /// Data-driven test to verify all DML tools allow both table and view entities. + /// This is critical for scenarios like vector data type support, where users must: + /// - Create a view that omits unsupported columns (e.g., vector columns) + /// - Perform DML operations against that view + /// + /// The tool type to test. + /// The entity source type (Table or View). + /// The entity name to use. + /// The JSON arguments for the tool. + [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) + { + // Arrange + RuntimeConfig config = sourceType == "View" + ? CreateConfigWithViewEntity() + : CreateConfigWithDmlToolEnabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + IMcpTool tool = CreateTool(toolType); + + JsonDocument arguments = JsonDocument.Parse(jsonArguments); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert - Should NOT be a source type blocking error (InvalidEntity) + // Other errors like missing metadata are acceptable since we're testing source type validation + if (result.IsError == true) + { + JsonElement content = ParseResultContent(result); + + if (content.TryGetProperty("error", out JsonElement error) && + error.TryGetProperty("type", out JsonElement errorType)) + { + string errorTypeValue = errorType.GetString() ?? string.Empty; + + // This error type indicates the tool is blocking based on source type + Assert.AreNotEqual("InvalidEntity", errorTypeValue, + $"{sourceType} entities should not be blocked with InvalidEntity"); + } + } + } + + #endregion + + #region Helper Methods + + /// + /// Helper method to parse the JSON content from a CallToolResult without re-executing the tool. + /// + /// The result from executing an MCP tool. + /// The parsed JsonElement from the result's content. + private static JsonElement ParseResultContent(CallToolResult result) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + + /// + /// Helper method to execute an MCP tool and return the parsed JsonElement from the result. + /// + /// The MCP tool to execute. + /// The JSON arguments for the tool. + /// The service provider with dependencies. + /// The parsed JsonElement from the tool's response. + private static async Task RunToolAsync(IMcpTool tool, JsonDocument arguments, IServiceProvider serviceProvider) + { + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + return ParseResultContent(result); + } + + /// + /// Helper method to assert that a JsonElement contains a ToolDisabled error. + /// + /// The JSON content to check for error. + /// Optional message fragment that should be present in the error message. + private static void AssertToolDisabledError(JsonElement content, string expectedMessageFragment = null) + { + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + + if (expectedMessageFragment != null) + { + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains(expectedMessageFragment), + $"Expected error message to contain '{expectedMessageFragment}', but got: {message}"); + } + } + + /// + /// Helper method to create an MCP tool instance based on the tool type. + /// + /// The type of tool to create (ReadRecords, CreateRecord, UpdateRecord, DeleteRecord, ExecuteEntity). + /// An instance of the requested tool. + private static IMcpTool CreateTool(string toolType) + { + return toolType switch + { + "ReadRecords" => new ReadRecordsTool(), + "CreateRecord" => new CreateRecordTool(), + "UpdateRecord" => new UpdateRecordTool(), + "DeleteRecord" => new DeleteRecordTool(), + "ExecuteEntity" => new ExecuteEntityTool(), + _ => throw new ArgumentException($"Unknown tool type: {toolType}", nameof(toolType)) + }; + } + + /// + /// Creates a runtime config with a table entity that has DmlToolEnabled=false. + /// + private static RuntimeConfig CreateConfigWithDmlToolDisabledEntity() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + 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, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false) + ) + }; + + 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(entities) + ); + } + + /// + /// Creates a runtime config with a stored procedure that has DmlToolEnabled=false. + /// + private static RuntimeConfig CreateConfigWithDmlToolDisabledStoredProcedure() + { + Dictionary entities = new() + { + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false) + ) + }; + + 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(entities) + ); + } + + /// + /// Creates a runtime config with a table entity that has DmlToolEnabled=true. + /// + private static RuntimeConfig CreateConfigWithDmlToolEnabledEntity() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + 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(entities) + ); + } + + /// + /// Creates a runtime config with a table entity that has no MCP configuration. + /// + private static RuntimeConfig CreateConfigWithEntityWithoutMcpConfig() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: 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(entities) + ); + } + + /// + /// Creates a runtime config with a stored procedure that has CustomToolEnabled=false. + /// Used to test DynamicCustomTool runtime validation. + /// + private static RuntimeConfig CreateConfigWithCustomToolDisabled() + { + Dictionary entities = new() + { + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + 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(entities) + ); + } + + /// + /// Creates a runtime config where runtime-level readRecords is disabled, + /// but entity-level DmlToolEnabled is true. This tests precedence behavior. + /// + private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled() + { + Dictionary entities = new() + { + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) + }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + 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: false, // Runtime-level DISABLED + createRecord: true, + updateRecord: true, + deleteRecord: true, + executeEntity: true + ) + ), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with a view entity. + /// This is the key scenario for vector data type support. + /// + private static RuntimeConfig CreateConfigWithViewEntity() + { + Dictionary entities = new() + { + ["BookView"] = new Entity( + Source: new EntitySource( + Object: "dbo.vBooks", + Type: EntitySourceType.View, + Parameters: null, + KeyFields: new[] { "id" } + ), + GraphQL: new("BookView", "BookViews"), + 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, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true) + ) + }; + + 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(entities) + ); + } + + /// + /// Creates a service provider with mocked dependencies for testing MCP tools. + /// Includes metadata provider mocks so tests can reach source type validation. + /// + private static IServiceProvider CreateServiceProvider(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); + + // Add metadata provider mocks so tests can reach source type validation. + // This is required for DmlTool_AllowsTablesAndViews to actually test the source type behavior. + Mock mockSqlMetadataProvider = new(); + Dictionary entityToDatabaseObject = new(); + + // Add database objects for each entity in the config + if (config.Entities != null) + { + foreach (KeyValuePair kvp in config.Entities) + { + string entityName = kvp.Key; + Entity entity = kvp.Value; + EntitySourceType sourceType = entity.Source.Type ?? EntitySourceType.Table; + + DatabaseObject dbObject; + if (sourceType == EntitySourceType.View) + { + dbObject = new DatabaseView("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.View + }; + } + else if (sourceType == EntitySourceType.StoredProcedure) + { + dbObject = new DatabaseStoredProcedure("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.StoredProcedure + }; + } + else + { + dbObject = new DatabaseTable("dbo", entity.Source.Object) + { + SourceType = EntitySourceType.Table + }; + } + + entityToDatabaseObject[entityName] = 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(); + } + + #endregion + } +}