Skip to content
Merged
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
154 changes: 150 additions & 4 deletions src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ namespace Azure.DataApiBuilder.Mcp.Core
public class DynamicCustomTool : IMcpTool
{
private readonly Entity _entity;
private JsonElement? _cachedInputSchema;

/// <summary>
/// Initializes a new instance of DynamicCustomTool.
Expand Down Expand Up @@ -71,6 +72,19 @@ public DynamicCustomTool(string entityName, Entity entity)
/// </summary>
public string EntityName { get; }

/// <summary>
/// Initializes the tool's input schema using DB metadata from the service provider.
/// Called after DI initialization to enrich the tool schema with DB-discovered parameters
/// and type information that aren't available at construction time.
/// Falls back silently to config-based schema if DB metadata is unavailable.
/// </summary>
/// <param name="serviceProvider">The application service provider with initialized metadata providers.</param>
public void InitializeMetadata(IServiceProvider serviceProvider)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
_cachedInputSchema = BuildInputSchemaFromDbMetadata(serviceProvider);
Comment thread
souvikghosh04 marked this conversation as resolved.
}

/// <summary>
/// Gets the metadata for this custom tool, including name, description, and input schema.
/// </summary>
Expand Down Expand Up @@ -291,9 +305,87 @@ private static string ConvertToToolName(string entityName)
}

/// <summary>
/// Builds the input schema for the tool based on entity parameters.
/// Builds the input schema for the tool. Returns cached DB-metadata-based schema
/// if available (set by InitializeMetadata), otherwise falls back to config-based schema.
/// </summary>
private JsonElement BuildInputSchema()
{
if (_cachedInputSchema.HasValue)
{
return _cachedInputSchema.Value;
}

return BuildInputSchemaFromConfig();
}

/// <summary>
/// Builds the input schema from DB metadata (StoredProcedureDefinition.Parameters).
/// Returns null if metadata cannot be resolved (caller should fall back to config-based schema).
/// </summary>
private JsonElement? BuildInputSchemaFromDbMetadata(IServiceProvider serviceProvider)
{
RuntimeConfigProvider? configProvider = serviceProvider.GetService<RuntimeConfigProvider>();
if (configProvider is null)
{
return null;
}

RuntimeConfig config = configProvider.GetConfig();

if (!McpMetadataHelper.TryResolveMetadata(
EntityName,
config,
serviceProvider,
out _,
out DatabaseObject dbObject,
out _,
out _))
{
return null;
}

if (dbObject is not DatabaseStoredProcedure storedProcedure)
{
return null;
}

StoredProcedureDefinition spDefinition = storedProcedure.StoredProcedureDefinition;
if (spDefinition.Parameters is null || spDefinition.Parameters.Count == 0)
{
// Zero-param SP: return empty properties schema
return JsonSerializer.SerializeToElement(new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>()
});
}

Dictionary<string, object> properties = new();
foreach ((string paramName, ParameterDefinition paramDef) in spDefinition.Parameters)
Comment thread
souvikghosh04 marked this conversation as resolved.
{
Dictionary<string, object> paramSchema = new()
{
["type"] = MapSystemTypeToJsonSchemaType(paramDef.SystemType),
["description"] = BuildParameterDescription(paramName, paramDef)
};

properties[paramName] = paramSchema;
}

Dictionary<string, object> schema = new()
{
["type"] = "object",
["properties"] = properties
};

return JsonSerializer.SerializeToElement(schema);
}

