From c5056bbf6ab09420a6bb0166dac11940c5f99788 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Thu, 9 Apr 2026 16:22:53 -0700 Subject: [PATCH] Added missing stored procedure parameters in describe_entities response. --- .../BuiltInTools/DescribeEntitiesTool.cs | 96 ++++++++- ...eEntitiesStoredProcedureParametersTests.cs | 204 ++++++++++++++++++ 2 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 95e37c4498..fede90c74e 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -3,9 +3,11 @@ 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; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; using Azure.DataApiBuilder.Mcp.Utils; using Azure.DataApiBuilder.Service.Exceptions; @@ -179,7 +181,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 +405,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 +425,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 +433,45 @@ 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; + } + + IMetadataProviderFactory? metadataProviderFactory = serviceProvider.GetService(); + if (metadataProviderFactory is null) + { + 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. /// @@ -461,10 +503,56 @@ private static List BuildFieldMetadataInfo(List? fields) /// 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) + private static List BuildParameterMetadataInfo(List? parameters, DatabaseObject? databaseObject) { List result = new(); + Dictionary configParameters = new(StringComparer.OrdinalIgnoreCase); + if (parameters != null) + { + foreach (ParameterMetadata parameter in parameters) + { + configParameters[parameter.Name] = parameter; + } + } + + if (databaseObject is DatabaseStoredProcedure storedProcedure) + { + foreach ((string parameterName, ParameterDefinition parameterDefinition) in storedProcedure.StoredProcedureDefinition.Parameters) + { + configParameters.TryGetValue(parameterName, out ParameterMetadata? configParameter); + + Dictionary paramInfo = new() + { + ["name"] = configParameter?.Name ?? parameterName, + ["required"] = configParameter?.Required ?? parameterDefinition.Required ?? false, + ["default"] = configParameter?.Default ?? parameterDefinition.Default, + ["description"] = configParameter?.Description ?? parameterDefinition.Description ?? string.Empty + }; + + result.Add(paramInfo); + } + + // Preserve config-only parameters if metadata is not available for a configured name. + foreach (ParameterMetadata configParameter in configParameters.Values) + { + if (!storedProcedure.StoredProcedureDefinition.Parameters.ContainsKey(configParameter.Name)) + { + Dictionary paramInfo = new() + { + ["name"] = configParameter.Name, + ["required"] = configParameter.Required, + ["default"] = configParameter.Default, + ["description"] = configParameter.Description ?? string.Empty + }; + + result.Add(paramInfo); + } + } + + return result; + } + if (parameters != null) { foreach (ParameterMetadata param in parameters) diff --git a/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs new file mode 100644 index 0000000000..e232cfcb3f --- /dev/null +++ b/src/Service.Tests/Mcp/DescribeEntitiesStoredProcedureParametersTests.cs @@ -0,0 +1,204 @@ +// 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.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 +{ + [TestClass] + public class DescribeEntitiesStoredProcedureParametersTests + { + [TestMethod] + public async Task DescribeEntities_IncludesStoredProcedureParametersFromMetadata_WhenConfigParametersMissing() + { + RuntimeConfig config = CreateRuntimeConfig(CreateStoredProcedureEntity(parameters: null)); + ServiceCollection services = new(); + RegisterCommonServices(services, config); + RegisterMetadataProvider(services, "GetBook", CreateStoredProcedureObject("dbo", "get_book", new Dictionary + { + ["id"] = new() { Required = true, Description = "Book id" }, + ["locale"] = new() { Required = false, Default = "en-US", Description = "Locale" } + })); + + 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); + JsonElement parameters = entity.GetProperty("parameters"); + Assert.AreEqual(2, parameters.GetArrayLength(), "Stored procedure parameters should be sourced from metadata when not specified in config."); + + JsonElement idParam = parameters.EnumerateArray().Single(p => p.GetProperty("name").GetString() == "id"); + Assert.IsTrue(idParam.GetProperty("required").GetBoolean()); + Assert.AreEqual("Book id", idParam.GetProperty("description").GetString()); + + JsonElement localeParam = parameters.EnumerateArray().Single(p => p.GetProperty("name").GetString() == "locale"); + Assert.AreEqual("en-US", localeParam.GetProperty("default").GetString()); + } + + [TestMethod] + public async Task DescribeEntities_ConfigParameterMetadataOverridesDatabaseParameterMetadata() + { + List configuredParameters = new() + { + new() { Name = "id", Required = true, Default = "42", Description = "Config description" } + }; + + RuntimeConfig config = CreateRuntimeConfig(CreateStoredProcedureEntity(parameters: configuredParameters)); + ServiceCollection services = new(); + RegisterCommonServices(services, config); + RegisterMetadataProvider(services, "GetBook", CreateStoredProcedureObject("dbo", "get_book", new Dictionary + { + ["id"] = new() { Required = false, Default = "1", Description = "Database description" }, + ["tenant"] = new() { Required = true, Description = "Tenant from DB" } + })); + + 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); + JsonElement parameters = entity.GetProperty("parameters"); + Assert.AreEqual(2, parameters.GetArrayLength()); + + JsonElement idParam = parameters.EnumerateArray().Single(p => p.GetProperty("name").GetString() == "id"); + Assert.IsTrue(idParam.GetProperty("required").GetBoolean(), "Config required value should override DB metadata."); + Assert.AreEqual("Config description", idParam.GetProperty("description").GetString(), "Config description should override DB metadata."); + Assert.AreEqual("42", idParam.GetProperty("default").ToString(), "Config default should override DB metadata."); + + JsonElement tenantParam = parameters.EnumerateArray().Single(p => p.GetProperty("name").GetString() == "tenant"); + Assert.IsTrue(tenantParam.GetProperty("required").GetBoolean()); + Assert.AreEqual("Tenant from DB", tenantParam.GetProperty("description").GetString()); + } + + private static RuntimeConfig CreateRuntimeConfig(Entity storedProcedureEntity) + { + Dictionary entities = new() + { + ["GetBook"] = 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("GetBook", "GetBook"), + 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(); + } + + private static void RegisterMetadataProvider(ServiceCollection services, string entityName, DatabaseObject dbObject) + { + Mock mockSqlMetadataProvider = new(); + mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(new Dictionary + { + [entityName] = dbObject + }); + 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(); + } + } +}