diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 1a944d115b..5ac12f5988 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -83,6 +83,13 @@ 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 && + 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..bc8efa96fe 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -101,6 +101,13 @@ 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 && + 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..8989680f9e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -107,11 +107,18 @@ 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 && + 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..64e73f0281 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -114,6 +114,13 @@ 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 && + 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..ed2a9f3ce4 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -115,6 +115,13 @@ 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 && + 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..875096a7e2 --- /dev/null +++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs @@ -0,0 +1,557 @@ +// 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. + /// + /// 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 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 + } +}