Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -179,7 +181,7 @@ public Task<CallToolResult> ExecuteAsync(
{
Dictionary<string, object?> entityInfo = nameOnly
? BuildBasicEntityInfo(entityName, entity)
: BuildFullEntityInfo(entityName, entity, currentUserRole);
: BuildFullEntityInfo(entityName, entity, currentUserRole, TryResolveDatabaseObject(entityName, entity, runtimeConfig, serviceProvider, logger, cancellationToken));

entityList.Add(entityInfo);
}
Expand Down Expand Up @@ -403,10 +405,11 @@ private static bool ShouldIncludeEntity(string entityName, HashSet<string>? enti
/// <param name="entityName">The name of the entity to include in the dictionary.</param>
/// <param name="entity">The entity object from which to extract additional information.</param>
/// <param name="currentUserRole">The role of the current user, used to determine permissions.</param>
/// <param name="databaseObject">The resolved database object metadata if available.</param>
/// <returns>
/// A dictionary containing the entity's name, description, fields, parameters (if applicable), and permissions.
/// </returns>
private static Dictionary<string, object?> BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole)
private static Dictionary<string, object?> 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)
Expand All @@ -422,14 +425,53 @@ private static bool ShouldIncludeEntity(string entityName, HashSet<string>? 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);

return info;
}

/// <summary>
/// Tries to resolve the metadata-backed database object for an entity.
/// </summary>
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<IMetadataProviderFactory>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is the metadataProviderFactory used?

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;
}

/// <summary>
/// Builds a list of metadata information objects from the provided collection of fields.
/// </summary>
Expand Down Expand Up @@ -461,10 +503,56 @@ private static List<object> BuildFieldMetadataInfo(List<FieldMetadata>? fields)
/// <param name="parameters">A list of <see cref="ParameterMetadata"/> objects representing the parameters to process. Can be null.</param>
/// <returns>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 <paramref name="parameters"/> is null.</returns>
private static List<object> BuildParameterMetadataInfo(List<ParameterMetadata>? parameters)
private static List<object> BuildParameterMetadataInfo(List<ParameterMetadata>? parameters, DatabaseObject? databaseObject)
{
List<object> result = new();

Dictionary<string, ParameterMetadata> 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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the reason again to have two different data structures? ParameterDefinitionand ParameterMetadata? why couldnt we have used ParameterDefinition for the config parameters?

{
configParameters.TryGetValue(parameterName, out ParameterMetadata? configParameter);

Dictionary<string, object?> paramInfo = new()
{
["name"] = configParameter?.Name ?? parameterName,
["required"] = configParameter?.Required ?? parameterDefinition.Required ?? false,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging Required prefers configParameter.Required whenever a parameter is present in config, but ParameterMetadata.Required is a non-nullable bool so an omitted "required" in config deserializes to false and will override DB metadata (potentially marking required DB parameters as optional). If the intended behavior is "config overrides only when explicitly set", Required likely needs to become nullable (bool?) or track a user-provided flag and only override when provided.

Suggested change
["required"] = configParameter?.Required ?? parameterDefinition.Required ?? false,
["required"] = parameterDefinition.Required ?? configParameter?.Required ?? false,

Copilot uses AI. Check for mistakes.
["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<string, object?> paramInfo = new()
Comment on lines +536 to +541
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config-only parameter preservation check uses Parameters.ContainsKey(configParameter.Name), but StoredProcedureDefinition.Parameters is a default Dictionary<string, ParameterDefinition> (case-sensitive). Since configParameters matching earlier is case-insensitive, a casing difference (e.g., "Id" vs "id") can cause duplicates to be added. Use a case-insensitive key comparer for Parameters or perform the contains check with OrdinalIgnoreCase.

Copilot uses AI. Check for mistakes.
{
["name"] = configParameter.Name,
["required"] = configParameter.Required,
["default"] = configParameter.Default,
["description"] = configParameter.Description ?? string.Empty
};

result.Add(paramInfo);
}
}

return result;
Comment on lines +521 to +553
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildParameterMetadataInfo assumes storedProcedure.StoredProcedureDefinition (and its Parameters) is non-null. DatabaseStoredProcedure.StoredProcedureDefinition is declared nullable-forgiving (null!) and there are code paths/tests that construct DatabaseStoredProcedure without setting it, which would throw here and cause the entity to be omitted from the response. Guard for null (or empty) StoredProcedureDefinition/Parameters and fall back to config parameters when it isn't available.

Suggested change
foreach ((string parameterName, ParameterDefinition parameterDefinition) in storedProcedure.StoredProcedureDefinition.Parameters)
{
configParameters.TryGetValue(parameterName, out ParameterMetadata? configParameter);
Dictionary<string, object?> 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<string, object?> paramInfo = new()
{
["name"] = configParameter.Name,
["required"] = configParameter.Required,
["default"] = configParameter.Default,
["description"] = configParameter.Description ?? string.Empty
};
result.Add(paramInfo);
}
}
return result;
IReadOnlyDictionary<string, ParameterDefinition>? storedProcedureParameters =
storedProcedure.StoredProcedureDefinition?.Parameters;
if (storedProcedureParameters is not null && storedProcedureParameters.Count > 0)
{
foreach ((string parameterName, ParameterDefinition parameterDefinition) in storedProcedureParameters)
{
configParameters.TryGetValue(parameterName, out ParameterMetadata? configParameter);
Dictionary<string, object?> 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 (!storedProcedureParameters.ContainsKey(configParameter.Name))
{
Dictionary<string, object?> paramInfo = new()
{
["name"] = configParameter.Name,
["required"] = configParameter.Required,
["default"] = configParameter.Default,
["description"] = configParameter.Description ?? string.Empty
};
result.Add(paramInfo);
}
}
return result;
}

Copilot uses AI. Check for mistakes.
}

if (parameters != null)
{
foreach (ParameterMetadata param in parameters)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, ParameterDefinition>
{
["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<ParameterMetadata> 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<string, ParameterDefinition>
{
["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<string, Entity> 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<ParameterMetadata> 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<string, ParameterDefinition> 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<IAuthorizationResolver> mockAuthResolver = new();
mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny<HttpContext>())).Returns(true);
services.AddSingleton(mockAuthResolver.Object);

Mock<HttpContext> mockHttpContext = new();
Mock<HttpRequest> mockRequest = new();
mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous");
mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object);

Mock<IHttpContextAccessor> 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<ISqlMetadataProvider> mockSqlMetadataProvider = new();
mockSqlMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(new Dictionary<string, DatabaseObject>
{
[entityName] = dbObject
});
mockSqlMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL);

Mock<IMetadataProviderFactory> mockMetadataProviderFactory = new();
mockMetadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny<string>())).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();
}
}
}