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
10 changes: 5 additions & 5 deletions config-generators/mssql-commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ add User_AutogenRelationshipColumn --config "dab-config.MsSql.json" --source "us
add User_AutogenToNonAutogenRelationshipColumn --config "dab-config.MsSql.json" --source "users" --permissions "anonymous:*" --rest true --graphql true
add User_RepeatedReferencingColumnToOneEntity --config "dab-config.MsSql.json" --source "users" --permissions "anonymous:*" --rest true --graphql true
add UserProfile_RepeatedReferencingColumnToTwoEntities --config "dab-config.MsSql.json" --source "user_profiles" --permissions "anonymous:*" --rest true --graphql true
add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true
add GetBook --config "dab-config.MsSql.json" --source "get_book_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql false
add GetPublisher --config "dab-config.MsSql.json" --source "get_publisher_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true --graphql.operation "query"
add InsertBook --config "dab-config.MsSql.json" --source "insert_book" --source.type "stored-procedure" --source.params "title:randomX,publisher_id:1234" --permissions "anonymous:execute" --rest true --graphql true
add CountBooks --config "dab-config.MsSql.json" --source "count_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true
add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true --mcp.custom-tool true
add GetBook --config "dab-config.MsSql.json" --source "get_book_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql false --mcp.custom-tool true
add GetPublisher --config "dab-config.MsSql.json" --source "get_publisher_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true --graphql.operation "query" --mcp.custom-tool true
add InsertBook --config "dab-config.MsSql.json" --source "insert_book" --source.type "stored-procedure" --source.params "title:randomX,publisher_id:1234" --permissions "anonymous:execute" --rest true --graphql true --mcp.custom-tool true
add CountBooks --config "dab-config.MsSql.json" --source "count_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true --mcp.custom-tool true
add DeleteLastInsertedBook --config "dab-config.MsSql.json" --source "delete_last_inserted_book" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true
add UpdateBookTitle --config "dab-config.MsSql.json" --source "update_book_title" --source.type "stored-procedure" --source.params "id:1,title:Testing Tonight" --permissions "anonymous:execute" --rest true --graphql true
add GetAuthorsHistoryByFirstName --config "dab-config.MsSql.json" --source "get_authors_history_by_first_name" --source.type "stored-procedure" --source.params "firstName:Aaron" --permissions "anonymous:execute" --rest true --graphql SearchAuthorByFirstName
Expand Down
29 changes: 20 additions & 9 deletions src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,22 @@ public async Task<CallToolResult> ExecuteAsync(
return McpErrorHelpers.PermissionDenied(toolName, entity, "execute", authError, logger);
}

// 7) Validate parameters against metadata
if (parameters != null && entityConfig.Source.Parameters != null)
// 7) Validate parameters against DB metadata (StoredProcedureDefinition.Parameters),
// which is the source of truth for parameter names. The upstream merge performed by
// FillSchemaForStoredProcedureAsync ensures this dictionary contains all valid parameters.
// Note: Comparison is case-sensitive (default Dictionary<string,...> comparer),
// consistent with the existing REST/GraphQL SP execution path.
if (dbObject is not DatabaseStoredProcedure storedProcedure)
{
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entity}' is not a stored procedure.", logger);
}

