diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index 7fb32d33c2..90b51d2837 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -164,13 +164,22 @@ public async Task 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 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 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); } @@ -203,14 +212,16 @@ public async Task 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; } } } diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..624990dc38 --- /dev/null +++ b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs @@ -0,0 +1,249 @@ +// 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.BuiltInTools; +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 +{ + /// + /// Integration tests for ExecuteEntityTool's parameter validation and default application. + /// Verifies the end-to-end behavior after the fix: + /// - Parameters are validated against StoredProcedureDefinition.Parameters (DB metadata), + /// not config-side parameters alone. + /// - Config defaults are applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue + /// for any parameter the user did not supply. + /// + /// Scenarios (reuse SPs already defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json): + /// - GetBook -> SP get_book_by_id(@id int), no config params. + /// - InsertBook -> SP insert_book(@title, @publisher_id), config defaults applied. + /// - GetBooks -> SP get_books, zero params. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class ExecuteEntityToolMsSqlIntegrationTests : SqlTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Data-driven test validating successful SP execution across multiple parameter scenarios. + /// Each row exercises a distinct code path in ExecuteEntityTool: + /// - DB-discovered param with no config entry (validates the fix for param validation). + /// - Config defaults applied when user omits params. + /// - User-supplied params override config defaults. + /// - Zero-param SP succeeds with no parameters. + /// + [DataTestMethod] + [DataRow("GetBook", "{\"id\": 1}", DisplayName = "DB-discovered param accepted (no config entry)")] + [DataRow("InsertBook", null, DisplayName = "Config defaults applied when no params supplied")] + [DataRow("InsertBook", "{\"title\": \"Integration Test Book\", \"publisher_id\": 2345}", DisplayName = "User-supplied params override defaults")] + [DataRow("GetBooks", null, DisplayName = "Zero-param SP succeeds")] + public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? parametersJson) + { + Dictionary? parameters = parametersJson != null + ? JsonSerializer.Deserialize>(parametersJson) + : null; + + CallToolResult result = await ExecuteEntityAsync(entityName, parameters); + + AssertSuccess(result, + $"execute_entity failed for entity '{entityName}' with params '{parametersJson}'."); + + // Parse response and verify structure + 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()); + } + + /// + /// Verify that GetBook with id=1 returns the actual book record from the database. + /// This ensures the parameter value is correctly passed to the stored procedure. + /// + [TestMethod] + public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() + { + Dictionary parameters = new() { { "id", 1 } }; + CallToolResult result = await ExecuteEntityAsync("GetBook", parameters); + + AssertSuccess(result, "GetBook with id=1 should succeed."); + + using JsonDocument doc = JsonDocument.Parse(GetFirstTextContent(result)); + JsonElement root = doc.RootElement; + + // Verify the value property contains the SP result with at least one record with id=1. + // SqlResponseHelpers.OkResponse wraps results in { value: [...] }, and + // BuildExecuteSuccessResponse serializes that as-is into the "value" field. + Assert.IsTrue(root.TryGetProperty("value", out JsonElement valueWrapper), "Response should contain 'value' property."); + + // The value may be the wrapper object { "value": [...] } or directly an array. + 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()); + } + + /// + /// Verify that InsertBook with no user params applies config defaults (title="randomX", publisher_id="1234"). + /// The SP inserts using those defaults. We verify the tool reports success (the SP executed without error). + /// + [TestMethod] + public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully() + { + CallToolResult result = await ExecuteEntityAsync("InsertBook", parameters: null); + + AssertSuccess(result, "InsertBook with config defaults should succeed."); + + using JsonDocument doc = JsonDocument.Parse(GetFirstTextContent(result)); + JsonElement root = doc.RootElement; + Assert.AreEqual("InsertBook", root.GetProperty("entity").GetString()); + } + + /// + /// Reject a parameter name that does not exist in the DB metadata. + /// Validation against StoredProcedureDefinition.Parameters should catch this. + /// + [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 ExecuteEntity_InvalidParamName_ReturnsError(string entityName, string paramName, string paramValue) + { + Dictionary parameters = new() { { paramName, paramValue } }; + CallToolResult result = await ExecuteEntityAsync(entityName, parameters); + + Assert.IsTrue(result.IsError == true, + $"execute_entity should reject parameter '{paramName}' not in DB metadata for '{entityName}'."); + string content = GetFirstTextContent(result); + StringAssert.Contains(content, paramName); + } + + private static async Task ExecuteEntityAsync(string entityName, Dictionary? parameters) + { + IServiceProvider serviceProvider = BuildExecuteEntityServiceProvider(); + ExecuteEntityTool tool = new(); + + var args = new Dictionary { { "entity", entityName } }; + if (parameters != null) + { + args["parameters"] = parameters; + } + + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + + return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + } + + /// + /// Builds a service provider that wires ExecuteEntityTool to the shared fixture's + /// real ISqlMetadataProvider, real IQueryEngine (SqlQueryEngine), and real + /// authorization resolver, with a DefaultHttpContext carrying the anonymous role header. + /// Uses the RuntimeConfigProvider from the WebApplicationFactory so that the datasource + /// name matches what the real MsSqlQueryExecutor was initialized with. + /// + private static IServiceProvider BuildExecuteEntityServiceProvider() + { + ServiceCollection services = new(); + + // Use the RuntimeConfigProvider from the WebApplicationFactory — this is the same + // provider that initialized _queryExecutor, so its DefaultDataSourceName matches + // the key in _queryExecutor.ConnectionStringBuilders. + RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); + services.AddSingleton(configProvider); + + // Real metadata-provider factory backed by the shared fixture's live provider. + services.AddSingleton(_metadataProviderFactory.Object); + + // Real authorization resolver wired by SqlTestBase against the live config + provider. + services.AddSingleton(_authorizationResolver); + + // Real HttpContext carrying the anonymous role header and a ClaimsPrincipal + // with the anonymous role claim so that AuthorizationResolver.IsValidRoleContext + // (which calls httpContext.User.IsInRole) returns true. + 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); + + // Build a real SqlQueryEngine using the shared fixtures. + Mock cache = new(); + DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); + + SqlQueryEngine queryEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + httpContextAccessor, + _authorizationResolver, + _gqlFilterParser, + new Mock>().Object, + configProvider, + cacheService); + + // Wrap in a mock IQueryEngineFactory that returns the real engine. + Mock queryEngineFactory = new(); + queryEngineFactory + .Setup(f => f.GetQueryEngine(It.IsAny())) + .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)}"); + } + } +} diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs new file mode 100644 index 0000000000..6d87da9eb4 --- /dev/null +++ b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs @@ -0,0 +1,449 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +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.Models; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Unit tests for ExecuteEntityTool parameter validation and default application. + /// Uses mocked metadata and query engine to isolate the tool's logic from real DB. + /// + /// Key behaviors tested: + /// - Parameters validated against StoredProcedureDefinition.Parameters (DB metadata). + /// - Config defaults (HasConfigDefault/ConfigDefaultValue) applied for missing params. + /// - Invalid parameter names rejected. + /// - Entity-level and runtime-level gating. + /// + [TestClass] + public class ExecuteEntityToolTests + { + private const string TEST_ENTITY = "GetBook"; + private const string SP_SOURCE_OBJECT = "get_book"; + + #region Parameter Validation Tests + + /// + /// A parameter that exists in DB metadata (StoredProcedureDefinition.Parameters) + /// is accepted even if it has no config-side entry. + /// + [TestMethod] + public async Task ExecuteEntity_AcceptsDbDiscoveredParam_NotInConfig() + { + Dictionary dbParams = new() + { + ["id"] = new() + }; + + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: new() { { "id", 1 } }); + + AssertSuccess(result, "Should accept DB-discovered param 'id'."); + } + + /// + /// A parameter name NOT in StoredProcedureDefinition.Parameters is rejected + /// with an InvalidArguments error. + /// + [DataTestMethod] + [DataRow("nonexistent", DisplayName = "Completely unknown param")] + [DataRow("ID", DisplayName = "Case-sensitive mismatch")] + public async Task ExecuteEntity_RejectsInvalidParamName(string invalidParamName) + { + Dictionary dbParams = new() + { + ["id"] = new() + }; + + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: new() { { invalidParamName, "value" } }); + + Assert.IsTrue(result.IsError == true, + $"Should reject param '{invalidParamName}' not in DB metadata."); + string content = GetFirstText(result); + StringAssert.Contains(content, invalidParamName); + StringAssert.Contains(content, "InvalidArguments"); + } + + /// + /// Multiple parameters can be provided when all exist in DB metadata. + /// + [TestMethod] + public async Task ExecuteEntity_AcceptsMultipleValidParams() + { + Dictionary dbParams = new() + { + ["title"] = new(), + ["publisher_id"] = new() + }; + + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: new() { { "title", "Test" }, { "publisher_id", 123 } }); + + AssertSuccess(result, "Should accept all valid params."); + } + + /// + /// If one param in a multi-param request is invalid, the entire request is rejected. + /// + [TestMethod] + public async Task ExecuteEntity_RejectsRequest_WhenAnyParamInvalid() + { + Dictionary dbParams = new() + { + ["id"] = new() + }; + + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: new() { { "id", 1 }, { "bogus", "x" } }); + + Assert.IsTrue(result.IsError == true, + "Should reject request when any param is invalid."); + StringAssert.Contains(GetFirstText(result), "bogus"); + } + + #endregion + + #region Default Application Tests + + /// + /// Config defaults are applied for parameters the user did not supply. + /// Verifies that the context passed to the query engine includes the default values. + /// + [TestMethod] + public async Task ExecuteEntity_AppliesConfigDefaults_ForMissingParams() + { + Dictionary dbParams = new() + { + ["title"] = new() { HasConfigDefault = true, ConfigDefaultValue = "defaultTitle" }, + ["publisher_id"] = new() { HasConfigDefault = true, ConfigDefaultValue = "999" } + }; + + StoredProcedureRequestContext? capturedContext = null; + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: null, + captureContext: ctx => capturedContext = ctx); + + AssertSuccess(result, "Should succeed with config defaults."); + Assert.IsNotNull(capturedContext, "Query engine should have been called."); + Assert.IsTrue(capturedContext.ResolvedParameters.ContainsKey("title")); + Assert.IsTrue(capturedContext.ResolvedParameters.ContainsKey("publisher_id")); + Assert.AreEqual("defaultTitle", capturedContext.ResolvedParameters["title"]); + Assert.AreEqual("999", capturedContext.ResolvedParameters["publisher_id"]); + } + + /// + /// User-supplied parameters override config defaults. + /// + [TestMethod] + public async Task ExecuteEntity_UserParams_OverrideConfigDefaults() + { + Dictionary dbParams = new() + { + ["title"] = new() { HasConfigDefault = true, ConfigDefaultValue = "defaultTitle" }, + ["publisher_id"] = new() { HasConfigDefault = true, ConfigDefaultValue = "999" } + }; + + StoredProcedureRequestContext? capturedContext = null; + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: new() { { "title", "UserTitle" } }, + captureContext: ctx => capturedContext = ctx); + + AssertSuccess(result, "Should succeed with user-supplied params."); + Assert.IsNotNull(capturedContext); + Assert.AreEqual("UserTitle", capturedContext.ResolvedParameters["title"]); + // publisher_id should get the config default since user didn't supply it + Assert.AreEqual("999", capturedContext.ResolvedParameters["publisher_id"]); + } + + /// + /// Parameters without config defaults are NOT injected into the request. + /// Only params with HasConfigDefault=true get applied. + /// + [TestMethod] + public async Task ExecuteEntity_DoesNotInjectParams_WithoutConfigDefault() + { + Dictionary dbParams = new() + { + ["id"] = new(), // No config default + ["tenant"] = new() { HasConfigDefault = true, ConfigDefaultValue = "default_tenant" } + }; + + StoredProcedureRequestContext? capturedContext = null; + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: new() { { "id", 42 } }, + captureContext: ctx => capturedContext = ctx); + + AssertSuccess(result, "Should succeed with partial params."); + Assert.IsNotNull(capturedContext); + Assert.IsTrue(capturedContext.ResolvedParameters.ContainsKey("id")); + Assert.IsTrue(capturedContext.ResolvedParameters.ContainsKey("tenant")); + Assert.AreEqual("default_tenant", capturedContext.ResolvedParameters["tenant"]); + } + + /// + /// Zero-parameter SP with no user params and no config defaults: no parameters + /// are passed to the query engine. + /// + [TestMethod] + public async Task ExecuteEntity_ZeroParamSP_PassesEmptyParams() + { + Dictionary dbParams = new(); + + StoredProcedureRequestContext? capturedContext = null; + CallToolResult result = await ExecuteWithMockedEngineAsync( + entityName: TEST_ENTITY, + dbParameters: dbParams, + userParameters: null, + captureContext: ctx => capturedContext = ctx); + + AssertSuccess(result, "Should succeed for zero-param SP."); + Assert.IsNotNull(capturedContext); + Assert.AreEqual(0, capturedContext.ResolvedParameters.Count); + } + + #endregion + + #region Gating Tests + + /// + /// When the entity is not a stored procedure, ExecuteEntityTool returns InvalidEntity. + /// + [TestMethod] + public async Task ExecuteEntity_RejectsNonStoredProcedureEntity() + { + IServiceProvider sp = BuildServiceProvider( + entityName: "Book", + sourceObject: "books", + sourceType: EntitySourceType.Table, + dbParameters: new()); + + ExecuteEntityTool tool = new(); + using JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\"}"); + CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None); + + Assert.IsTrue(result.IsError == true); + StringAssert.Contains(GetFirstText(result), "InvalidEntity"); + } + + /// + /// When the entity does not exist in config, returns EntityNotFound. + /// + [TestMethod] + public async Task ExecuteEntity_ReturnsError_WhenEntityNotFound() + { + IServiceProvider sp = BuildServiceProvider( + entityName: TEST_ENTITY, + sourceObject: SP_SOURCE_OBJECT, + sourceType: EntitySourceType.StoredProcedure, + dbParameters: new() { ["id"] = new() }); + + ExecuteEntityTool tool = new(); + using JsonDocument args = JsonDocument.Parse("{\"entity\": \"NonExistent\"}"); + CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None); + + Assert.IsTrue(result.IsError == true); + StringAssert.Contains(GetFirstText(result), "EntityNotFound"); + } + + #endregion + + #region Helpers + + /// + /// Runs ExecuteEntityTool with a mocked query engine that captures the + /// StoredProcedureRequestContext and returns an empty result. + /// + private static async Task ExecuteWithMockedEngineAsync( + string entityName, + Dictionary dbParameters, + Dictionary? userParameters, + Action? captureContext = null) + { + IServiceProvider sp = BuildServiceProvider( + entityName: entityName, + sourceObject: SP_SOURCE_OBJECT, + sourceType: EntitySourceType.StoredProcedure, + dbParameters: dbParameters, + captureContext: captureContext); + + ExecuteEntityTool tool = new(); + + var args = new Dictionary { { "entity", entityName } }; + if (userParameters != null) + { + args["parameters"] = userParameters; + } + + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + + return await tool.ExecuteAsync(arguments, sp, CancellationToken.None); + } + + /// + /// Builds a fully mocked service provider for ExecuteEntityTool. + /// + private static IServiceProvider BuildServiceProvider( + string entityName, + string sourceObject, + EntitySourceType sourceType, + Dictionary dbParameters, + Action? captureContext = null) + { + Entity entity = new( + Source: new(sourceObject, sourceType, Parameters: null, KeyFields: null), + GraphQL: new(entityName, entityName), + 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); + + Dictionary entities = new() { [entityName] = entity }; + + RuntimeConfig config = new( + 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)); + + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + services.AddSingleton(configProvider); + + // Mock authorization resolver + Mock mockAuthResolver = new(); + mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true); + mockAuthResolver + .Setup(x => x.AreRoleAndOperationDefinedForEntity( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(true); + services.AddSingleton(mockAuthResolver.Object); + + // Mock HttpContext with anonymous role header + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = "anonymous"; + IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + services.AddSingleton(httpContextAccessor); + + // Mock metadata provider with DB object + DatabaseObject dbObject = sourceType == EntitySourceType.StoredProcedure + ? new DatabaseStoredProcedure("dbo", sourceObject) + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new StoredProcedureDefinition + { + Parameters = dbParameters + } + } + : new DatabaseTable("dbo", sourceObject) { SourceType = EntitySourceType.Table }; + + 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); + + // Mock query engine factory + Mock mockQueryEngine = new(); + mockQueryEngine + .Setup(x => x.ExecuteAsync(It.IsAny(), It.IsAny())) + .Returns((StoredProcedureRequestContext ctx, string ds) => + { + captureContext?.Invoke(ctx); + // Return empty JSON array result + using JsonDocument doc = JsonDocument.Parse("[]"); + return Task.FromResult(new OkObjectResult(doc.RootElement.Clone())); + }); + + Mock mockQueryEngineFactory = new(); + mockQueryEngineFactory + .Setup(x => x.GetQueryEngine(It.IsAny())) + .Returns(mockQueryEngine.Object); + services.AddSingleton(mockQueryEngineFactory.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + private static void AssertSuccess(CallToolResult result, string message) + { + Assert.IsTrue(result.IsError != true, + $"{message} Content: {GetFirstText(result)}"); + } + + private static string GetFirstText(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; + } + + #endregion + } +}