diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index c5b283214f..00d72881ee 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -146,6 +146,10 @@ public Task ExecuteAsync( List> entityList = new(); + // Track how many entities were filtered out because DML tools are disabled (dml-tools: false). + // This helps provide a more specific error message when all entities are filtered. + int filteredDmlDisabledCount = 0; + if (runtimeConfig.Entities != null) { foreach (KeyValuePair entityEntry in runtimeConfig.Entities) @@ -155,6 +159,16 @@ public Task ExecuteAsync( string entityName = entityEntry.Key; Entity entity = entityEntry.Value; + // Filter out entities when dml-tools is explicitly disabled (false). + // This applies to all entity types (tables, views, stored procedures). + // When dml-tools is false, the entity is not exposed via DML tools + // (read_records, create_record, etc.) and should not appear in describe_entities. + if (entity.Mcp?.DmlToolEnabled == false) + { + filteredDmlDisabledCount++; + continue; + } + if (!ShouldIncludeEntity(entityName, entityFilter)) { continue; @@ -177,6 +191,7 @@ public Task ExecuteAsync( if (entityList.Count == 0) { + // No entities matched the filter criteria if (entityFilter != null && entityFilter.Count > 0) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( @@ -185,6 +200,20 @@ public Task ExecuteAsync( $"No entities found matching the filter: {string.Join(", ", entityFilter)}", logger)); } + // Return a specific error when ALL configured entities have dml-tools: false. + // Only show this error when every entity was intentionally filtered by the dml-tools check above, + // not when some entities failed to build due to exceptions in BuildBasicEntityInfo() or BuildFullEntityInfo() functions. + else if (filteredDmlDisabledCount > 0 && + runtimeConfig.Entities != null && + filteredDmlDisabledCount == runtimeConfig.Entities.Entities.Count) + { + return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, + "AllEntitiesFilteredDmlDisabled", + $"All {filteredDmlDisabledCount} configured entities have DML tools disabled (dml-tools: false). Entities with dml-tools disabled do not appear in describe_entities. For stored procedures, check tools/list if custom-tool is enabled.", + logger)); + } + // Truly no entities configured in the runtime config, or entities failed to build for other reasons else { return Task.FromResult(McpResponseBuilder.BuildErrorResult( @@ -207,6 +236,18 @@ public Task ExecuteAsync( ["count"] = finalEntityList.Count }; + // Log when entities were filtered due to DML tools disabled for visibility + if (filteredDmlDisabledCount > 0) + { + logger?.LogInformation( + "DescribeEntitiesTool: {FilteredCount} entity(ies) filtered with DML tools disabled (dml-tools: false). " + + "These entities are not exposed via DML tools and do not appear in describe_entities response. " + + "Returned {ReturnedCount} entities, filtered {FilteredCount}.", + filteredDmlDisabledCount, + finalEntityList.Count, + filteredDmlDisabledCount); + } + logger?.LogInformation( "DescribeEntitiesTool returned {EntityCount} entities. Response type: {ResponseType} (nameOnly={NameOnly}).", finalEntityList.Count, diff --git a/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs new file mode 100644 index 0000000000..2c509adb6a --- /dev/null +++ b/src/Service.Tests/Mcp/DescribeEntitiesFilteringTests.cs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +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 DescribeEntitiesTool filtering logic (GitHub issue #3043). + /// Ensures stored procedures with custom-tool enabled are excluded from describe_entities results + /// to prevent duplication (they appear in tools/list instead). + /// Regular entities (tables, views, non-custom-tool SPs) remain visible in describe_entities. + /// + [TestClass] + public class DescribeEntitiesFilteringTests + { + /// + /// Verifies that when ALL entities are stored procedures with custom-tool enabled, + /// describe_entities returns an AllEntitiesFilteredDmlDisabled error with guidance + /// to use tools/list instead. This ensures users understand why describe_entities is empty. + /// + [TestMethod] + public async Task DescribeEntities_ExcludesCustomToolStoredProcedures() + { + // Arrange + RuntimeConfig config = CreateConfigWithCustomToolSP(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // Assert + AssertErrorResult(result, "AllEntitiesFilteredDmlDisabled"); + + // Verify the error message is helpful + JsonElement content = GetContentFromResult(result); + content.TryGetProperty("error", out JsonElement error); + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains("DML tools disabled") || message.Contains("dml-tools")); + Assert.IsTrue(message.Contains("tools/list") || message.Contains("custom-tool")); + } + + /// + /// Verifies that stored procedures WITHOUT custom-tool enabled still appear in describe_entities, + /// while stored procedures WITH custom-tool enabled are filtered out. + /// This ensures filtering is selective and only applies to custom-tool SPs. + /// + [TestMethod] + public async Task DescribeEntities_IncludesRegularStoredProcedures() + { + // Arrange + RuntimeConfig config = CreateConfigWithMixedStoredProcedures(); + + // Act & Assert + CallToolResult result = await ExecuteToolAsync(config); + AssertSuccessResultWithEntityNames(result, new[] { "CountBooks" }, new[] { "GetBook" }); + } + + /// + /// Verifies that custom-tool filtering ONLY applies to stored procedures. + /// Tables and views always appear in describe_entities regardless of any custom-tool configuration. + /// This ensures filtering doesn't accidentally hide non-SP entities. + /// + [TestMethod] + public async Task DescribeEntities_TablesAndViewsUnaffectedByFiltering() + { + // Arrange & Act & Assert + RuntimeConfig config = CreateConfigWithMixedEntityTypes(); + CallToolResult result = await ExecuteToolAsync(config); + AssertSuccessResultWithEntityNames(result, new[] { "Book", "BookView" }, new[] { "GetBook" }); + } + + /// + /// Verifies that the 'count' field in describe_entities response accurately reflects + /// the number of entities AFTER filtering (excludes custom-tool stored procedures). + /// This ensures count matches the actual entities array length. + /// + [TestMethod] + public async Task DescribeEntities_CountReflectsFilteredList() + { + // Arrange + RuntimeConfig config = CreateConfigWithMixedEntityTypes(); + + // Act + CallToolResult result = await ExecuteToolAsync(config); + + // Assert + Assert.IsTrue(result.IsError == false || result.IsError == null); + JsonElement content = GetContentFromResult(result); + Assert.IsTrue(content.TryGetProperty("entities", out JsonElement entities)); + Assert.IsTrue(content.TryGetProperty("count", out JsonElement countElement)); + + int entityCount = entities.GetArrayLength(); + Assert.AreEqual(2, entityCount, "Config has 3 entities but only 2 should be returned (custom-tool SP excluded)"); + Assert.AreEqual(entityCount, countElement.GetInt32(), "Count field should match filtered entity array length"); + } + + /// + /// Verifies that custom-tool filtering is applied consistently regardless of the nameOnly parameter. + /// When nameOnly=true (lightweight response), custom-tool SPs are still filtered out. + /// This ensures filtering behavior is consistent across both response modes. + /// + [TestMethod] + public async Task DescribeEntities_NameOnlyWorksWithFiltering() + { + // Arrange + RuntimeConfig config = CreateConfigWithMixedEntityTypes(); + IServiceProvider serviceProvider = CreateServiceProvider(config); + DescribeEntitiesTool tool = new(); + JsonDocument arguments = JsonDocument.Parse("{\"nameOnly\": true}"); + + // Act + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + // Assert + AssertSuccessResultWithEntityNames(result, new[] { "Book", "BookView" }, new[] { "GetBook" }); + } + + /// + /// Test that NoEntitiesConfigured error is returned when runtime config truly has no entities. + /// This is different from AllEntitiesFilteredAsCustomTools where entities exist but are filtered. + /// + [TestMethod] + public async Task DescribeEntities_ReturnsNoEntitiesConfigured_WhenConfigHasNoEntities() + { + // Arrange & Act + RuntimeConfig config = CreateConfigWithNoEntities(); + CallToolResult result = await ExecuteToolAsync(config); + + // Assert + AssertErrorResult(result, "NoEntitiesConfigured"); + + // Verify the error message indicates no entities configured + JsonElement content = GetContentFromResult(result); + content.TryGetProperty("error", out JsonElement error); + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains("No entities are configured")); + } + + /// + /// CRITICAL TEST: Verifies that stored procedures with BOTH custom-tool AND dml-tools enabled + /// appear in describe_entities. This validates the truth table scenario: + /// custom-tool: true, dml-tools: true → ✔ describe_entities + ✔ tools/list + /// + /// This test ensures the filtering logic only filters when dml-tools is FALSE, + /// not just when custom-tool is TRUE. + /// + [TestMethod] + public async Task DescribeEntities_IncludesCustomToolWithDmlEnabled() + { + // Arrange & Act + RuntimeConfig config = CreateConfigWithCustomToolAndDmlEnabled(); + CallToolResult result = await ExecuteToolAsync(config); + + // Assert + AssertSuccessResultWithEntityNames(result, new[] { "GetBook" }, Array.Empty()); + } + + /// + /// Verifies that when some (but not all) entities are filtered as custom-tool-only, + /// the filtering is logged but does not affect the response content. + /// The response should contain only the non-filtered entities. + /// + [TestMethod] + public async Task DescribeEntities_LogsFilteringInfo_WhenSomeEntitiesFiltered() + { + // Arrange & Act + RuntimeConfig config = CreateConfigWithMixedEntityTypes(); + CallToolResult result = await ExecuteToolAsync(config); + + // Assert + AssertSuccessResultWithEntityNames(result, new[] { "Book", "BookView" }, new[] { "GetBook" }); + + // Verify count matches + JsonElement content = GetContentFromResult(result); + Assert.IsTrue(content.TryGetProperty("count", out JsonElement countElement)); + Assert.AreEqual(2, countElement.GetInt32()); + } + + /// + /// Verifies that entities with DML tools disabled (dml-tools: false) are filtered from describe_entities. + /// This ensures the filtering applies to all entity types, not just stored procedures. + /// + [DataTestMethod] + [DataRow(EntitySourceType.Table, "Publisher", "Book", DisplayName = "Filters Table with DML disabled")] + [DataRow(EntitySourceType.View, "Book", "BookView", DisplayName = "Filters View with DML disabled")] + public async Task DescribeEntities_FiltersEntityWithDmlToolsDisabled(EntitySourceType filteredEntityType, string includedEntityName, string filteredEntityName) + { + // Arrange + RuntimeConfig config = CreateConfigWithEntityDmlDisabled(filteredEntityType, includedEntityName, filteredEntityName); + IServiceProvider serviceProvider = CreateServiceProvider(config); + DescribeEntitiesTool tool = new(); + + // Act + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + // Assert + AssertSuccessResultWithEntityNames(result, new[] { includedEntityName }, new[] { filteredEntityName }); + } + + /// + /// Verifies that when ALL entities have dml-tools disabled, the appropriate error is returned. + /// This tests the error scenario applies to all entity types, not just stored procedures. + /// + [TestMethod] + public async Task DescribeEntities_ReturnsAllEntitiesFilteredDmlDisabled_WhenAllEntitiesHaveDmlDisabled() + { + // Arrange & Act + RuntimeConfig config = CreateConfigWithAllEntitiesDmlDisabled(); + CallToolResult result = await ExecuteToolAsync(config); + + // Assert + AssertErrorResult(result, "AllEntitiesFilteredDmlDisabled"); + + // Verify the error message is helpful + JsonElement content = GetContentFromResult(result); + content.TryGetProperty("error", out JsonElement error); + Assert.IsTrue(error.TryGetProperty("message", out JsonElement errorMessage)); + string message = errorMessage.GetString() ?? string.Empty; + Assert.IsTrue(message.Contains("DML tools disabled"), "Error message should mention DML tools disabled"); + Assert.IsTrue(message.Contains("dml-tools: false"), "Error message should mention the config syntax"); + } + + #region Helper Methods + + /// + /// Executes the DescribeEntitiesTool with the given config. + /// + private static async Task ExecuteToolAsync(RuntimeConfig config, JsonDocument arguments = null) + { + IServiceProvider serviceProvider = CreateServiceProvider(config); + DescribeEntitiesTool tool = new(); + return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + } + + /// + /// Runs the DescribeEntitiesTool and asserts successful execution with expected entity names. + /// + private static void AssertSuccessResultWithEntityNames(CallToolResult result, string[] includedEntities, string[] excludedEntities) + { + Assert.IsTrue(result.IsError == false || result.IsError == null); + JsonElement content = GetContentFromResult(result); + Assert.IsTrue(content.TryGetProperty("entities", out JsonElement entities)); + + List entityNames = entities.EnumerateArray() + .Select(e => e.GetProperty("name").GetString()!) + .ToList(); + + foreach (string includedEntity in includedEntities) + { + Assert.IsTrue(entityNames.Contains(includedEntity), $"{includedEntity} should be included"); + } + + foreach (string excludedEntity in excludedEntities) + { + Assert.IsFalse(entityNames.Contains(excludedEntity), $"{excludedEntity} should be excluded"); + } + + Assert.AreEqual(includedEntities.Length, entities.GetArrayLength()); + } + + /// + /// Asserts that the result contains an error with the specified type. + /// + private static void AssertErrorResult(CallToolResult result, string expectedErrorType) + { + Assert.IsTrue(result.IsError == true); + JsonElement content = GetContentFromResult(result); + Assert.IsTrue(content.TryGetProperty("error", out JsonElement error)); + Assert.IsTrue(error.TryGetProperty("type", out JsonElement errorType)); + Assert.AreEqual(expectedErrorType, errorType.GetString()); + } + + /// + /// Creates a basic entity with standard permissions. + /// + private static Entity CreateEntity(string sourceName, EntitySourceType sourceType, string singularName, string pluralName, EntityMcpOptions mcpOptions = null) + { + EntityActionOperation action = sourceType == EntitySourceType.StoredProcedure + ? EntityActionOperation.Execute + : EntityActionOperation.Read; + + return new Entity( + Source: new(sourceName, sourceType, null, null), + GraphQL: new(singularName, pluralName), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: action, Fields: null, Policy: null) }) }, + Mappings: null, + Relationships: null, + Mcp: mcpOptions + ); + } + + /// + /// Creates a runtime config with the specified entities. + /// + private static RuntimeConfig CreateRuntimeConfig(Dictionary entities) + { + 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: null), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + /// + /// Creates a runtime config with only custom-tool stored procedures. + /// Used to test the AllEntitiesFilteredAsCustomTools error scenario. + /// + private static RuntimeConfig CreateConfigWithCustomToolSP() + { + Dictionary entities = new() + { + ["GetBook"] = CreateEntity("get_book", EntitySourceType.StoredProcedure, "GetBook", "GetBook", + new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false)) + }; + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a runtime config with mixed stored procedures: + /// one regular SP (CountBooks) and one custom-tool SP (GetBook). + /// Used to test that filtering is selective. + /// + private static RuntimeConfig CreateConfigWithMixedStoredProcedures() + { + Dictionary entities = new() + { + ["CountBooks"] = CreateEntity("count_books", EntitySourceType.StoredProcedure, "CountBooks", "CountBooks"), + ["GetBook"] = CreateEntity("get_book", EntitySourceType.StoredProcedure, "GetBook", "GetBook", + new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false)) + }; + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a runtime config with mixed entity types: + /// table (Book), view (BookView), and custom-tool SP (GetBook). + /// Used to test that filtering only affects stored procedures. + /// + private static RuntimeConfig CreateConfigWithMixedEntityTypes() + { + Dictionary entities = new() + { + ["Book"] = CreateEntity("books", EntitySourceType.Table, "Book", "Books"), + ["BookView"] = CreateEntity("book_view", EntitySourceType.View, "BookView", "BookViews"), + ["GetBook"] = CreateEntity("get_book", EntitySourceType.StoredProcedure, "GetBook", "GetBook", + new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false)) + }; + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a runtime config with an empty entities dictionary. + /// Used to test the NoEntitiesConfigured error when no entities are configured at all. + /// + private static RuntimeConfig CreateConfigWithNoEntities() + { + return CreateRuntimeConfig(new Dictionary()); + } + + /// + /// Creates a runtime config with a stored procedure that has BOTH custom-tool and dml-tools enabled. + /// Used to test the truth table scenario: custom-tool:true + dml-tools:true → should appear in describe_entities. + /// + private static RuntimeConfig CreateConfigWithCustomToolAndDmlEnabled() + { + Dictionary entities = new() + { + ["GetBook"] = CreateEntity("get_book", EntitySourceType.StoredProcedure, "GetBook", "GetBook", + new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: true)) + }; + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a runtime config with an entity that has dml-tools disabled. + /// Used to test that entities with dml-tools: false are filtered from describe_entities. + /// + private static RuntimeConfig CreateConfigWithEntityDmlDisabled(EntitySourceType filteredEntityType, string includedEntityName, string filteredEntityName) + { + Dictionary entities = new(); + + // Add the included entity (different type based on what's being filtered) + if (filteredEntityType == EntitySourceType.Table) + { + entities[includedEntityName] = CreateEntity("publishers", EntitySourceType.Table, includedEntityName, $"{includedEntityName}s", + new EntityMcpOptions(customToolEnabled: null, dmlToolsEnabled: true)); + entities[filteredEntityName] = CreateEntity("books", EntitySourceType.Table, filteredEntityName, $"{filteredEntityName}s", + new EntityMcpOptions(customToolEnabled: null, dmlToolsEnabled: false)); + } + else if (filteredEntityType == EntitySourceType.View) + { + entities[includedEntityName] = CreateEntity("books", EntitySourceType.Table, includedEntityName, $"{includedEntityName}s"); + entities[filteredEntityName] = CreateEntity("book_view", EntitySourceType.View, filteredEntityName, $"{filteredEntityName}s", + new EntityMcpOptions(customToolEnabled: null, dmlToolsEnabled: false)); + } + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a runtime config where all entities have dml-tools disabled. + /// Used to test the AllEntitiesFilteredDmlDisabled error scenario. + /// + private static RuntimeConfig CreateConfigWithAllEntitiesDmlDisabled() + { + Dictionary entities = new() + { + ["Book"] = CreateEntity("books", EntitySourceType.Table, "Book", "Books", + new EntityMcpOptions(customToolEnabled: null, dmlToolsEnabled: false)), + ["BookView"] = CreateEntity("book_view", EntitySourceType.View, "BookView", "BookViews", + new EntityMcpOptions(customToolEnabled: null, dmlToolsEnabled: false)), + ["GetBook"] = CreateEntity("get_book", EntitySourceType.StoredProcedure, "GetBook", "GetBook", + new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false)) + }; + + return CreateRuntimeConfig(entities); + } + + /// + /// Creates a service provider with mocked dependencies for testing DescribeEntitiesTool. + /// Configures anonymous role and necessary DAB services. + /// + private static IServiceProvider CreateServiceProvider(RuntimeConfig config) + { + ServiceCollection services = new(); + + // Use shared test helper to create RuntimeConfigProvider + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(configProvider); + + // Mock IAuthorizationResolver + Mock mockAuthResolver = new(); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + services.AddSingleton(mockAuthResolver.Object); + + // Mock HttpContext with anonymous role + 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 logging + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + /// + /// Extracts and parses the JSON content from an MCP tool call result. + /// Returns the root JsonElement for assertion purposes. + /// + private static JsonElement GetContentFromResult(CallToolResult result) + { + Assert.IsNotNull(result.Content); + Assert.IsTrue(result.Content.Count > 0); + + // Verify the content block is the expected type before casting + Assert.IsInstanceOfType(result.Content[0], typeof(TextContentBlock), + "Expected first content block to be TextContentBlock"); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + Assert.IsNotNull(firstContent.Text); + + return JsonDocument.Parse(firstContent.Text).RootElement; + } + + #endregion + } +}