diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 95e37c4498..10d160a42f 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; @@ -179,7 +180,7 @@ public Task ExecuteAsync( { Dictionary entityInfo = nameOnly ? BuildBasicEntityInfo(entityName, entity) - : BuildFullEntityInfo(entityName, entity, currentUserRole); + : BuildFullEntityInfo(entityName, entity, currentUserRole, TryResolveDatabaseObject(entityName, entity, runtimeConfig, serviceProvider, logger, cancellationToken)); entityList.Add(entityInfo); } @@ -403,10 +404,11 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti /// The name of the entity to include in the dictionary. /// The entity object from which to extract additional information. /// The role of the current user, used to determine permissions. + /// The resolved database object metadata if available. /// /// A dictionary containing the entity's name, description, fields, parameters (if applicable), and permissions. /// - private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole) + private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole, DatabaseObject? databaseObject) { // Use GraphQL singular name as alias if available, otherwise use entity name string displayName = !string.IsNullOrWhiteSpace(entity.GraphQL?.Singular) @@ -422,7 +424,7 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti if (entity.Source.Type == EntitySourceType.StoredProcedure) { - info["parameters"] = BuildParameterMetadataInfo(entity.Source.Parameters); + info["parameters"] = BuildParameterMetadataInfo(entity.Source.Parameters, databaseObject); } info["permissions"] = BuildPermissionsInfo(entity, currentUserRole); @@ -430,6 +432,39 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti return info; } + /// + /// Tries to resolve the metadata-backed database object for an entity. + /// + private static DatabaseObject? TryResolveDatabaseObject( + string entityName, + Entity entity, + RuntimeConfig runtimeConfig, + IServiceProvider serviceProvider, + ILogger? logger, + CancellationToken cancellationToken) + { + if (entity.Source.Type != EntitySourceType.StoredProcedure) + { + return null; + } + + if (McpMetadataHelper.TryResolveMetadata( + entityName, + runtimeConfig, + serviceProvider, + out _, + out DatabaseObject dbObject, + out _, + out string error, + cancellationToken)) + { + return dbObject; + } + + logger?.LogDebug("Could not resolve metadata for stored procedure entity {EntityName}. Falling back to config parameters. Error: {Error}", entityName, error); + return null; + } + /// /// Builds a list of metadata information objects from the provided collection of fields. /// @@ -456,33 +491,68 @@ private static List BuildFieldMetadataInfo(List? fields) } /// - /// Builds a list of parameter metadata objects containing information about each parameter. + /// Merges DB-discovered stored-procedure parameters with optional config overlays (see issue #3400): + /// + /// name: from DB metadata. + /// required: from config when the parameter is listed there; defaults to true when config is silent. + /// default, description: config-only (T-SQL doesn't expose them reliably). + /// + /// The metadata provider validates at startup that every config-listed parameter exists in DB metadata + /// (see SqlMetadataProvider.FillSchemaForStoredProcedureAsync), so iterating DB parameters alone is exhaustive. + /// Falls back to emitting config parameters as-is when DB metadata is not available. /// - /// A list of objects representing the parameters to process. Can be null. - /// A list of dictionaries, each containing the parameter's name, whether it is required, its default - /// value, and its description. Returns an empty list if is null. - private static List BuildParameterMetadataInfo(List? parameters) + /// Parameter overlays declared in the runtime config. May be null or empty. + /// Resolved database metadata for the entity. Expected to be a ; may be null. + /// A list of parameter dictionaries (name, required, default, description) describing each parameter. + private static List BuildParameterMetadataInfo( + List? configParameters, + DatabaseObject? databaseObject) { - List result = new(); + Dictionary configByName = new(StringComparer.OrdinalIgnoreCase); + if (configParameters is not null) + { + foreach (ParameterMetadata parameter in configParameters) + { + configByName[parameter.Name] = parameter; + } + } + + IReadOnlyDictionary? dbParameters = + (databaseObject as DatabaseStoredProcedure)?.StoredProcedureDefinition?.Parameters; - if (parameters != null) + if (dbParameters is { Count: > 0 }) { - foreach (ParameterMetadata param in parameters) + List result = new(dbParameters.Count); + foreach (string parameterName in dbParameters.Keys) { - Dictionary paramInfo = new() - { - ["name"] = param.Name, - ["required"] = param.Required, - ["default"] = param.Default, - ["description"] = param.Description ?? string.Empty - }; - result.Add(paramInfo); + configByName.TryGetValue(parameterName, out ParameterMetadata? config); + result.Add(BuildParameterEntry(parameterName, config)); } + + return result; } - return result; + // DB metadata unavailable: emit config parameters as-is so the tool stays usable. + List configOnly = new(); + if (configParameters is not null) + { + foreach (ParameterMetadata parameter in configParameters) + { + configOnly.Add(BuildParameterEntry(parameter.Name, parameter)); + } + } + + return configOnly; } + private static Dictionary BuildParameterEntry(string name, ParameterMetadata? config) => new() + { + ["name"] = name, + ["required"] = config?.Required ?? true, + ["default"] = config?.Default, + ["description"] = config?.Description ?? string.Empty + }; + /// /// Build a list of permission metadata info for the current user's role /// diff --git a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs new file mode 100644 index 0000000000..5d6b75fdf2 --- /dev/null +++ b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +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.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Validates the stored-procedure parameter merge contract used by + /// . See that method's XML doc for the rules. + /// + [TestClass] + public class DescribeEntitiesStoredProcedureParametersTests + { + private const string TEST_ENTITY_NAME = "GetBook"; + + /// + /// Parameters discovered from DB metadata but absent from config are still surfaced. Their + /// required flag defaults to true; default and description are config-only and absent. + /// + [TestMethod] + public async Task DescribeEntities_DiscoversParametersFromMetadata_WhenConfigParametersMissing() + { + JsonElement parameters = await RunDescribeAsync( + configParameters: null, + dbParameters: new Dictionary + { + ["id"] = new() { Required = true, Description = "Book id (ignored: description is config-only)" }, + ["locale"] = new() { Required = false, Default = "en-US", Description = "Locale (ignored)" } + }); + + Assert.AreEqual(2, parameters.GetArrayLength()); + AssertParameter(parameters, name: "id", expectedRequired: true, expectedDefault: null, expectedDescription: string.Empty); + AssertParameter(parameters, name: "locale", expectedRequired: true, expectedDefault: null, expectedDescription: string.Empty); + } + + /// + /// When a parameter exists in both config and DB, config wins for required, default, and description. + /// Parameters present only in DB default to required: true with no default or description. + /// + [TestMethod] + public async Task DescribeEntities_ConfigOverridesDatabaseMetadata_AndDbOnlyParameterDefaultsToRequired() + { + JsonElement parameters = await RunDescribeAsync( + configParameters: new List + { + new() { Name = "id", Required = true, Default = "42", Description = "Config description" } + }, + dbParameters: new Dictionary + { + ["id"] = new() { Required = false, Default = "1", Description = "Database description" }, + ["tenant"] = new() { Required = true, Description = "Tenant from DB (ignored)" } + }); + + Assert.AreEqual(2, parameters.GetArrayLength()); + AssertParameter(parameters, name: "id", expectedRequired: true, expectedDefault: "42", expectedDescription: "Config description"); + AssertParameter(parameters, name: "tenant", expectedRequired: true, expectedDefault: null, expectedDescription: string.Empty); + } + + /// + /// Explicit required: false in config is honored even when DB metadata reports required: true. + /// The "default to true" rule only applies when the parameter is absent from config altogether. + /// + [TestMethod] + public async Task DescribeEntities_ConfigRequiredFalse_IsHonored() + { + JsonElement parameters = await RunDescribeAsync( + configParameters: new List + { + new() { Name = "locale", Required = false, Default = "en-US", Description = "Locale override" } + }, + dbParameters: new Dictionary + { + ["locale"] = new() { Required = true, Description = "DB description (ignored)" } + }); + + Assert.AreEqual(1, parameters.GetArrayLength()); + AssertParameter(parameters, name: "locale", expectedRequired: false, expectedDefault: "en-US", expectedDescription: "Locale override"); + } + + /// + /// Degraded fallback: when DB metadata is unavailable for the entity, the tool surfaces config-declared + /// parameters as-is so the response is still useful. + /// + [TestMethod] + public async Task DescribeEntities_FallsBackToConfigParameters_WhenDatabaseMetadataUnavailable() + { + JsonElement parameters = await RunDescribeAsync( + configParameters: new List + { + new() { Name = "id", Required = true, Description = "Book id" }, + new() { Name = "locale", Required = false, Default = "en-US", Description = "Locale" } + }, + dbParameters: null); + + Assert.AreEqual(2, parameters.GetArrayLength()); + AssertParameter(parameters, name: "id", expectedRequired: true, expectedDefault: null, expectedDescription: "Book id"); + AssertParameter(parameters, name: "locale", expectedRequired: false, expectedDefault: "en-US", expectedDescription: "Locale"); + } + + /// + /// Builds the in-memory DI container, executes , and returns the + /// parameters array of the single emitted entity. + /// + /// Config-declared parameters for the entity, or null for none. + /// DB-discovered parameters for the entity, or null to simulate the + /// metadata provider not having an entry for the entity (degraded path). + private static async Task RunDescribeAsync( + List? configParameters, + Dictionary? dbParameters) + { + RuntimeConfig config = CreateRuntimeConfig(CreateStoredProcedureEntity(parameters: configParameters)); + ServiceCollection services = new(); + RegisterCommonServices(services, config); + + DatabaseObject? dbObject = dbParameters is null + ? null + : CreateStoredProcedureObject("dbo", "get_book", dbParameters); + RegisterMetadataProvider(services, TEST_ENTITY_NAME, dbObject); + + IServiceProvider serviceProvider = services.BuildServiceProvider(); + DescribeEntitiesTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + Assert.IsTrue(result.IsError == false || result.IsError == null); + + JsonElement entity = GetSingleEntityFromResult(result); + return entity.GetProperty("parameters"); + } + + /// + /// Asserts that a parameter in the emitted JSON array matches the expected merge result. + /// + /// Expected default value as a string, or null to assert that the JSON value is null. + private static void AssertParameter( + JsonElement parameters, + string name, + bool expectedRequired, + string? expectedDefault, + string expectedDescription) + { + JsonElement param = parameters.EnumerateArray().Single(p => p.GetProperty("name").GetString() == name); + + Assert.AreEqual(expectedRequired, param.GetProperty("required").GetBoolean(), $"required mismatch for parameter '{name}'."); + + JsonElement defaultElement = param.GetProperty("default"); + if (expectedDefault is null) + { + Assert.AreEqual(JsonValueKind.Null, defaultElement.ValueKind, $"default should be JSON null for parameter '{name}'."); + } + else + { + Assert.AreEqual(expectedDefault, defaultElement.GetString(), $"default mismatch for parameter '{name}'."); + } + + Assert.AreEqual(expectedDescription, param.GetProperty("description").GetString(), $"description mismatch for parameter '{name}'."); + } + + private static RuntimeConfig CreateRuntimeConfig(Entity storedProcedureEntity) + { + Dictionary entities = new() + { + [TEST_ENTITY_NAME] = storedProcedureEntity + }; + + 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) + ); + } + + private static Entity CreateStoredProcedureEntity(List? parameters) + { + return new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, parameters, null), + GraphQL: new(TEST_ENTITY_NAME, TEST_ENTITY_NAME), + Rest: new(Enabled: true), + Fields: null, + Permissions: new[] + { + new EntityPermission( + Role: "anonymous", + Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) + }, + Relationships: null, + Mappings: null, + Mcp: null + ); + } + + private static DatabaseStoredProcedure CreateStoredProcedureObject( + string schema, + string name, + Dictionary parameters) + { + return new DatabaseStoredProcedure(schema, name) + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new StoredProcedureDefinition + { + Parameters = parameters + } + }; + } + + private static void RegisterCommonServices(ServiceCollection services, RuntimeConfig config) + { + 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(); + } + + /// + /// Registers a mock metadata provider. When is null, the provider + /// is wired up but reports no mapping for the entity (simulates the "metadata not available" path). + /// + private static void RegisterMetadataProvider(ServiceCollection services, string entityName, DatabaseObject? dbObject) + { + Dictionary entityMap = dbObject is null + ? new Dictionary() + : new Dictionary { [entityName] = dbObject }; + + Mock mockSqlMetadataProvider = new(); + mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(entityMap); + mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL); + + Mock mockMetadataProviderFactory = new(); + mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockSqlMetadataProvider.Object); + services.AddSingleton(mockMetadataProviderFactory.Object); + } + + private static JsonElement GetSingleEntityFromResult(CallToolResult result) + { + Assert.IsNotNull(result.Content); + Assert.IsTrue(result.Content.Count > 0); + Assert.IsInstanceOfType(result.Content[0], typeof(TextContentBlock)); + + TextContentBlock firstContent = (TextContentBlock)result.Content[0]; + JsonElement root = JsonDocument.Parse(firstContent.Text!).RootElement; + JsonElement entities = root.GetProperty("entities"); + + Assert.AreEqual(1, entities.GetArrayLength()); + return entities.EnumerateArray().First(); + } + } +}