/// <summary>
/// Builds the input schema from config-side ParameterMetadata.
/// Used as fallback when DB metadata is not available.
/// </summary>
private JsonElement BuildInputSchemaFromConfig()
{
Dictionary<string, object> schema = new()
{
Expand All @@ -307,9 +399,6 @@ private JsonElement BuildInputSchema()

foreach (ParameterMetadata param in _entity.Source.Parameters)
{
// Note: Parameter type information is not available in ParameterMetadata,
// so we allow multiple JSON types to match the behavior of GetParameterValue
// that handles string, number, boolean, and null values.
properties[param.Name] = new Dictionary<string, object>
{
["type"] = new[] { "string", "number", "boolean", "null" },
Expand All @@ -321,6 +410,63 @@ private JsonElement BuildInputSchema()
return JsonSerializer.SerializeToElement(schema);
}

/// <summary>
/// Maps a .NET System.Type to the appropriate JSON Schema type string.
/// </summary>
private static object MapSystemTypeToJsonSchemaType(Type? systemType)
{
if (systemType is null)
{
return new[] { "string", "number", "boolean", "null" };
}

// Handle nullable types
Type underlyingType = Nullable.GetUnderlyingType(systemType) ?? systemType;

if (underlyingType == typeof(int) || underlyingType == typeof(long) ||
underlyingType == typeof(short) || underlyingType == typeof(byte) ||
underlyingType == typeof(sbyte) || underlyingType == typeof(uint) ||
underlyingType == typeof(ulong) || underlyingType == typeof(ushort))
{
return "integer";
}

if (underlyingType == typeof(float) || underlyingType == typeof(double) ||
underlyingType == typeof(decimal))
{
return "number";
}

if (underlyingType == typeof(bool))
{
return "boolean";
}

if (underlyingType == typeof(string) || underlyingType == typeof(Guid) ||
underlyingType == typeof(DateTime) || underlyingType == typeof(DateTimeOffset))
{
return "string";
}

// Default: permissive multi-type
return new[] { "string", "number", "boolean", "null" };
}

/// <summary>
/// Builds a description string for a parameter using DB metadata.
/// Uses ParameterDefinition.Description when available, falling back to generic text.
/// </summary>
private static string BuildParameterDescription(string paramName, ParameterDefinition paramDef)
{
string description = paramDef.Description ?? $"Parameter {paramName}";
if (paramDef.HasConfigDefault)
{
description += $" (default: {paramDef.ConfigDefaultValue})";
}

return description;
}

/// <summary>
/// Converts a JSON element to its appropriate CLR type.
/// </summary>
Expand Down
20 changes: 20 additions & 0 deletions src/Azure.DataApiBuilder.Mcp/Core/McpToolRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,25 @@ public bool TryGetTool(string toolName, out IMcpTool? tool)
{
return _tools.TryGetValue(toolName, out tool);
}

/// <summary>
/// Initializes and registers all MCP tools, enriching custom tools with DB metadata schemas.
/// Shared by both HTTP hosted-service and stdio startup paths.
/// </summary>
public static void InitializeAndRegisterTools(
IEnumerable<IMcpTool> tools,
McpToolRegistry registry,
IServiceProvider serviceProvider)
{
foreach (IMcpTool tool in tools)
{
if (tool is DynamicCustomTool customTool)
{
customTool.InitializeMetadata(serviceProvider);
Comment thread
souvikghosh04 marked this conversation as resolved.
}

registry.RegisterTool(tool);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,8 @@ public McpToolRegistryInitializer(IServiceProvider serviceProvider, McpToolRegis

public Task StartAsync(CancellationToken cancellationToken)
{
// Register all IMcpTool implementations
IEnumerable<IMcpTool> tools = _serviceProvider.GetServices<IMcpTool>();
foreach (IMcpTool tool in tools)
{
_toolRegistry.RegisterTool(tool);
}

McpToolRegistry.InitializeAndRegisterTools(tools, _toolRegistry, _serviceProvider);
return Task.CompletedTask;
}

Expand Down
74 changes: 74 additions & 0 deletions src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,80 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN
StringAssert.Contains(content, paramName);
}

#region Schema Alignment Integration Tests

/// <summary>
/// Validates that InitializeMetadata maps DB parameter types to JSON Schema types.
/// </summary>
[DataTestMethod]
[DataRow("GetBook", "id", "integer", DisplayName = "int param maps to integer")]
[DataRow("InsertBook", "title", "string", DisplayName = "varchar param maps to string")]
[DataRow("InsertBook", "publisher_id", "integer", DisplayName = "int param maps to integer (multi-param SP)")]
public void InitializeMetadata_SchemaReflectsDbParameterTypes(string entityName, string paramName, string expectedType)
{
IServiceProvider serviceProvider = BuildServiceProvider();
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
Entity entity = configProvider.GetConfig().Entities[entityName];

DynamicCustomTool tool = new(entityName, entity);
tool.InitializeMetadata(serviceProvider);

JsonElement properties = tool.GetToolMetadata().InputSchema.GetProperty("properties");

Assert.IsTrue(properties.TryGetProperty(paramName, out JsonElement paramProp),
$"Schema should contain '{paramName}' property.");
Assert.AreEqual(expectedType, paramProp.GetProperty("type").GetString(),
$"'{paramName}' should map to JSON Schema type '{expectedType}'.");
}

/// <summary>
/// Validates that zero-param SP produces an empty properties object.
/// </summary>
[TestMethod]
public void InitializeMetadata_ZeroParamSP_HasEmptyProperties()
{
IServiceProvider serviceProvider = BuildServiceProvider();
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
Entity entity = configProvider.GetConfig().Entities["GetBooks"];

DynamicCustomTool tool = new("GetBooks", entity);
tool.InitializeMetadata(serviceProvider);

JsonElement properties = tool.GetToolMetadata().InputSchema.GetProperty("properties");

int paramCount = 0;
foreach (JsonProperty _ in properties.EnumerateObject())
{
paramCount++;
}

Assert.AreEqual(0, paramCount, "Zero-param SP should produce empty properties.");
}

/// <summary>
/// Validates that config default values appear in parameter descriptions.
/// </summary>
[DataTestMethod]
[DataRow("InsertBook", "title", "randomX", DisplayName = "title description includes default 'randomX'")]
[DataRow("InsertBook", "publisher_id", "1234", DisplayName = "publisher_id description includes default '1234'")]
public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityName, string paramName, string expectedDefault)
{
IServiceProvider serviceProvider = BuildServiceProvider();
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
Entity entity = configProvider.GetConfig().Entities[entityName];

DynamicCustomTool tool = new(entityName, entity);
tool.InitializeMetadata(serviceProvider);

JsonElement properties = tool.GetToolMetadata().InputSchema.GetProperty("properties");
string description = properties.GetProperty(paramName).GetProperty("description").GetString()!;

StringAssert.Contains(description, expectedDefault,
$"'{paramName}' description should mention config default '{expectedDefault}'.");
}

#endregion

/// <summary>
/// Executes a DynamicCustomTool for the given entity using the shared test fixture.
/// </summary>
Expand Down
Loading