From e604c7acb26fa55b3304073e63ab8541ed9c1a2a Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 28 Jan 2026 14:52:39 +0530 Subject: [PATCH 1/3] Entity level check if Tool is enabled --- .../BuiltInTools/CreateRecordTool.cs | 9 + .../BuiltInTools/DeleteRecordTool.cs | 9 + .../BuiltInTools/ExecuteEntityTool.cs | 11 +- .../BuiltInTools/ReadRecordsTool.cs | 9 + .../BuiltInTools/UpdateRecordTool.cs | 9 + .../Core/DynamicCustomTool.cs | 6 + .../Utils/McpErrorHelpers.cs | 4 +- .../EntityLevelDmlToolConfigurationTests.cs | 551 ++++++++++++++++++ 8 files changed, 605 insertions(+), 3 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..bd0a8ccac0 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -83,6 +83,15 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true) + { + if (entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + } + if (!McpMetadataHelper.TryResolveMetadata( entityName, runtimeConfig, diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index d7837c0103..955ef413db 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -101,6 +101,15 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true) + { + if (entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + } + // 4) Resolve metadata for entity existence if (!McpMetadataHelper.TryResolveMetadata( entityName, diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index e780c8ddeb..aa4fbd86f0 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -107,11 +107,20 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity is required", logger); } + // Check entity-level DML tool configuration early (before metadata resolution) + if (config.Entities?.TryGetValue(entity, out Entity? entityForCheck) == true) + { + if (entityForCheck.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entity}'."); + } + } + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); // 4) Validate entity exists and is a stored procedure - if (!config.Entities.TryGetValue(entity, out Entity? entityConfig)) + if (config.Entities is null || !config.Entities.TryGetValue(entity, out Entity? entityConfig)) { return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index 1ed91c30a8..a8ce662b5c 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -114,6 +114,15 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true) + { + if (entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + } + if (root.TryGetProperty("select", out JsonElement selectElement)) { select = selectElement.GetString(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 195e27a0cd..446cffb918 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -115,6 +115,15 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } + // Check entity-level DML tool configuration + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true) + { + if (entity.Mcp?.DmlToolEnabled == false) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); + } + } + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs index 3c79839877..ea2fa0cfea 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -124,6 +124,12 @@ public async Task ExecuteAsync( return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {_entityName} is not a stored procedure.", logger); } + // Check if custom tool is still enabled for this entity + if (entityConfig.Mcp?.CustomToolEnabled != true) + { + return McpErrorHelpers.ToolDisabled(toolName, logger, $"Custom tool is disabled for entity '{_entityName}'."); + } + // 4) Resolve metadata if (!McpMetadataHelper.TryResolveMetadata( _entityName, diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs index 75335b2db1..1a5c223798 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs @@ -19,9 +19,9 @@ public static CallToolResult PermissionDenied(string toolName, string entityName } // Centralized language for 'tool disabled' errors. Pass the tool name, e.g. "read_records". - public static CallToolResult ToolDisabled(string toolName, ILogger? logger) + public static CallToolResult ToolDisabled(string toolName, ILogger? logger, string? customMessage = null) { - string message = $"The {toolName} tool is disabled in the configuration."; + string message = customMessage ?? $"The {toolName} tool is disabled in the configuration."; return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.ToolDisabled.ToString(), message, logger); } } diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs new file mode 100644 index 0000000000..fd340209f2 --- /dev/null +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -0,0 +1,551 @@ +// 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.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +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. + /// + [TestClass] + public class EntityLevelDmlToolConfigurationTests + { + /// + /// Verifies that ReadRecordsTool respects 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 ReadRecords is enabled. + /// + [TestMethod] + public async Task ReadRecords_RespectsEntityLevelDmlToolDisabled() + { + // Arrange + RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); + 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 entity has DmlToolEnabled=false"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains("DML tools are disabled for entity 'Book'")); + } + + /// + /// Verifies that CreateRecordTool respects entity-level DmlToolEnabled=false. + /// + [TestMethod] + public async Task CreateRecord_RespectsEntityLevelDmlToolDisabled() + { + // Arrange + RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + CreateRecordTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + } + + /// + /// Verifies that UpdateRecordTool respects entity-level DmlToolEnabled=false. + /// + [TestMethod] + public async Task UpdateRecord_RespectsEntityLevelDmlToolDisabled() + { + // Arrange + RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + UpdateRecordTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + } + + /// + /// Verifies that DeleteRecordTool respects entity-level DmlToolEnabled=false. + /// + [TestMethod] + public async Task DeleteRecord_RespectsEntityLevelDmlToolDisabled() + { + // Arrange + RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + DeleteRecordTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\", \"keys\": {\"id\": 1}}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + } + + /// + /// Verifies that ExecuteEntityTool respects entity-level DmlToolEnabled=false. + /// + [TestMethod] + public async Task ExecuteEntity_RespectsEntityLevelDmlToolDisabled() + { + // Arrange + RuntimeConfig config = CreateConfigWithDmlToolDisabledStoredProcedure(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + ExecuteEntityTool tool = new(); + + JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"GetBook\"}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + } + + /// + /// Verifies that DML tools work normally when entity has DmlToolEnabled=true (default). + /// This test ensures the entity-level check doesn't break the normal flow. + /// + [TestMethod] + public async Task ReadRecords_WorksWhenEntityLevelDmlToolEnabled() + { + // Arrange + RuntimeConfig config = CreateConfigWithDmlToolEnabledEntity(); + 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) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + 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 when DmlToolEnabled=true"); + } + } + } + + /// + /// Verifies that entity-level check is skipped when entity has no MCP configuration. + /// When entity.Mcp is null, DmlToolEnabled defaults to true. + /// + [TestMethod] + public async Task ReadRecords_WorksWhenEntityHasNoMcpConfig() + { + // Arrange + RuntimeConfig config = 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 + if (result.IsError == true) + { + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + 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 when entity has no MCP config"); + } + } + } + + /// + /// 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 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: 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"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual("ToolDisabled", errorType.GetString()); + + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains("Custom tool is disabled for entity 'GetBook'")); + } + + #region Helper Methods + + /// + /// 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 service provider with mocked dependencies for testing MCP tools. + /// + 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); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + #endregion + } +} From 708e5651ab97d5c8cd5fcf9ae594f89ddf923b95 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Wed, 28 Jan 2026 15:32:37 +0530 Subject: [PATCH 2/3] Implemented copilot suggesstions --- .../BuiltInTools/CreateRecordTool.cs | 8 +-- .../BuiltInTools/DeleteRecordTool.cs | 8 +-- .../BuiltInTools/ExecuteEntityTool.cs | 8 +-- .../BuiltInTools/ReadRecordsTool.cs | 8 +-- .../BuiltInTools/UpdateRecordTool.cs | 8 +-- .../EntityLevelDmlToolConfigurationTests.cs | 62 ++++++++++--------- 6 files changed, 49 insertions(+), 53 deletions(-) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index bd0a8ccac0..5ac12f5988 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -84,12 +84,10 @@ public async Task ExecuteAsync( } // Check entity-level DML tool configuration - if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true) + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) { - if (entity.Mcp?.DmlToolEnabled == false) - { - return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); - } + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); } if (!McpMetadataHelper.TryResolveMetadata( diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 955ef413db..bc8efa96fe 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -102,12 +102,10 @@ public async Task ExecuteAsync( } // Check entity-level DML tool configuration - if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true) + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) { - if (entity.Mcp?.DmlToolEnabled == false) - { - return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); - } + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); } // 4) Resolve metadata for entity existence diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index aa4fbd86f0..8989680f9e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -108,12 +108,10 @@ public async Task ExecuteAsync( } // Check entity-level DML tool configuration early (before metadata resolution) - if (config.Entities?.TryGetValue(entity, out Entity? entityForCheck) == true) + if (config.Entities?.TryGetValue(entity, out Entity? entityForCheck) == true && + entityForCheck.Mcp?.DmlToolEnabled == false) { - if (entityForCheck.Mcp?.DmlToolEnabled == false) - { - return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entity}'."); - } + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entity}'."); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index a8ce662b5c..64e73f0281 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -115,12 +115,10 @@ public async Task ExecuteAsync( } // Check entity-level DML tool configuration - if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true) + if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) { - if (entity.Mcp?.DmlToolEnabled == false) - { - return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); - } + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); } if (root.TryGetProperty("select", out JsonElement selectElement)) diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 446cffb918..ed2a9f3ce4 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -116,12 +116,10 @@ public async Task ExecuteAsync( } // Check entity-level DML tool configuration - if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true) + if (config.Entities?.TryGetValue(entityName, out Entity? entity) == true && + entity.Mcp?.DmlToolEnabled == false) { - if (entity.Mcp?.DmlToolEnabled == false) - { - return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); - } + return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'."); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs index fd340209f2..875096a7e2 100644 --- a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -23,6 +23,12 @@ 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 @@ -47,14 +53,14 @@ public async Task ReadRecords_RespectsEntityLevelDmlToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); Assert.AreEqual("ToolDisabled", errorType.GetString()); - + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); string message = errorMessage.GetString() ?? string.Empty; Assert.IsTrue(message.Contains("DML tools are disabled for entity 'Book'")); @@ -78,10 +84,10 @@ public async Task CreateRecord_RespectsEntityLevelDmlToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); Assert.AreEqual("ToolDisabled", errorType.GetString()); @@ -105,10 +111,10 @@ public async Task UpdateRecord_RespectsEntityLevelDmlToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); Assert.AreEqual("ToolDisabled", errorType.GetString()); @@ -132,10 +138,10 @@ public async Task DeleteRecord_RespectsEntityLevelDmlToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); Assert.AreEqual("ToolDisabled", errorType.GetString()); @@ -159,10 +165,10 @@ public async Task ExecuteEntity_RespectsEntityLevelDmlToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); Assert.AreEqual("ToolDisabled", errorType.GetString()); @@ -192,12 +198,12 @@ public async Task ReadRecords_WorksWhenEntityLevelDmlToolEnabled() { TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - if (content.TryGetProperty("error", out JsonElement error) && + + if (content.TryGetProperty("error", out JsonElement error) && error.TryGetProperty("type", out JsonElement errorType)) { string errorTypeValue = errorType.GetString(); - Assert.AreNotEqual("ToolDisabled", errorTypeValue, + Assert.AreNotEqual("ToolDisabled", errorTypeValue, "Should not get ToolDisabled error when DmlToolEnabled=true"); } } @@ -226,12 +232,12 @@ public async Task ReadRecords_WorksWhenEntityHasNoMcpConfig() { TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - if (content.TryGetProperty("error", out JsonElement error) && + + if (content.TryGetProperty("error", out JsonElement error) && error.TryGetProperty("type", out JsonElement errorType)) { string errorTypeValue = errorType.GetString(); - Assert.AreNotEqual("ToolDisabled", errorTypeValue, + Assert.AreNotEqual("ToolDisabled", errorTypeValue, "Should not get ToolDisabled error when entity has no MCP config"); } } @@ -249,7 +255,7 @@ 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 Entity( @@ -257,14 +263,14 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled() GraphQL: new("GetBook", "GetBook"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + 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("{}"); @@ -274,14 +280,14 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when CustomToolEnabled=false in runtime config"); - + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); Assert.AreEqual("ToolDisabled", errorType.GetString()); - + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); string message = errorMessage.GetString() ?? string.Empty; Assert.IsTrue(message.Contains("Custom tool is disabled for entity 'GetBook'")); @@ -301,7 +307,7 @@ private static RuntimeConfig CreateConfigWithDmlToolDisabledEntity() GraphQL: new("Book", "Books"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + 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), @@ -349,7 +355,7 @@ private static RuntimeConfig CreateConfigWithDmlToolDisabledStoredProcedure() GraphQL: new("GetBook", "GetBook"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) }) }, Mappings: null, @@ -394,7 +400,7 @@ private static RuntimeConfig CreateConfigWithDmlToolEnabledEntity() GraphQL: new("Book", "Books"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) }, Mappings: null, @@ -439,7 +445,7 @@ private static RuntimeConfig CreateConfigWithEntityWithoutMcpConfig() GraphQL: new("Book", "Books"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) }, Mappings: null, @@ -485,7 +491,7 @@ private static RuntimeConfig CreateConfigWithCustomToolDisabled() GraphQL: new("GetBook", "GetBook"), Fields: null, Rest: new(Enabled: true), - Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) }) }, Mappings: null, From ab7eb5c00155208e3685d85ab27d3e1ec39a3e1a Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 29 Jan 2026 14:23:02 +0530 Subject: [PATCH 3/3] Refactoring tests --- .../EntityLevelDmlToolConfigurationTests.cs | 303 +++++++++--------- 1 file changed, 148 insertions(+), 155 deletions(-) diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs index 875096a7e2..40456f0e2b 100644 --- a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -11,6 +11,7 @@ using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Azure.DataApiBuilder.Mcp.Model; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -34,131 +35,29 @@ namespace Azure.DataApiBuilder.Service.Tests.Mcp public class EntityLevelDmlToolConfigurationTests { /// - /// Verifies that ReadRecordsTool respects entity-level DmlToolEnabled=false. + /// 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 ReadRecords is enabled. + /// return a ToolDisabled error even if the runtime-level tool is enabled. /// - [TestMethod] - public async Task ReadRecords_RespectsEntityLevelDmlToolDisabled() - { - // Arrange - RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); - 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 entity has DmlToolEnabled=false"); - - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); - Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); - Assert.AreEqual("ToolDisabled", errorType.GetString()); - - Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); - string message = errorMessage.GetString() ?? string.Empty; - Assert.IsTrue(message.Contains("DML tools are disabled for entity 'Book'")); - } - - /// - /// Verifies that CreateRecordTool respects entity-level DmlToolEnabled=false. - /// - [TestMethod] - public async Task CreateRecord_RespectsEntityLevelDmlToolDisabled() - { - // Arrange - RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); - IServiceProvider serviceProvider = CreateServiceProvider(config); - CreateRecordTool tool = new(); - - JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\", \"data\": {\"id\": 1, \"title\": \"Test\"}}"); - - // Act - CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - - // Assert - Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); - Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); - Assert.AreEqual("ToolDisabled", errorType.GetString()); - } - - /// - /// Verifies that UpdateRecordTool respects entity-level DmlToolEnabled=false. - /// - [TestMethod] - public async Task UpdateRecord_RespectsEntityLevelDmlToolDisabled() - { - // Arrange - RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); - IServiceProvider serviceProvider = CreateServiceProvider(config); - UpdateRecordTool tool = new(); - - JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}"); - - // Act - CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - - // Assert - Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); - Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); - Assert.AreEqual("ToolDisabled", errorType.GetString()); - } - - /// - /// Verifies that DeleteRecordTool respects entity-level DmlToolEnabled=false. - /// - [TestMethod] - public async Task DeleteRecord_RespectsEntityLevelDmlToolDisabled() - { - // Arrange - RuntimeConfig config = CreateConfigWithDmlToolDisabledEntity(); - IServiceProvider serviceProvider = CreateServiceProvider(config); - DeleteRecordTool tool = new(); - - JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"Book\", \"keys\": {\"id\": 1}}"); - - // Act - CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - - // Assert - Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); - Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); - Assert.AreEqual("ToolDisabled", errorType.GetString()); - } - - /// - /// Verifies that ExecuteEntityTool respects entity-level DmlToolEnabled=false. - /// - [TestMethod] - public async Task ExecuteEntity_RespectsEntityLevelDmlToolDisabled() + /// 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 = CreateConfigWithDmlToolDisabledStoredProcedure(); + RuntimeConfig config = isStoredProcedure + ? CreateConfigWithDmlToolDisabledStoredProcedure() + : CreateConfigWithDmlToolDisabledEntity(); IServiceProvider serviceProvider = CreateServiceProvider(config); - ExecuteEntityTool tool = new(); + IMcpTool tool = CreateTool(toolType); - JsonDocument arguments = JsonDocument.Parse("{\"entity\": \"GetBook\"}"); + JsonDocument arguments = JsonDocument.Parse(jsonArguments); // Act CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); @@ -166,23 +65,27 @@ public async Task ExecuteEntity_RespectsEntityLevelDmlToolDisabled() // Assert Assert.IsTrue(result.IsError == true, "Expected error when entity has DmlToolEnabled=false"); - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; - - Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); - Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); - Assert.AreEqual("ToolDisabled", errorType.GetString()); + JsonElement content = await RunToolAsync(tool, arguments, serviceProvider); + AssertToolDisabledError(content); } /// - /// Verifies that DML tools work normally when entity has DmlToolEnabled=true (default). - /// This test ensures the entity-level check doesn't break the normal flow. + /// 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) /// - [TestMethod] - public async Task ReadRecords_WorksWhenEntityLevelDmlToolEnabled() + /// 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 = CreateConfigWithDmlToolEnabledEntity(); + RuntimeConfig config = useMcpConfig + ? CreateConfigWithDmlToolEnabledEntity() + : CreateConfigWithEntityWithoutMcpConfig(); IServiceProvider serviceProvider = CreateServiceProvider(config); ReadRecordsTool tool = new(); @@ -196,28 +99,28 @@ public async Task ReadRecords_WorksWhenEntityLevelDmlToolEnabled() // but that's OK for this test. We just want to ensure it passes the entity-level check. if (result.IsError == true) { - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + 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 when DmlToolEnabled=true"); + $"Should not get ToolDisabled error for scenario: {scenario}"); } } } /// - /// Verifies that entity-level check is skipped when entity has no MCP configuration. - /// When entity.Mcp is null, DmlToolEnabled defaults to true. + /// 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_WorksWhenEntityHasNoMcpConfig() + public async Task ReadRecords_RuntimeDisabledTakesPrecedenceOverEntityEnabled() { - // Arrange - RuntimeConfig config = CreateConfigWithEntityWithoutMcpConfig(); + // Arrange - Runtime has readRecords=false, but entity has DmlToolEnabled=true + RuntimeConfig config = CreateConfigWithRuntimeDisabledButEntityEnabled(); IServiceProvider serviceProvider = CreateServiceProvider(config); ReadRecordsTool tool = new(); @@ -227,19 +130,19 @@ public async Task ReadRecords_WorksWhenEntityHasNoMcpConfig() CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); // Assert - // Should not be a ToolDisabled error - if (result.IsError == true) - { - TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + Assert.IsTrue(result.IsError == true, "Expected error when runtime-level tool is disabled"); - 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 when entity has no MCP config"); - } + 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"); } } @@ -281,19 +184,63 @@ public async Task DynamicCustomTool_RespectsCustomToolDisabled() // 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 Helper Methods + + /// + /// 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); TextContentBlock firstContent = (TextContentBlock)result.Content[0]; - JsonElement content = JsonDocument.Parse(firstContent.Text).RootElement; + return JsonDocument.Parse(firstContent.Text).RootElement; + } + /// + /// 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()); - Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); - string message = errorMessage.GetString() ?? string.Empty; - Assert.IsTrue(message.Contains("Custom tool is disabled for entity 'GetBook'")); + 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}"); + } } - #region Helper Methods + /// + /// 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. @@ -524,6 +471,52 @@ private static RuntimeConfig CreateConfigWithCustomToolDisabled() ); } + /// + /// 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 service provider with mocked dependencies for testing MCP tools. ///