StoredProcedureDefinition spDefinition = storedProcedure.StoredProcedureDefinition;
if (parameters != null && spDefinition.Parameters is not null)
{
// Validate all provided parameters exist in metadata
foreach (KeyValuePair<string, object?> param in parameters)
{
if (!entityConfig.Source.Parameters.Any(p => p.Name == param.Key))
if (!spDefinition.Parameters.ContainsKey(param.Key))
{
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Invalid parameter: {param.Key}", logger);
}
Expand Down Expand Up @@ -203,14 +212,16 @@ public async Task<CallToolResult> ExecuteAsync(
}
}

// Then, add default parameters from configuration (only if not already provided by user)
if ((parameters == null || parameters.Count == 0) && entityConfig.Source.Parameters != null)
// Apply config-declared defaults from the merged ParameterDefinitions.
// This covers all parameters (including DB-discovered ones with config defaults)
// and applies them when the user didn't supply a value.
if (spDefinition.Parameters is not null)
{
foreach (ParameterMetadata param in entityConfig.Source.Parameters)
foreach ((string paramName, ParameterDefinition paramDef) in spDefinition.Parameters)
{
if (!context.FieldValuePairsInBody.ContainsKey(param.Name))
if (!context.FieldValuePairsInBody.ContainsKey(paramName) && paramDef.HasConfigDefault)
{
context.FieldValuePairsInBody[param.Name] = param.Default;
context.FieldValuePairsInBody[paramName] = paramDef.ConfigDefaultValue;
}
}
}
Expand Down
35 changes: 29 additions & 6 deletions src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,28 @@ public async Task<CallToolResult> ExecuteAsync(
return McpErrorHelpers.PermissionDenied(toolName, EntityName, "execute", authError, logger);
}

// 6) Build request payload
// 6) Validate parameters against DB metadata (StoredProcedureDefinition.Parameters),
// which is the source of truth for parameter names.
// Note: Comparison is case-sensitive (default Dictionary<string,...> comparer),
// consistent with the existing REST/GraphQL SP execution path.
if (dbObject is not DatabaseStoredProcedure storedProcedure)
{
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{EntityName}' is not a stored procedure.", logger);
}

StoredProcedureDefinition spDefinition = storedProcedure.StoredProcedureDefinition;
if (parameters.Count > 0 && spDefinition.Parameters is not null)
{
foreach (KeyValuePair<string, object?> param in parameters)
{
if (!spDefinition.Parameters.ContainsKey(param.Key))
{
return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Invalid parameter: {param.Key}", logger);
}
}
}

// 7) Build request payload
JsonElement? requestPayloadRoot = null;
if (parameters.Count > 0)
{
Expand All @@ -195,14 +216,16 @@ public async Task<CallToolResult> ExecuteAsync(
}
}

// Add default parameters from configuration if not provided
if (entityConfig.Source.Parameters != null)
// Apply config-declared defaults from the merged ParameterDefinitions.
// This covers all parameters (including DB-discovered ones with config defaults)
// and applies them per-missing-parameter when the user didn't supply a value.
if (spDefinition.Parameters is not null)
{
foreach (ParameterMetadata param in entityConfig.Source.Parameters)
foreach ((string paramName, ParameterDefinition paramDef) in spDefinition.Parameters)
{
if (!context.FieldValuePairsInBody.ContainsKey(param.Name))
if (!context.FieldValuePairsInBody.ContainsKey(paramName) && paramDef.HasConfigDefault)
{
context.FieldValuePairsInBody[param.Name] = param.Default;
context.FieldValuePairsInBody[paramName] = paramDef.ConfigDefaultValue;
}
}
}
Expand Down
214 changes: 214 additions & 0 deletions src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

#nullable enable

using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Core.Resolvers;
using Azure.DataApiBuilder.Core.Resolvers.Factories;
using Azure.DataApiBuilder.Core.Services.Cache;
using Azure.DataApiBuilder.Mcp.Core;
using Azure.DataApiBuilder.Service.Tests.SqlTests;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ModelContextProtocol.Protocol;
using Moq;
using ZiggyCreatures.Caching.Fusion;

namespace Azure.DataApiBuilder.Service.Tests.Mcp
{
/// <summary>
/// Integration tests for DynamicCustomTool's parameter validation and default application.
/// Verifies the same execution-time fixes applied to ExecuteEntityTool also work correctly
/// for per-entity custom tools:
/// - Parameters are validated against StoredProcedureDefinition.Parameters (DB metadata).
/// - Config defaults are applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue.
///
/// Uses SPs defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json:
/// - GetBook -> SP get_book_by_id(@id int)
/// - InsertBook -> SP insert_book(@title, @publisher_id), config defaults title=randomX, publisher_id=1234
/// - GetBooks -> SP get_books, zero params
/// </summary>
[TestClass, TestCategory(TestCategory.MSSQL)]
public class DynamicCustomToolMsSqlIntegrationTests : SqlTestBase
{
[ClassInitialize]
public static async Task SetupAsync(TestContext context)
{
DatabaseEngine = TestCategory.MSSQL;
await InitializeTestFixture();
}

/// <summary>
/// Data-driven test validating successful SP execution via DynamicCustomTool.
/// </summary>
[DataTestMethod]
[DataRow("GetBook", "{\"id\": 1}", DisplayName = "DB-discovered param accepted")]
[DataRow("InsertBook", null, DisplayName = "Config defaults applied when no params supplied")]
[DataRow("InsertBook", "{\"title\": \"Custom Tool Test\", \"publisher_id\": 2345}", DisplayName = "User params override defaults")]
[DataRow("GetBooks", null, DisplayName = "Zero-param SP succeeds")]
public async Task DynamicCustomTool_SuccessfulExecution(string entityName, string? parametersJson)
{
Dictionary<string, object>? parameters = parametersJson != null
? JsonSerializer.Deserialize<Dictionary<string, object>>(parametersJson)
: null;

CallToolResult result = await ExecuteCustomToolAsync(entityName, parameters);

AssertSuccess(result,
$"Custom tool failed for entity '{entityName}' with params '{parametersJson}'.");

string content = GetFirstTextContent(result);
Assert.IsFalse(string.IsNullOrWhiteSpace(content), $"Expected non-empty result for entity '{entityName}'.");

using JsonDocument doc = JsonDocument.Parse(content);
JsonElement root = doc.RootElement;
Assert.AreEqual(entityName, root.GetProperty("entity").GetString());
Assert.AreEqual("Stored procedure executed successfully", root.GetProperty("message").GetString());
}

/// <summary>
/// Verify GetBook with id=1 returns a matching record through DynamicCustomTool.
/// Unlike SuccessfulExecution data rows (which validate response structure only),
/// this test validates the actual returned data content (id field in the result).
/// </summary>
[TestMethod]
public async Task DynamicCustomTool_GetBookById_ReturnsMatchingRecord()
Comment thread
souvikghosh04 marked this conversation as resolved.
{
Dictionary<string, object> parameters = new() { { "id", 1 } };
CallToolResult result = await ExecuteCustomToolAsync("GetBook", parameters);

AssertSuccess(result, "GetBook with id=1 should succeed.");

using JsonDocument doc = JsonDocument.Parse(GetFirstTextContent(result));
JsonElement root = doc.RootElement;

Assert.IsTrue(root.TryGetProperty("value", out JsonElement valueWrapper), "Response should contain 'value' property.");

JsonElement records = valueWrapper.ValueKind == JsonValueKind.Object
? valueWrapper.GetProperty("value")
: valueWrapper;

Assert.AreEqual(JsonValueKind.Array, records.ValueKind);
Assert.IsTrue(records.GetArrayLength() > 0, "Expected at least one book record.");
Assert.AreEqual(1, records[0].GetProperty("id").GetInt32());
}

/// <summary>
/// Reject a parameter name that does not exist in the DB metadata.
/// </summary>
[DataTestMethod]
[DataRow("GetBook", "nonexistent_param", "value", DisplayName = "Rejects unknown param on single-param SP")]
[DataRow("GetBooks", "bogus", "123", DisplayName = "Rejects any param on zero-param SP")]
public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityName, string paramName, string paramValue)
{
Dictionary<string, object> parameters = new() { { paramName, paramValue } };
CallToolResult result = await ExecuteCustomToolAsync(entityName, parameters);

Assert.IsTrue(result.IsError == true,
$"Custom tool should reject parameter '{paramName}' not in DB metadata for '{entityName}'.");
string content = GetFirstTextContent(result);
StringAssert.Contains(content, paramName);
}

/// <summary>
/// Executes a DynamicCustomTool for the given entity using the shared test fixture.
/// </summary>
private static async Task<CallToolResult> ExecuteCustomToolAsync(string entityName, Dictionary<string, object>? parameters)
{
IServiceProvider serviceProvider = BuildServiceProvider();

// Resolve the entity config from the runtime config
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>();
RuntimeConfig config = configProvider.GetConfig();
Entity entity = config.Entities[entityName];

DynamicCustomTool tool = new(entityName, entity);

// DynamicCustomTool expects parameters as top-level JSON properties (no "entity" wrapper)
string argsJson = parameters != null
? JsonSerializer.Serialize(parameters)
: "{}";
using JsonDocument arguments = JsonDocument.Parse(argsJson);

return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None);
}

/// <summary>
/// Builds a service provider wired to the shared fixture's real providers.
/// Uses the same pattern as ExecuteEntityToolMsSqlIntegrationTests.
/// </summary>
private static IServiceProvider BuildServiceProvider()
{
ServiceCollection services = new();

RuntimeConfigProvider configProvider = _application.Services.GetRequiredService<RuntimeConfigProvider>();
services.AddSingleton(configProvider);

services.AddSingleton(_metadataProviderFactory.Object);
services.AddSingleton(_authorizationResolver);

DefaultHttpContext httpContext = new();
httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS;
ClaimsIdentity identity = new(
authenticationType: "TestAuth",
nameType: null,
roleType: AuthenticationOptions.ROLE_CLAIM_TYPE);
identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS));
httpContext.User = new ClaimsPrincipal(identity);
IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
services.AddSingleton(httpContextAccessor);

Mock<IFusionCache> cache = new();
DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor);

SqlQueryEngine queryEngine = new(
_queryManagerFactory.Object,
_metadataProviderFactory.Object,
httpContextAccessor,
_authorizationResolver,
_gqlFilterParser,
new Mock<ILogger<IQueryEngine>>().Object,
configProvider,
cacheService);

Mock<IQueryEngineFactory> queryEngineFactory = new();
queryEngineFactory
.Setup(f => f.GetQueryEngine(It.IsAny<DatabaseType>()))
.Returns(queryEngine);
services.AddSingleton(queryEngineFactory.Object);

services.AddLogging();

return services.BuildServiceProvider();
}

private static string GetFirstTextContent(CallToolResult result)
{
if (result.Content is null || result.Content.Count == 0)
{
return string.Empty;
}

return result.Content[0] is TextContentBlock textBlock
? textBlock.Text ?? string.Empty
: string.Empty;
}

private static void AssertSuccess(CallToolResult result, string message)
{
Assert.IsTrue(result.IsError != true,
$"{message} Content: {GetFirstTextContent(result)}");
}
}
}
Loading