diff --git a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs
new file mode 100644
index 0000000000..71ec37e0a3
--- /dev/null
+++ b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs
@@ -0,0 +1,315 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Mcp.BuiltInTools;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Integration tests for AggregateRecordsTool against a real MsSql database.
+ /// The books table has: id (int PK), title (varchar), publisher_id (int).
+ /// Seed data: 21 books with publisher_ids: 1234, 2345, 2323, 2324, 1940, 1941.
+ ///
+ [TestClass, TestCategory(TestCategory.MSSQL)]
+ public class AggregateRecordsToolMsSqlIntegrationTests : McpToolTestBase
+ {
+ [ClassInitialize]
+ public static async Task SetupAsync(TestContext context)
+ {
+ DatabaseEngine = TestCategory.MSSQL;
+ await InitializeTestFixture();
+ }
+
+ #region COUNT Tests
+
+ ///
+ /// Counts all records in the Book entity using COUNT(*).
+ ///
+ [TestMethod]
+ public async Task Aggregate_CountAll_ReturnsCorrectCount()
+ {
+ CallToolResult result = await ExecuteAggregateAsync("Book", "count", "*");
+
+ AssertSuccess(result, "COUNT(*) should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ Assert.AreEqual("Book", root.GetProperty("entity").GetString());
+ JsonElement resultArray = root.GetProperty("result");
+ Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind, "Simple aggregate result should be an array.");
+ int count = resultArray[0].GetProperty("count").GetInt32();
+ Assert.IsTrue(count >= 21,
+ $"Expected at least 21 books (seed data), got {count}.");
+ }
+
+ ///
+ /// Counts records with an OData filter (publisher_id eq 1234).
+ ///
+ [TestMethod]
+ public async Task Aggregate_CountWithFilter_ReturnsFilteredCount()
+ {
+ CallToolResult result = await ExecuteAggregateAsync(
+ "Book", "count", "*", filter: "publisher_id eq 1234");
+
+ AssertSuccess(result, "COUNT with filter should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ JsonElement resultArray = root.GetProperty("result");
+ int count = resultArray[0].GetProperty("count").GetInt32();
+ Assert.IsTrue(count >= 10,
+ $"Expected at least 10 books with publisher_id=1234, got {count}.");
+ }
+
+ ///
+ /// Counts distinct publisher_id values.
+ ///
+ [TestMethod]
+ public async Task Aggregate_CountDistinct_ReturnsDistinctCount()
+ {
+ CallToolResult result = await ExecuteAggregateAsync(
+ "Book", "count", "publisher_id", distinct: true);
+
+ AssertSuccess(result, "COUNT DISTINCT should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ JsonElement resultArray = root.GetProperty("result");
+ Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind, "Result should be an array.");
+ Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty.");
+
+ JsonElement firstRow = resultArray[0];
+ int count = 0;
+ if (firstRow.TryGetProperty("count", out JsonElement countElement))
+ {
+ count = countElement.GetInt32();
+ }
+ else if (firstRow.TryGetProperty("count_publisher_id", out JsonElement aliasElement))
+ {
+ count = aliasElement.GetInt32();
+ }
+
+ Assert.IsTrue(count >= 6, $"Expected at least 6 distinct publisher_ids, got {count}.");
+ }
+
+ #endregion
+
+ #region SUM/AVG/MIN/MAX Tests
+
+ ///
+ /// Validates that numeric aggregation functions (sum, avg, min, max) succeed on publisher_id.
+ ///
+ [DataTestMethod]
+ [DataRow("sum", "sum_publisher_id", DisplayName = "SUM of publisher_id")]
+ [DataRow("avg", "avg_publisher_id", DisplayName = "AVG of publisher_id")]
+ [DataRow("min", "min_publisher_id", DisplayName = "MIN of publisher_id")]
+ [DataRow("max", "max_publisher_id", DisplayName = "MAX of publisher_id")]
+ public async Task Aggregate_NumericFunction_ReturnsResult(string function, string expectedAlias)
+ {
+ CallToolResult result = await ExecuteAggregateAsync("Book", function, "publisher_id");
+
+ AssertSuccess(result, $"{function.ToUpper()} should succeed on numeric field.");
+
+ string content = GetFirstTextContent(result);
+ Assert.IsFalse(string.IsNullOrWhiteSpace(content), $"Expected non-empty result for {function.ToUpper()}.");
+ }
+
+ ///
+ /// Validates that MIN returns the expected minimum value.
+ ///
+ [TestMethod]
+ public async Task Aggregate_Min_ReturnsExpectedMinValue()
+ {
+ CallToolResult result = await ExecuteAggregateAsync("Book", "min", "publisher_id");
+ AssertSuccess(result, "MIN should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ JsonElement resultArray = root.GetProperty("result");
+ Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind);
+ Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty.");
+ Assert.AreEqual(1234, resultArray[0].GetProperty("min_publisher_id").GetInt32(),
+ "MIN publisher_id should be 1234 (from seed data).");
+ }
+
+ ///
+ /// Validates that MAX returns the expected maximum value.
+ ///
+ [TestMethod]
+ public async Task Aggregate_Max_ReturnsExpectedMaxValue()
+ {
+ CallToolResult result = await ExecuteAggregateAsync("Book", "max", "publisher_id");
+ AssertSuccess(result, "MAX should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ JsonElement resultArray = root.GetProperty("result");
+ Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind);
+ Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty.");
+ Assert.AreEqual(2345, resultArray[0].GetProperty("max_publisher_id").GetInt32(),
+ "MAX publisher_id should be 2345 (from seed data).");
+ }
+
+ #endregion
+
+ #region GROUP BY Tests
+
+ ///
+ /// Groups by publisher_id and counts records per group.
+ ///
+ [TestMethod]
+ public async Task Aggregate_GroupByWithCount_ReturnsGroupedResults()
+ {
+ CallToolResult result = await ExecuteAggregateAsync(
+ "Book", "count", "*", groupby: new[] { "publisher_id" });
+
+ AssertSuccess(result, "COUNT with GROUP BY should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ JsonElement resultElement = root.GetProperty("result");
+
+ // Non-paginated GROUP BY returns result as an array
+ if (resultElement.ValueKind == JsonValueKind.Array)
+ {
+ Assert.IsTrue(resultElement.GetArrayLength() > 1, "Expected multiple groups.");
+ }
+ else if (resultElement.ValueKind == JsonValueKind.Object &&
+ resultElement.TryGetProperty("items", out JsonElement itemsElement))
+ {
+ Assert.IsTrue(itemsElement.GetArrayLength() > 1, "Expected multiple groups.");
+ }
+ else
+ {
+ Assert.Fail("Unexpected result shape for GROUP BY response.");
+ }
+ }
+
+ ///
+ /// Groups by publisher_id with first parameter for pagination.
+ ///
+ [TestMethod]
+ public async Task Aggregate_GroupByWithFirst_ReturnsPaginatedResults()
+ {
+ CallToolResult result = await ExecuteAggregateAsync(
+ "Book", "count", "*", groupby: new[] { "publisher_id" }, first: 2);
+
+ AssertSuccess(result, "COUNT with GROUP BY and first should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+ JsonElement resultElement = root.GetProperty("result");
+
+ // Paginated GROUP BY returns result as { items: [...], endCursor, hasNextPage }
+ Assert.AreEqual(JsonValueKind.Object, resultElement.ValueKind,
+ "Paginated GROUP BY result should be an object.");
+ Assert.IsTrue(resultElement.TryGetProperty("items", out JsonElement itemsElement),
+ "Paginated response should contain 'items' property.");
+ Assert.IsTrue(itemsElement.GetArrayLength() <= 2, "Expected at most 2 items when first=2.");
+ Assert.IsTrue(resultElement.TryGetProperty("hasNextPage", out _),
+ "Paginated response should include hasNextPage.");
+ }
+
+ #endregion
+
+ #region Error Cases
+
+ ///
+ /// Validates error scenarios for AggregateRecordsTool.
+ ///
+ [DataTestMethod]
+ [DataRow("NonExistentEntity", "count", "*", "NonExistentEntity", DisplayName = "Invalid entity")]
+ [DataRow("Book", "sum", "nonexistent_field", null, DisplayName = "Invalid field")]
+ public async Task Aggregate_ErrorScenarios(string entity, string function, string field, string? expectedSubstring)
+ {
+ CallToolResult result = await ExecuteAggregateAsync(entity, function, field);
+
+ Assert.IsTrue(result.IsError == true, $"Aggregate({entity}, {function}, {field}) should fail.");
+ if (expectedSubstring != null)
+ {
+ StringAssert.Contains(GetFirstTextContent(result), expectedSubstring);
+ }
+ }
+
+ ///
+ /// Attempts aggregation with no arguments.
+ ///
+ [TestMethod]
+ public async Task Aggregate_NoArguments_ReturnsError()
+ {
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
+ AggregateRecordsTool tool = new();
+
+ CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None);
+
+ AssertError(result);
+ }
+
+ ///
+ /// Attempts aggregation with an invalid function name.
+ ///
+ [TestMethod]
+ public async Task Aggregate_InvalidFunction_ReturnsError()
+ {
+ CallToolResult result = await ExecuteAggregateAsync("Book", "invalid_func", "*");
+
+ AssertError(result);
+ }
+
+ #endregion
+
+ private static async Task ExecuteAggregateAsync(
+ string entity,
+ string function,
+ string field,
+ string? filter = null,
+ bool distinct = false,
+ string[]? groupby = null,
+ string? orderby = null,
+ int? first = null,
+ string? after = null)
+ {
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
+ AggregateRecordsTool tool = new();
+
+ var args = new Dictionary
+ {
+ { "entity", entity },
+ { "function", function },
+ { "field", field }
+ };
+
+ if (filter != null)
+ {
+ args["filter"] = filter;
+ }
+
+ if (distinct)
+ {
+ args["distinct"] = true;
+ }
+
+ if (groupby != null)
+ {
+ args["groupby"] = groupby;
+ }
+
+ if (orderby != null)
+ {
+ args["orderby"] = orderby;
+ }
+
+ if (first != null)
+ {
+ args["first"] = first;
+ }
+
+ if (after != null)
+ {
+ args["after"] = after;
+ }
+
+ return await ExecuteToolAsync(tool, serviceProvider, args);
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs
new file mode 100644
index 0000000000..d6f2ed7d8c
--- /dev/null
+++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Mcp.BuiltInTools;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Integration tests for CreateRecordTool against a real MsSql database.
+ ///
+ [TestClass, TestCategory(TestCategory.MSSQL)]
+ public class CreateRecordToolMsSqlIntegrationTests : McpToolTestBase
+ {
+ [ClassInitialize]
+ public static async Task SetupAsync(TestContext context)
+ {
+ DatabaseEngine = TestCategory.MSSQL;
+ await InitializeTestFixture();
+ }
+
+ ///
+ /// Creates a new book record with valid data and verifies success.
+ ///
+ [TestMethod]
+ public async Task CreateRecord_ValidData_ReturnsSuccess()
+ {
+ var data = new Dictionary
+ {
+ { "title", "Integration Test Book" },
+ { "publisher_id", 1234 }
+ };
+
+ CallToolResult result = await ExecuteCreateAsync("Book", data);
+
+ AssertSuccess(result, "CreateRecord should succeed with valid data.");
+
+ JsonElement root = ParseResultRoot(result);
+ Assert.AreEqual("Book", root.GetProperty("entity").GetString());
+ Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully created"),
+ "Response message should indicate success.");
+ }
+
+ ///
+ /// Creates a record and verifies the returned record contains the inserted data.
+ ///
+ [TestMethod]
+ public async Task CreateRecord_ReturnsCreatedData()
+ {
+ var data = new Dictionary
+ {
+ { "title", "Verify Created Data" },
+ { "publisher_id", 2345 }
+ };
+
+ CallToolResult result = await ExecuteCreateAsync("Book", data);
+
+ AssertSuccess(result, "CreateRecord should succeed.");
+
+ JsonElement root = ParseResultRoot(result);
+
+ Assert.IsTrue(root.TryGetProperty("result", out JsonElement resultElement),
+ "Response should contain 'result' property.");
+
+ if (resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueArray))
+ {
+ Assert.AreEqual(JsonValueKind.Array, valueArray.ValueKind);
+ Assert.IsTrue(valueArray.GetArrayLength() > 0);
+ JsonElement created = valueArray[0];
+ Assert.AreEqual("Verify Created Data", created.GetProperty("title").GetString());
+ Assert.AreEqual(2345, created.GetProperty("publisher_id").GetInt32());
+ Assert.IsTrue(created.TryGetProperty("id", out _), "Created record should have an auto-generated id.");
+ }
+ }
+
+ ///
+ /// Validates error scenarios for CreateRecordTool.
+ ///
+ [DataTestMethod]
+ [DataRow("NonExistentEntity", "NonExistentEntity", DisplayName = "Invalid entity")]
+ public async Task CreateRecord_InvalidEntity_ReturnsError(string entity, string expectedSubstring)
+ {
+ var data = new Dictionary { { "name", "Test" } };
+
+ CallToolResult result = await ExecuteCreateAsync(entity, data);
+
+ AssertError(result, expectedSubstring);
+ }
+
+ ///
+ /// Attempts to create a record without providing any arguments.
+ ///
+ [TestMethod]
+ public async Task CreateRecord_NoArguments_ReturnsError()
+ {
+ IServiceProvider serviceProvider = BuildMutationServiceProvider();
+ CreateRecordTool tool = new();
+
+ CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None);
+
+ AssertError(result);
+ }
+
+ ///
+ /// Attempts to create a record with missing required NOT NULL field (title).
+ ///
+ [TestMethod]
+ public async Task CreateRecord_MissingRequiredField_ReturnsError()
+ {
+ var data = new Dictionary
+ {
+ { "publisher_id", 1234 }
+ };
+
+ CallToolResult result = await ExecuteCreateAsync("Book", data);
+
+ AssertError(result, message: "CreateRecord should fail when missing required NOT NULL fields.");
+ }
+
+ private static async Task ExecuteCreateAsync(string entity, Dictionary data)
+ {
+ IServiceProvider serviceProvider = BuildMutationServiceProvider();
+ CreateRecordTool tool = new();
+
+ var args = new Dictionary
+ {
+ { "entity", entity },
+ { "data", data }
+ };
+
+ return await ExecuteToolAsync(tool, serviceProvider, args);
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs
new file mode 100644
index 0000000000..73ce685a68
--- /dev/null
+++ b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Mcp.BuiltInTools;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Integration tests for DeleteRecordTool against a real MsSql database.
+ /// Tests that delete records first create a record to avoid interfering with seed data.
+ ///
+ [TestClass, TestCategory(TestCategory.MSSQL)]
+ public class DeleteRecordToolMsSqlIntegrationTests : McpToolTestBase
+ {
+ [ClassInitialize]
+ public static async Task SetupAsync(TestContext context)
+ {
+ DatabaseEngine = TestCategory.MSSQL;
+ await InitializeTestFixture();
+ }
+
+ ///
+ /// Creates a book then deletes it, verifying the delete succeeds.
+ ///
+ [TestMethod]
+ public async Task DeleteRecord_ExistingRecord_ReturnsSuccess()
+ {
+ int createdId = await CreateBookForDeletion("Delete Me Book");
+
+ var keys = new Dictionary { { "id", createdId } };
+ CallToolResult result = await ExecuteDeleteAsync("Book", keys);
+
+ AssertSuccess(result, "DeleteRecord should succeed for existing record.");
+ Assert.IsFalse(string.IsNullOrWhiteSpace(GetFirstTextContent(result)));
+ }
+
+ ///
+ /// Attempts to delete a record with a non-existent key, expecting an error.
+ ///
+ [TestMethod]
+ public async Task DeleteRecord_NonExistentKey_ReturnsError()
+ {
+ var keys = new Dictionary { { "id", 99999 } };
+
+ CallToolResult result = await ExecuteDeleteAsync("Book", keys);
+
+ AssertError(result);
+ }
+
+ ///
+ /// Attempts to delete from a non-existent entity, expecting an error.
+ ///
+ [TestMethod]
+ public async Task DeleteRecord_InvalidEntity_ReturnsError()
+ {
+ var keys = new Dictionary { { "id", 1 } };
+
+ CallToolResult result = await ExecuteDeleteAsync("NonExistentEntity", keys);
+
+ AssertError(result, "NonExistentEntity");
+ }
+
+ ///
+ /// Attempts to delete with no arguments, expecting an error.
+ ///
+ [TestMethod]
+ public async Task DeleteRecord_NoArguments_ReturnsError()
+ {
+ IServiceProvider serviceProvider = BuildMutationServiceProvider();
+ DeleteRecordTool tool = new();
+
+ CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None);
+
+ AssertError(result);
+ }
+
+ ///
+ /// Attempts to delete with null key value, expecting an error.
+ ///
+ [TestMethod]
+ public async Task DeleteRecord_NullKeyValue_ReturnsError()
+ {
+ var args = new Dictionary
+ {
+ { "entity", "Book" },
+ { "keys", new Dictionary { { "id", null } } }
+ };
+
+ IServiceProvider serviceProvider = BuildMutationServiceProvider();
+ DeleteRecordTool tool = new();
+
+ string argsJson = JsonSerializer.Serialize(args);
+ using JsonDocument arguments = JsonDocument.Parse(argsJson);
+
+ CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None);
+
+ AssertError(result);
+ }
+
+ ///
+ /// Verifies that after successful deletion, the record is no longer accessible.
+ ///
+ [TestMethod]
+ public async Task DeleteRecord_ThenRead_RecordNotFound()
+ {
+ int createdId = await CreateBookForDeletion("Delete Then Verify Book");
+
+ // Delete the book
+ var keys = new Dictionary { { "id", createdId } };
+ CallToolResult deleteResult = await ExecuteDeleteAsync("Book", keys);
+ AssertSuccess(deleteResult, "Delete should succeed.");
+
+ // Verify it's gone via read
+ IServiceProvider readProvider = BuildQueryServiceProvider();
+ ReadRecordsTool readTool = new();
+
+ var readArgs = new Dictionary { { "entity", "Book" }, { "filter", $"id eq {createdId}" } };
+ CallToolResult readResult = await ExecuteToolAsync(readTool, readProvider, readArgs);
+
+ Assert.IsTrue(readResult.IsError != true, "Follow-up read after delete should succeed.");
+
+ JsonElement root = ParseResultRoot(readResult);
+ JsonElement records = root.GetProperty("result").GetProperty("value");
+ Assert.AreEqual(0, records.GetArrayLength(),
+ "Deleted record should not be found in subsequent read.");
+ }
+
+ ///
+ /// Creates a book record using the CreateRecordTool and returns its ID.
+ ///
+ private static async Task CreateBookForDeletion(string title)
+ {
+ IServiceProvider serviceProvider = BuildMutationServiceProvider();
+ CreateRecordTool createTool = new();
+
+ var args = new Dictionary
+ {
+ { "entity", "Book" },
+ { "data", new Dictionary { { "title", title }, { "publisher_id", 1234 } } }
+ };
+
+ CallToolResult createResult = await ExecuteToolAsync(createTool, serviceProvider, args);
+ Assert.IsTrue(createResult.IsError != true, $"Setup: Failed to create book for deletion test. {GetFirstTextContent(createResult)}");
+
+ JsonElement root = ParseResultRoot(createResult);
+ if (root.TryGetProperty("result", out JsonElement resultElement) &&
+ resultElement.ValueKind == JsonValueKind.Object &&
+ resultElement.TryGetProperty("value", out JsonElement valueArray) &&
+ valueArray.ValueKind == JsonValueKind.Array &&
+ valueArray.GetArrayLength() > 0)
+ {
+ return valueArray[0].GetProperty("id").GetInt32();
+ }
+
+ Assert.Fail("Could not extract ID from created book record.");
+ return -1;
+ }
+
+ private static async Task ExecuteDeleteAsync(string entity, Dictionary keys)
+ {
+ IServiceProvider serviceProvider = BuildMutationServiceProvider();
+ DeleteRecordTool tool = new();
+
+ var args = new Dictionary
+ {
+ { "entity", entity },
+ { "keys", keys }
+ };
+
+ return await ExecuteToolAsync(tool, serviceProvider, args);
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs
index 5744fb7c3a..9ebadeacdd 100644
--- a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs
+++ b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs
@@ -1,29 +1,17 @@
// 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
{
@@ -40,7 +28,7 @@ namespace Azure.DataApiBuilder.Service.Tests.Mcp
/// - GetBooks -> SP get_books, zero params
///
[TestClass, TestCategory(TestCategory.MSSQL)]
- public class DynamicCustomToolMsSqlIntegrationTests : SqlTestBase
+ public class DynamicCustomToolMsSqlIntegrationTests : McpToolTestBase
{
[ClassInitialize]
public static async Task SetupAsync(TestContext context)
@@ -79,8 +67,6 @@ public async Task DynamicCustomTool_SuccessfulExecution(string entityName, strin
///
/// 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).
///
[TestMethod]
public async Task DynamicCustomTool_GetBookById_ReturnsMatchingRecord()
@@ -115,10 +101,8 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN
Dictionary parameters = new() { { paramName, paramValue } };
CallToolResult result = await ExecuteCustomToolAsync(entityName, parameters);
- Assert.IsTrue(result.IsError == true,
+ AssertError(result, paramName,
$"Custom tool should reject parameter '{paramName}' not in DB metadata for '{entityName}'.");
- string content = GetFirstTextContent(result);
- StringAssert.Contains(content, paramName);
}
#region Schema Alignment Integration Tests
@@ -132,7 +116,7 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN
[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();
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService();
Entity entity = configProvider.GetConfig().Entities[entityName];
@@ -153,7 +137,7 @@ public void InitializeMetadata_SchemaReflectsDbParameterTypes(string entityName,
[TestMethod]
public void InitializeMetadata_ZeroParamSP_HasEmptyProperties()
{
- IServiceProvider serviceProvider = BuildServiceProvider();
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService();
Entity entity = configProvider.GetConfig().Entities["GetBooks"];
@@ -179,7 +163,7 @@ public void InitializeMetadata_ZeroParamSP_HasEmptyProperties()
[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();
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService();
Entity entity = configProvider.GetConfig().Entities[entityName];
@@ -200,9 +184,8 @@ public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityNa
///
private static async Task ExecuteCustomToolAsync(string entityName, Dictionary? parameters)
{
- IServiceProvider serviceProvider = BuildServiceProvider();
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
- // Resolve the entity config from the runtime config
RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService();
RuntimeConfig config = configProvider.GetConfig();
Entity entity = config.Entities[entityName];
@@ -217,72 +200,5 @@ private static async Task ExecuteCustomToolAsync(string entityNa
return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None);
}
-
- ///
- /// Builds a service provider wired to the shared fixture's real providers.
- /// Uses the same pattern as ExecuteEntityToolMsSqlIntegrationTests.
- ///
- private static IServiceProvider BuildServiceProvider()
- {
- ServiceCollection services = new();
-
- RuntimeConfigProvider configProvider = _application.Services.GetRequiredService();
- 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 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);
-
- 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/DynamicCustomToolTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs
index 6509c64b0a..bb67aec5a0 100644
--- a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs
+++ b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-#nullable enable
-
using System;
using System.Collections.Generic;
using System.Linq;
diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs
index 624990dc38..f4089d8e19 100644
--- a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs
+++ b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs
@@ -1,47 +1,29 @@
// 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.
+ /// Verifies:
+ /// - Parameters validated against StoredProcedureDefinition.Parameters (DB metadata).
+ /// - Config defaults applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue.
///
- /// Scenarios (reuse SPs already defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json):
+ /// Uses SPs 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
+ public class ExecuteEntityToolMsSqlIntegrationTests : McpToolTestBase
{
[ClassInitialize]
public static async Task SetupAsync(TestContext context)
@@ -52,11 +34,6 @@ public static async Task SetupAsync(TestContext context)
///
/// 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)")]
@@ -74,7 +51,6 @@ public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? p
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}'.");
@@ -86,7 +62,6 @@ public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? p
///
/// 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()
@@ -99,12 +74,8 @@ public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord()
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;
@@ -115,8 +86,7 @@ public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord()
}
///
- /// 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).
+ /// Verify that InsertBook with no user params applies config defaults.
///
[TestMethod]
public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully()
@@ -132,7 +102,6 @@ public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully()
///
/// 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")]
@@ -142,108 +111,22 @@ public async Task ExecuteEntity_InvalidParamName_ReturnsError(string entityName,
Dictionary parameters = new() { { paramName, paramValue } };
CallToolResult result = await ExecuteEntityAsync(entityName, parameters);
- Assert.IsTrue(result.IsError == true,
+ AssertError(result, paramName,
$"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();
+ IServiceProvider serviceProvider = BuildQueryServiceProvider();
ExecuteEntityTool tool = new();
- var args = new Dictionary { { "entity", entityName } };
+ 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)}");
+ return await ExecuteToolAsync(tool, serviceProvider, args);
}
}
}
diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs
index 2410c1ab8b..4cc7b352fd 100644
--- a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs
+++ b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-#nullable enable
-
using System;
using System.Collections.Generic;
using System.Text.Json;
diff --git a/src/Service.Tests/Mcp/McpToolRegistryTests.cs b/src/Service.Tests/Mcp/McpToolRegistryTests.cs
index 14f966d81b..d8c5dc0b59 100644
--- a/src/Service.Tests/Mcp/McpToolRegistryTests.cs
+++ b/src/Service.Tests/Mcp/McpToolRegistryTests.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-#nullable enable
-
using System;
using System.Collections.Generic;
using System.Linq;
diff --git a/src/Service.Tests/Mcp/McpToolTestBase.cs b/src/Service.Tests/Mcp/McpToolTestBase.cs
new file mode 100644
index 0000000000..963d3ac165
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpToolTestBase.cs
@@ -0,0 +1,225 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+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;
+using Azure.DataApiBuilder.Core.Services.Cache;
+using Azure.DataApiBuilder.Mcp.Model;
+using Azure.DataApiBuilder.Service.Tests.SqlTests;
+using Microsoft.AspNetCore.Authorization;
+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
+{
+ ///
+ /// Shared base class for MCP tool integration tests.
+ /// Provides common service provider builders and assertion helpers to avoid duplication.
+ ///
+ public abstract class McpToolTestBase : SqlTestBase
+ {
+ #region Service Provider Builders
+
+ ///
+ /// Builds a service provider for read-only tools (ReadRecordsTool, AggregateRecordsTool).
+ /// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver,
+ /// IAuthorizationService, IHttpContextAccessor, IQueryEngineFactory, GQLFilterParser,
+ /// IAbstractQueryManagerFactory.
+ ///
+ protected static IServiceProvider BuildQueryServiceProvider()
+ {
+ ServiceCollection services = new();
+
+ RuntimeConfigProvider configProvider = _application.Services.GetRequiredService();
+ services.AddSingleton(configProvider);
+
+ services.AddSingleton(_metadataProviderFactory.Object);
+ services.AddSingleton(_authorizationResolver);
+ services.AddSingleton(_gqlFilterParser);
+ services.AddSingleton(_queryManagerFactory.Object);
+
+ IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor();
+ services.AddSingleton(httpContextAccessor);
+
+ Mock authorizationService = new();
+ authorizationService
+ .Setup(a => a.AuthorizeAsync(It.IsAny(), It.IsAny