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 + } +}