diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json
index cbe38b7d72..fa5208af66 100644
--- a/schemas/dab.draft.schema.json
+++ b/schemas/dab.draft.schema.json
@@ -315,6 +315,34 @@
"type": "boolean",
"description": "Enable/disable the execute-entity tool.",
"default": false
+ },
+ "aggregate-records": {
+ "oneOf": [
+ {
+ "type": "boolean",
+ "description": "Enable/disable the aggregate-records tool."
+ },
+ {
+ "type": "object",
+ "description": "Aggregate records tool configuration",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Enable/disable the aggregate-records tool.",
+ "default": true
+ },
+ "query-timeout": {
+ "type": "integer",
+ "description": "Execution timeout in seconds for aggregate queries. Range: 1-600.",
+ "default": 30,
+ "minimum": 1,
+ "maximum": 600
+ }
+ }
+ }
+ ],
+ "default": false
}
}
}
diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/AggregateRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/AggregateRecordsTool.cs
new file mode 100644
index 0000000000..00875d2596
--- /dev/null
+++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/AggregateRecordsTool.cs
@@ -0,0 +1,1087 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Data.Common;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+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.Parsers;
+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.Model;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Protocol;
+using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.Sql.SchemaConverter;
+
+namespace Azure.DataApiBuilder.Mcp.BuiltInTools
+{
+ ///
+ /// Tool to aggregate records from a table/view entity configured in DAB.
+ /// Supports count, avg, sum, min, max with optional distinct, filter, groupby, having, orderby.
+ ///
+ public class AggregateRecordsTool : IMcpTool
+ {
+ public ToolType ToolType { get; } = ToolType.BuiltIn;
+
+ private static readonly HashSet _validFunctions = new(StringComparer.OrdinalIgnoreCase) { "count", "avg", "sum", "min", "max" };
+ private static readonly HashSet _validHavingOperators = new(StringComparer.OrdinalIgnoreCase) { "eq", "neq", "gt", "gte", "lt", "lte", "in" };
+
+ private static readonly Tool _cachedToolMetadata = new()
+ {
+ Name = "aggregate_records",
+ Description = "Computes aggregations (count, avg, sum, min, max) on entity data. "
+ + "WORKFLOW: 1) Call describe_entities first to get entity names and field names. "
+ + "2) Call this tool with entity, function, and field from step 1. "
+ + "RULES: field '*' is ONLY valid with count. "
+ + "orderby, having, first, and after ONLY apply when groupby is provided. "
+ + "RESPONSE: Result is aliased as '{function}_{field}' (e.g. avg_unitPrice). "
+ + "For count(*), the alias is 'count'. "
+ + "With groupby and first, response includes items, endCursor, and hasNextPage for pagination.",
+ InputSchema = JsonSerializer.Deserialize(
+ @"{
+ ""type"": ""object"",
+ ""properties"": {
+ ""entity"": {
+ ""type"": ""string"",
+ ""description"": ""Entity name from describe_entities with READ permission (case-sensitive).""
+ },
+ ""function"": {
+ ""type"": ""string"",
+ ""enum"": [""count"", ""avg"", ""sum"", ""min"", ""max""],
+ ""description"": ""Aggregation function. count supports field '*'; avg, sum, min, max require a numeric field.""
+ },
+ ""field"": {
+ ""type"": ""string"",
+ ""description"": ""Field name to aggregate, or '*' with count to count all rows.""
+ },
+ ""distinct"": {
+ ""type"": ""boolean"",
+ ""description"": ""Remove duplicate values before aggregating. Not valid with field '*'."",
+ ""default"": false
+ },
+ ""filter"": {
+ ""type"": ""string"",
+ ""description"": ""OData WHERE clause applied before aggregating. Operators: eq, ne, gt, ge, lt, le, and, or, not. Example: 'unitPrice lt 10'."",
+ ""default"": """"
+ },
+ ""groupby"": {
+ ""type"": ""array"",
+ ""items"": { ""type"": ""string"" },
+ ""description"": ""Field names to group by. Each unique combination produces one aggregated row. Enables orderby, having, first, and after."",
+ ""default"": []
+ },
+ ""orderby"": {
+ ""type"": ""string"",
+ ""enum"": [""asc"", ""desc""],
+ ""description"": ""Sort grouped results by the aggregated value. Requires groupby."",
+ ""default"": ""desc""
+ },
+ ""having"": {
+ ""type"": ""object"",
+ ""description"": ""Filter groups by the aggregated value (HAVING clause). Requires groupby. Multiple operators are AND-ed."",
+ ""properties"": {
+ ""eq"": { ""type"": ""number"", ""description"": ""Equals."" },
+ ""neq"": { ""type"": ""number"", ""description"": ""Not equals."" },
+ ""gt"": { ""type"": ""number"", ""description"": ""Greater than."" },
+ ""gte"": { ""type"": ""number"", ""description"": ""Greater than or equal."" },
+ ""lt"": { ""type"": ""number"", ""description"": ""Less than."" },
+ ""lte"": { ""type"": ""number"", ""description"": ""Less than or equal."" },
+ ""in"": {
+ ""type"": ""array"",
+ ""items"": { ""type"": ""number"" },
+ ""description"": ""Matches any value in the list.""
+ }
+ }
+ },
+ ""first"": {
+ ""type"": ""integer"",
+ ""description"": ""Max grouped results to return. Requires groupby. Enables paginated response with endCursor and hasNextPage."",
+ ""minimum"": 1
+ },
+ ""after"": {
+ ""type"": ""string"",
+ ""description"": ""Opaque cursor from a previous endCursor for next-page retrieval. Requires groupby and first. Do not construct manually.""
+ }
+ },
+ ""required"": [""entity"", ""function"", ""field""]
+ }"
+ )
+ };
+
+ ///
+ /// Holds all validated arguments parsed from the tool invocation.
+ ///
+ internal sealed record AggregateArguments(
+ string EntityName,
+ string Function,
+ string Field,
+ bool IsCountStar,
+ bool Distinct,
+ string? Filter,
+ bool UserProvidedOrderby,
+ string Orderby,
+ int? First,
+ string? After,
+ List Groupby,
+ Dictionary? HavingOperators,
+ List? HavingInValues);
+
+ ///
+ /// Holds the result of a successful authorization and context-building step.
+ ///
+ private sealed record AuthorizedContext(
+ FindRequestContext RequestContext,
+ HttpContext HttpContext);
+
+ public Tool GetToolMetadata()
+ {
+ return _cachedToolMetadata;
+ }
+
+ public async Task ExecuteAsync(
+ JsonDocument? arguments,
+ IServiceProvider serviceProvider,
+ CancellationToken cancellationToken = default)
+ {
+ ILogger? logger = serviceProvider.GetService>();
+ string toolName = GetToolMetadata().Name;
+ string entityName = string.Empty;
+
+ RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService();
+ RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig();
+
+ if (runtimeConfig.McpDmlTools?.AggregateRecords is not true)
+ {
+ return McpErrorHelpers.ToolDisabled(toolName, logger);
+ }
+
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Read aggregate-records query-timeout from current config per invocation (hot-reload safe).
+ int timeoutSeconds = runtimeConfig.McpDmlTools?.EffectiveAggregateRecordsQueryTimeoutSeconds
+ ?? DmlToolsConfig.DEFAULT_QUERY_TIMEOUT_SECONDS;
+
+ // Defensive runtime guard: clamp timeout to valid range [1, MAX_QUERY_TIMEOUT_SECONDS].
+ if (timeoutSeconds < 1 || timeoutSeconds > DmlToolsConfig.MAX_QUERY_TIMEOUT_SECONDS)
+ {
+ timeoutSeconds = DmlToolsConfig.DEFAULT_QUERY_TIMEOUT_SECONDS;
+ }
+
+ // Wrap tool execution with the configured timeout using a linked CancellationTokenSource.
+ using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
+
+ // 1. Parse and validate all input arguments
+ CallToolResult? parseError = TryParseAndValidateArguments(arguments, runtimeConfig, toolName, out AggregateArguments args, logger);
+ if (parseError != null)
+ {
+ return parseError;
+ }
+
+ entityName = args.EntityName;
+
+ // 2. Resolve metadata and validate entity source type
+ if (!McpMetadataHelper.TryResolveMetadata(
+ entityName, runtimeConfig, serviceProvider,
+ out ISqlMetadataProvider sqlMetadataProvider,
+ out DatabaseObject dbObject,
+ out string dataSourceName,
+ out string metadataError))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger);
+ }
+
+ CallToolResult? sourceTypeError = ValidateEntitySourceType(entityName, dbObject, toolName, logger);
+ if (sourceTypeError != null)
+ {
+ return sourceTypeError;
+ }
+
+ // 3. Early field validation: check all user-supplied field names before authorization or query building
+ CallToolResult? fieldError = ValidateFieldsExist(args, entityName, sqlMetadataProvider, toolName, logger);
+ if (fieldError != null)
+ {
+ return fieldError;
+ }
+
+ // 4. Authorize the request and build the query context
+ (AuthorizedContext? authCtx, CallToolResult? authError) = await AuthorizeRequestAsync(
+ args, entityName, dbObject, serviceProvider, runtimeConfigProvider, sqlMetadataProvider, toolName, logger);
+ if (authError != null)
+ {
+ return authError;
+ }
+
+ // 5. Validate database type support
+ DatabaseType databaseType = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
+ if (databaseType != DatabaseType.MSSQL && databaseType != DatabaseType.DWSQL)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "UnsupportedDatabase",
+ $"Aggregation is not supported for database type '{databaseType}'. Aggregation is only available for Azure SQL, SQL Server, and SQL Data Warehouse.", logger);
+ }
+
+ // 6. Build SQL query structure with aggregation, groupby, having
+ IAuthorizationResolver authResolver = serviceProvider.GetRequiredService();
+ GQLFilterParser gQLFilterParser = serviceProvider.GetRequiredService();
+ SqlQueryStructure structure = new(
+ authCtx!.RequestContext, sqlMetadataProvider, authResolver, runtimeConfigProvider, gQLFilterParser, authCtx.HttpContext);
+
+ string? backingField = ResolveBackingField(args, entityName, sqlMetadataProvider, toolName, out CallToolResult? pkError, logger);
+ if (pkError != null)
+ {
+ return pkError;
+ }
+
+ string alias = ComputeAlias(args.Function, args.Field);
+ BuildAggregationStructure(args, structure, dbObject, backingField!, alias, entityName, sqlMetadataProvider);
+
+ // 7. Generate and post-process SQL
+ IAbstractQueryManagerFactory queryManagerFactory = serviceProvider.GetRequiredService();
+ IQueryBuilder queryBuilder = queryManagerFactory.GetQueryBuilder(databaseType);
+ IQueryExecutor queryExecutor = queryManagerFactory.GetQueryExecutor(databaseType);
+
+ string sql = queryBuilder.Build(structure);
+ if (args.Groupby.Count > 0)
+ {
+ sql = ApplyOrderByAndPagination(sql, args, structure, queryBuilder, backingField!);
+ }
+
+ // 8. Execute query and return results
+ timeoutCts.Token.ThrowIfCancellationRequested();
+ JsonDocument? queryResult = await queryExecutor.ExecuteQueryAsync(
+ sql, structure.Parameters, queryExecutor.GetJsonResultAsync,
+ dataSourceName, authCtx.HttpContext);
+
+ JsonArray? resultArray = queryResult != null
+ ? JsonSerializer.Deserialize(queryResult.RootElement.GetRawText())
+ : null;
+
+ return args.First.HasValue && args.Groupby.Count > 0
+ ? BuildPaginatedResponse(resultArray, args.First.Value, args.After, entityName, logger)
+ : BuildSimpleResponse(resultArray, entityName, alias, logger);
+ }
+ catch (TimeoutException)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", BuildTimeoutErrorMessage(entityName), logger);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // The timeout CTS fired, not the caller's token. Treat as timeout error.
+ return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", BuildTimeoutErrorMessage(entityName), logger);
+ }
+ catch (OperationCanceledException)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", BuildOperationCanceledErrorMessage(entityName), logger);
+ }
+ catch (DbException dbException)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseOperationFailed", dbException.Message, logger);
+ }
+ catch (ArgumentException argumentException)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argumentException.Message, logger);
+ }
+ catch (DataApiBuilderException dabException)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, dabException.StatusCode.ToString(), dabException.Message, logger);
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError(ex, "Unexpected error in AggregateRecordsTool.");
+ return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "Unexpected error occurred in AggregateRecordsTool.", logger);
+ }
+ }
+
+ #region Argument Parsing and Validation
+
+ ///
+ /// Parses and validates all arguments from the tool invocation.
+ /// Returns null on success with the parsed arguments in the out parameter,
+ /// or returns a error to return to the caller.
+ ///
+ private static CallToolResult? TryParseAndValidateArguments(
+ JsonDocument? arguments,
+ RuntimeConfig runtimeConfig,
+ string toolName,
+ out AggregateArguments args,
+ ILogger? logger)
+ {
+ args = default!;
+
+ if (arguments == null)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger);
+ }
+
+ JsonElement root = arguments.RootElement;
+
+ // Parse entity
+ if (!McpArgumentParser.TryParseEntity(root, out string entityName, out string parseError))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger);
+ }
+
+ if (runtimeConfig.Entities?.TryGetValue(entityName, out Entity? entity) == true &&
+ entity.Mcp?.DmlToolEnabled == false)
+ {
+ return McpErrorHelpers.ToolDisabled(toolName, logger, $"DML tools are disabled for entity '{entityName}'.");
+ }
+
+ // Parse function
+ if (!root.TryGetProperty("function", out JsonElement functionElement) || string.IsNullOrWhiteSpace(functionElement.GetString()))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Missing required argument 'function'.", logger);
+ }
+
+ string function = functionElement.GetString()!.ToLowerInvariant();
+ if (!_validFunctions.Contains(function))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ $"Invalid function '{function}'. Must be one of: count, avg, sum, min, max.", logger);
+ }
+
+ // Parse field
+ if (!root.TryGetProperty("field", out JsonElement fieldElement) || string.IsNullOrWhiteSpace(fieldElement.GetString()))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Missing required argument 'field'.", logger);
+ }
+
+ string field = fieldElement.GetString()!;
+ bool isCountStar = function == "count" && field == "*";
+
+ if (field == "*" && function != "count")
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ $"Field '*' is only valid with function 'count'. For function '{function}', provide a specific field name.", logger);
+ }
+
+ // Parse distinct
+ bool distinct = false;
+ if (root.TryGetProperty("distinct", out JsonElement distinctElement))
+ {
+ if (distinctElement.ValueKind != JsonValueKind.True && distinctElement.ValueKind != JsonValueKind.False)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ $"Argument 'distinct' must be a boolean (true or false). Got: '{distinctElement}'.", logger);
+ }
+
+ distinct = distinctElement.GetBoolean();
+ }
+
+ if (isCountStar && distinct)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "Cannot use distinct=true with field='*'. DISTINCT requires a specific field name. Use a field name instead of '*' to count distinct values.", logger);
+ }
+
+ // Parse filter
+ string? filter = root.TryGetProperty("filter", out JsonElement filterElement) ? filterElement.GetString() : null;
+
+ // Parse orderby
+ bool userProvidedOrderby = root.TryGetProperty("orderby", out JsonElement orderbyElement) && !string.IsNullOrWhiteSpace(orderbyElement.GetString());
+ string orderby = "desc";
+ if (userProvidedOrderby)
+ {
+ string normalizedOrderby = (orderbyElement.GetString() ?? string.Empty).Trim().ToLowerInvariant();
+ if (normalizedOrderby != "asc" && normalizedOrderby != "desc")
+ {
+ return McpResponseBuilder.BuildErrorResult(
+ toolName,
+ "InvalidArguments",
+ $"Argument 'orderby' must be either 'asc' or 'desc' when provided. Got: '{orderbyElement.GetString()}'.",
+ logger);
+ }
+
+ orderby = normalizedOrderby;
+ }
+
+ // Parse first
+ int? first = null;
+ if (root.TryGetProperty("first", out JsonElement firstElement) && firstElement.ValueKind == JsonValueKind.Number)
+ {
+ first = firstElement.GetInt32();
+ if (first < 1)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Argument 'first' must be at least 1.", logger);
+ }
+ }
+
+ // Parse after
+ string? after = root.TryGetProperty("after", out JsonElement afterElement) ? afterElement.GetString() : null;
+
+ // Parse groupby (deduplicate to avoid redundant GROUP BY columns)
+ List groupby = new();
+ HashSet seenGroupby = new(StringComparer.OrdinalIgnoreCase);
+ if (root.TryGetProperty("groupby", out JsonElement groupbyElement) && groupbyElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (JsonElement groupbyItem in groupbyElement.EnumerateArray())
+ {
+ string? groupbyFieldName = groupbyItem.GetString();
+ if (!string.IsNullOrWhiteSpace(groupbyFieldName) && seenGroupby.Add(groupbyFieldName))
+ {
+ groupby.Add(groupbyFieldName);
+ }
+ }
+ }
+
+ // Validate groupby-dependent parameters
+ CallToolResult? dependencyError = ValidateGroupByDependencies(
+ groupby.Count, userProvidedOrderby, first, after, toolName, logger);
+ if (dependencyError != null)
+ {
+ return dependencyError;
+ }
+
+ // Parse having clause
+ Dictionary? havingOperators = null;
+ List? havingInValues = null;
+ if (root.TryGetProperty("having", out JsonElement havingElement) && havingElement.ValueKind == JsonValueKind.Object)
+ {
+ CallToolResult? havingError = TryParseHaving(
+ havingElement, groupby.Count, toolName, out havingOperators, out havingInValues, logger);
+ if (havingError != null)
+ {
+ return havingError;
+ }
+ }
+
+ args = new AggregateArguments(
+ EntityName: entityName,
+ Function: function,
+ Field: field,
+ IsCountStar: isCountStar,
+ Distinct: distinct,
+ Filter: filter,
+ UserProvidedOrderby: userProvidedOrderby,
+ Orderby: orderby,
+ First: first,
+ After: after,
+ Groupby: groupby,
+ HavingOperators: havingOperators,
+ HavingInValues: havingInValues);
+
+ return null;
+ }
+
+ ///
+ /// Validates that parameters requiring groupby (orderby, first, after) are only used when groupby is present.
+ /// Also validates that 'after' requires 'first'.
+ ///
+ private static CallToolResult? ValidateGroupByDependencies(
+ int groupbyCount,
+ bool userProvidedOrderby,
+ int? first,
+ string? after,
+ string toolName,
+ ILogger? logger)
+ {
+ if (groupbyCount == 0)
+ {
+ if (userProvidedOrderby)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'orderby' parameter requires 'groupby' to be specified. Sorting applies to grouped aggregation results.", logger);
+ }
+
+ if (first.HasValue)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'first' parameter requires 'groupby' to be specified. Pagination applies to grouped aggregation results.", logger);
+ }
+
+ if (!string.IsNullOrEmpty(after))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'after' parameter requires 'groupby' to be specified. Pagination applies to grouped aggregation results.", logger);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(after) && !first.HasValue)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'after' parameter requires 'first' to be specified. Provide 'first' to enable pagination.", logger);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses and validates the 'having' clause from the tool arguments.
+ ///
+ private static CallToolResult? TryParseHaving(
+ JsonElement havingElement,
+ int groupbyCount,
+ string toolName,
+ out Dictionary? havingOperators,
+ out List? havingInValues,
+ ILogger? logger)
+ {
+ havingOperators = null;
+ havingInValues = null;
+
+ if (groupbyCount == 0)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'having' parameter requires 'groupby' to be specified. HAVING filters groups after aggregation.", logger);
+ }
+
+ havingOperators = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (JsonProperty prop in havingElement.EnumerateObject())
+ {
+ if (!_validHavingOperators.Contains(prop.Name))
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ $"Unsupported having operator '{prop.Name}'. Supported operators: {string.Join(", ", _validHavingOperators)}.", logger);
+ }
+
+ if (prop.Name.Equals("in", StringComparison.OrdinalIgnoreCase))
+ {
+ if (prop.Value.ValueKind != JsonValueKind.Array)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'having.in' value must be a numeric array. Example: {\"in\": [5, 10]}.", logger);
+ }
+
+ havingInValues = new List();
+ foreach (JsonElement item in prop.Value.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Number)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ $"All values in 'having.in' must be numeric. Found non-numeric value: '{item}'.", logger);
+ }
+
+ havingInValues.Add(item.GetDouble());
+ }
+
+ if (havingInValues.Count == 0)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ "The 'having.in' array must contain at least one numeric value.", logger);
+ }
+ }
+ else
+ {
+ if (prop.Value.ValueKind != JsonValueKind.Number)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments",
+ $"The 'having.{prop.Name}' value must be numeric. Got: '{prop.Value}'. HAVING filters compare aggregated numeric results.", logger);
+ }
+
+ havingOperators[prop.Name] = prop.Value.GetDouble();
+ }
+ }
+
+ return null;
+ }
+
+ #endregion
+
+ #region Entity and Field Validation
+
+ ///
+ /// Validates that the entity is a table or view (not a stored procedure).
+ ///
+ private static CallToolResult? ValidateEntitySourceType(
+ string entityName, DatabaseObject dbObject, string toolName, ILogger? logger)
+ {
+ if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View)
+ {
+ return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity",
+ $"Entity '{entityName}' is not a table or view. Aggregation is not supported for stored procedures. Use 'execute_entity' for stored procedures.", logger);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Validates that all user-supplied field names (aggregation field and groupby fields)
+ /// exist in the entity's metadata. This early validation lets the model discover typos immediately.
+ ///
+ private static CallToolResult? ValidateFieldsExist(
+ AggregateArguments args,
+ string entityName,
+ ISqlMetadataProvider sqlMetadataProvider,
+ string toolName,
+ ILogger? logger)
+ {
+ if (!args.IsCountStar && !sqlMetadataProvider.TryGetBackingColumn(entityName, args.Field, out _))
+ {
+ return McpErrorHelpers.FieldNotFound(toolName, entityName, args.Field, "field", logger);
+ }
+
+ foreach (string groupbyField in args.Groupby)
+ {
+ if (!sqlMetadataProvider.TryGetBackingColumn(entityName, groupbyField, out _))
+ {
+ return McpErrorHelpers.FieldNotFound(toolName, entityName, groupbyField, "groupby", logger);
+ }
+ }
+
+ return null;
+ }
+
+ #endregion
+
+ #region Authorization
+
+ ///
+ /// Authorizes the request and builds the with validated fields and filters.
+ /// Returns a tuple of (AuthorizedContext on success, CallToolResult error on failure).
+ ///
+ private static async Task<(AuthorizedContext? context, CallToolResult? error)> AuthorizeRequestAsync(
+ AggregateArguments args,
+ string entityName,
+ DatabaseObject dbObject,
+ IServiceProvider serviceProvider,
+ RuntimeConfigProvider runtimeConfigProvider,
+ ISqlMetadataProvider sqlMetadataProvider,
+ string toolName,
+ ILogger? logger)
+ {
+ IAuthorizationResolver authResolver = serviceProvider.GetRequiredService();
+ IAuthorizationService authorizationService = serviceProvider.GetRequiredService();
+ IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService();
+ HttpContext? httpContext = httpContextAccessor.HttpContext;
+
+ if (httpContext is null)
+ {
+ return (null, McpErrorHelpers.PermissionDenied(toolName, entityName, "read", "No active HTTP request context.", logger));
+ }
+
+ if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleCtxError))
+ {
+ return (null, McpErrorHelpers.PermissionDenied(toolName, entityName, "read", roleCtxError, logger));
+ }
+
+ if (!McpAuthorizationHelper.TryResolveAuthorizedRole(
+ httpContext!,
+ authResolver,
+ entityName,
+ EntityActionOperation.Read,
+ out string? _,
+ out string readAuthError))
+ {
+ string finalError = readAuthError.StartsWith("You do not have permission", StringComparison.OrdinalIgnoreCase)
+ ? $"You do not have permission to read records for entity '{entityName}'."
+ : readAuthError;
+ return (null, McpErrorHelpers.PermissionDenied(toolName, entityName, "read", finalError, logger));
+ }
+
+ // Build select list for authorization: groupby fields + aggregation field
+ List selectFields = new(args.Groupby);
+ if (!args.IsCountStar && !selectFields.Contains(args.Field, StringComparer.OrdinalIgnoreCase))
+ {
+ selectFields.Add(args.Field);
+ }
+
+ // Build and validate FindRequestContext
+ RequestValidator requestValidator = new(serviceProvider.GetRequiredService(), runtimeConfigProvider);
+ FindRequestContext context = new(entityName, dbObject, true);
+ httpContext!.Request.Method = "GET";
+
+ requestValidator.ValidateEntity(entityName);
+
+ if (selectFields.Count > 0)
+ {
+ context.UpdateReturnFields(selectFields);
+ }
+
+ if (!string.IsNullOrWhiteSpace(args.Filter))
+ {
+ string filterQueryString = $"?{RequestParser.FILTER_URL}={args.Filter}";
+ context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(
+ filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}");
+ }
+
+ requestValidator.ValidateRequestContext(context);
+
+ AuthorizationResult authorizationResult = await authorizationService.AuthorizeAsync(
+ user: httpContext.User,
+ resource: context,
+ requirements: new[] { new ColumnsPermissionsRequirement() });
+ if (!authorizationResult.Succeeded)
+ {
+ return (null, McpErrorHelpers.PermissionDenied(toolName, entityName, "read", DataApiBuilderException.AUTHORIZATION_FAILURE, logger));
+ }
+
+ return (new AuthorizedContext(context, httpContext), null);
+ }
+
+ #endregion
+
+ #region Query Building
+
+ ///
+ /// Resolves the backing database column name for the aggregation field.
+ /// For COUNT(*), uses the first primary key column (PK is always NOT NULL, so COUNT(pk) ≡ COUNT(*)).
+ ///
+ private static string? ResolveBackingField(
+ AggregateArguments args,
+ string entityName,
+ ISqlMetadataProvider sqlMetadataProvider,
+ string toolName,
+ out CallToolResult? error,
+ ILogger? logger)
+ {
+ error = null;
+
+ if (!args.IsCountStar)
+ {
+ if (!sqlMetadataProvider.TryGetBackingColumn(entityName, args.Field, out string? backingField))
+ {
+ error = McpErrorHelpers.FieldNotFound(toolName, entityName, args.Field, "field", logger);
+ return null;
+ }
+
+ return backingField;
+ }
+
+ // For COUNT(*), use primary key column since PK is always NOT NULL,
+ // making COUNT(pk) equivalent to COUNT(*). The engine's Build(AggregationColumn)
+ // does not support "*" as a column name (it would produce invalid SQL like count([].[*])).
+ SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName);
+ if (sourceDefinition.PrimaryKey.Count == 0)
+ {
+ error = McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity",
+ $"Entity '{entityName}' has no primary key defined. COUNT(*) requires at least one primary key column.", logger);
+ return null;
+ }
+
+ return sourceDefinition.PrimaryKey[0];
+ }
+
+ ///
+ /// Configures the with groupby columns, aggregation column,
+ /// and HAVING predicates based on the parsed arguments.
+ ///
+ private static void BuildAggregationStructure(
+ AggregateArguments args,
+ SqlQueryStructure structure,
+ DatabaseObject dbObject,
+ string backingField,
+ string alias,
+ string entityName,
+ ISqlMetadataProvider sqlMetadataProvider)
+ {
+ // Clear default columns from FindRequestContext
+ structure.Columns.Clear();
+
+ // Add groupby columns as LabelledColumns and GroupByMetadata.Fields
+ foreach (string groupbyField in args.Groupby)
+ {
+ if (!sqlMetadataProvider.TryGetBackingColumn(entityName, groupbyField, out string? backingGroupbyColumn) || string.IsNullOrEmpty(backingGroupbyColumn))
+ {
+ throw new DataApiBuilderException(
+ message: $"GroupBy field '{groupbyField}' is not a valid field for entity '{entityName}'.",
+ statusCode: System.Net.HttpStatusCode.BadRequest,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
+ }
+
+ structure.Columns.Add(new LabelledColumn(
+ dbObject.SchemaName, dbObject.Name, backingGroupbyColumn, groupbyField, structure.SourceAlias));
+ structure.GroupByMetadata.Fields[backingGroupbyColumn] = new Column(
+ dbObject.SchemaName, dbObject.Name, backingGroupbyColumn, structure.SourceAlias);
+ }
+
+ // Build aggregation column using engine's AggregationColumn type.
+ AggregationType aggregationType = Enum.Parse(args.Function, ignoreCase: true);
+ AggregationColumn aggregationColumn = new(
+ dbObject.SchemaName, dbObject.Name, backingField, aggregationType, alias, args.Distinct, structure.SourceAlias);
+
+ // Build HAVING predicate and configure aggregation metadata
+ Predicate? combinedHaving = BuildHavingPredicate(args, aggregationColumn, structure);
+ structure.GroupByMetadata.Aggregations.Add(
+ new AggregationOperation(aggregationColumn, having: combinedHaving != null ? new List { combinedHaving } : null));
+ structure.GroupByMetadata.RequestedAggregations = true;
+
+ // Clear default OrderByColumns (PK-based) and configure pagination
+ structure.OrderByColumns.Clear();
+ if (args.First.HasValue && args.Groupby.Count > 0)
+ {
+ structure.IsListQuery = true;
+ }
+ }
+
+ ///
+ /// Builds a combined HAVING predicate from the parsed having operators and IN values.
+ /// Multiple conditions are AND-ed together.
+ ///
+ private static Predicate? BuildHavingPredicate(
+ AggregateArguments args,
+ AggregationColumn aggregationColumn,
+ SqlQueryStructure structure)
+ {
+ List havingPredicates = new();
+
+ if (args.HavingOperators != null)
+ {
+ foreach (var havingOperator in args.HavingOperators)
+ {
+ PredicateOperation predicateOperation = havingOperator.Key.ToLowerInvariant() switch
+ {
+ "eq" => PredicateOperation.Equal,
+ "neq" => PredicateOperation.NotEqual,
+ "gt" => PredicateOperation.GreaterThan,
+ "gte" => PredicateOperation.GreaterThanOrEqual,
+ "lt" => PredicateOperation.LessThan,
+ "lte" => PredicateOperation.LessThanOrEqual,
+ _ => throw new ArgumentException($"Invalid having operator: {havingOperator.Key}")
+ };
+ string paramName = BaseQueryStructure.GetEncodedParamName(structure.Counter.Next());
+ structure.Parameters.Add(paramName, new DbConnectionParam(havingOperator.Value));
+ havingPredicates.Add(new Predicate(
+ new PredicateOperand(aggregationColumn),
+ predicateOperation,
+ new PredicateOperand(paramName)));
+ }
+ }
+
+ if (args.HavingInValues != null && args.HavingInValues.Count > 0)
+ {
+ List inParams = new();
+ foreach (double val in args.HavingInValues)
+ {
+ string paramName = BaseQueryStructure.GetEncodedParamName(structure.Counter.Next());
+ structure.Parameters.Add(paramName, new DbConnectionParam(val));
+ inParams.Add(paramName);
+ }
+
+ havingPredicates.Add(new Predicate(
+ new PredicateOperand(aggregationColumn),
+ PredicateOperation.IN,
+ new PredicateOperand($"({string.Join(", ", inParams)})")));
+ }
+
+ // Combine multiple HAVING predicates with AND
+ Predicate? combinedHaving = null;
+ foreach (var predicate in havingPredicates)
+ {
+ combinedHaving = combinedHaving == null
+ ? predicate
+ : new Predicate(new PredicateOperand(combinedHaving), PredicateOperation.AND, new PredicateOperand(predicate));
+ }
+
+ return combinedHaving;
+ }
+
+ ///
+ /// Post-processes the generated SQL to add ORDER BY and OFFSET/FETCH pagination
+ /// for grouped aggregation queries.
+ ///
+ private static string ApplyOrderByAndPagination(
+ string sql,
+ AggregateArguments args,
+ SqlQueryStructure structure,
+ IQueryBuilder queryBuilder,
+ string backingField)
+ {
+ string direction = args.Orderby.Equals("asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
+ string quotedCol = $"{queryBuilder.QuoteIdentifier(structure.SourceAlias)}.{queryBuilder.QuoteIdentifier(backingField)}";
+ string orderByAggExpr = args.Distinct
+ ? $"{args.Function.ToUpperInvariant()}(DISTINCT {quotedCol})"
+ : $"{args.Function.ToUpperInvariant()}({quotedCol})";
+ string orderByClause = $" ORDER BY {orderByAggExpr} {direction}";
+
+ if (args.First.HasValue)
+ {
+ // With pagination: SQL Server requires ORDER BY for OFFSET/FETCH and
+ // does not allow both TOP and OFFSET/FETCH. Remove TOP and add ORDER BY + OFFSET/FETCH.
+ int offset = DecodeCursorOffset(args.After);
+ int fetchCount = args.First.Value + 1;
+ string offsetParam = BaseQueryStructure.GetEncodedParamName(structure.Counter.Next());
+ structure.Parameters.Add(offsetParam, new DbConnectionParam(offset));
+ string limitParam = BaseQueryStructure.GetEncodedParamName(structure.Counter.Next());
+ structure.Parameters.Add(limitParam, new DbConnectionParam(fetchCount));
+
+ string paginationClause = $" OFFSET {offsetParam} ROWS FETCH NEXT {limitParam} ROWS ONLY";
+
+ // Remove TOP N from the SELECT clause (TOP conflicts with OFFSET/FETCH)
+ sql = Regex.Replace(sql, @"SELECT TOP \d+", "SELECT");
+
+ // Insert ORDER BY + pagination before FOR JSON PATH
+ int jsonPathIdx = sql.IndexOf(" FOR JSON PATH", StringComparison.OrdinalIgnoreCase);
+ if (jsonPathIdx > 0)
+ {
+ sql = sql.Insert(jsonPathIdx, orderByClause + paginationClause);
+ }
+ else
+ {
+ sql += orderByClause + paginationClause;
+ }
+ }
+ else
+ {
+ // Without pagination: insert ORDER BY before FOR JSON PATH
+ int jsonPathIdx = sql.IndexOf(" FOR JSON PATH", StringComparison.OrdinalIgnoreCase);
+ if (jsonPathIdx > 0)
+ {
+ sql = sql.Insert(jsonPathIdx, orderByClause);
+ }
+ else
+ {
+ sql += orderByClause;
+ }
+ }
+
+ return sql;
+ }
+
+ #endregion
+
+ #region Result Formatting and Helpers
+
+ ///
+ /// Computes the response alias for the aggregation result.
+ /// For count with "*", the alias is "count". Otherwise it's "{function}_{field}".
+ ///
+ internal static string ComputeAlias(string function, string field)
+ {
+ if (function == "count" && field == "*")
+ {
+ return "count";
+ }
+
+ return $"{function}_{field}";
+ }
+
+ ///
+ /// Decodes a base64-encoded cursor string to an integer offset.
+ /// Returns 0 if the cursor is null, empty, or invalid.
+ ///
+ internal static int DecodeCursorOffset(string? after)
+ {
+ if (string.IsNullOrWhiteSpace(after))
+ {
+ return 0;
+ }
+
+ try
+ {
+ byte[] bytes = Convert.FromBase64String(after);
+ string decoded = Encoding.UTF8.GetString(bytes);
+ return int.TryParse(decoded, out int cursorOffset) && cursorOffset >= 0 ? cursorOffset : 0;
+ }
+ catch (FormatException)
+ {
+ return 0;
+ }
+ }
+
+ ///
+ /// Builds the paginated response from a SQL result that fetched first+1 rows.
+ ///
+ private static CallToolResult BuildPaginatedResponse(
+ JsonArray? resultArray, int first, string? after, string entityName, ILogger? logger)
+ {
+ int startOffset = DecodeCursorOffset(after);
+ int actualCount = resultArray?.Count ?? 0;
+ bool hasNextPage = actualCount > first;
+ int returnCount = hasNextPage ? first : actualCount;
+
+ // Build page items from the SQL result
+ JsonArray pageItems = new();
+ for (int i = 0; i < returnCount && resultArray != null && i < resultArray.Count; i++)
+ {
+ pageItems.Add(resultArray[i]?.DeepClone());
+ }
+
+ string? endCursor = null;
+ if (returnCount > 0)
+ {
+ int lastItemIndex = startOffset + returnCount;
+ endCursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(lastItemIndex.ToString()));
+ }
+
+ JsonElement itemsElement = JsonSerializer.Deserialize(pageItems.ToJsonString());
+
+ return McpResponseBuilder.BuildSuccessResult(
+ new Dictionary
+ {
+ ["entity"] = entityName,
+ ["result"] = new Dictionary
+ {
+ ["items"] = itemsElement,
+ ["endCursor"] = endCursor,
+ ["hasNextPage"] = hasNextPage
+ },
+ ["message"] = $"Successfully aggregated records for entity '{entityName}'"
+ },
+ logger,
+ $"AggregateRecordsTool success for entity {entityName}.");
+ }
+
+ ///
+ /// Builds the simple (non-paginated) response from a SQL result.
+ ///
+ private static CallToolResult BuildSimpleResponse(
+ JsonArray? resultArray, string entityName, string alias, ILogger? logger)
+ {
+ JsonElement resultElement;
+ if (resultArray == null || resultArray.Count == 0)
+ {
+ // For non-grouped aggregate with no results, return null value
+ JsonArray nullArray = new() { new JsonObject { [alias] = null } };
+ resultElement = JsonSerializer.Deserialize(nullArray.ToJsonString());
+ }
+ else
+ {
+ resultElement = JsonSerializer.Deserialize(resultArray.ToJsonString());
+ }
+
+ return McpResponseBuilder.BuildSuccessResult(
+ new Dictionary
+ {
+ ["entity"] = entityName,
+ ["result"] = resultElement,
+ ["message"] = $"Successfully aggregated records for entity '{entityName}'"
+ },
+ logger,
+ $"AggregateRecordsTool success for entity {entityName}.");
+ }
+
+ #endregion
+
+ #region Error Message Builders
+
+ ///
+ /// Builds the error message for a TimeoutException during aggregation.
+ ///
+ internal static string BuildTimeoutErrorMessage(string entityName)
+ {
+ return $"The aggregation query for entity '{entityName}' timed out. "
+ + "This is NOT a tool error. The database did not respond in time. "
+ + "This may occur with large datasets or complex aggregations. "
+ + "Try narrowing results with a 'filter', reducing 'groupby' fields, or adding 'first' for pagination.";
+ }
+
+ ///
+ /// Builds the error message for an OperationCanceledException during aggregation.
+ ///
+ internal static string BuildOperationCanceledErrorMessage(string entityName)
+ {
+ return $"The aggregation query for entity '{entityName}' was canceled before completion. "
+ + "This is NOT a tool error. The operation was interrupted, possibly due to a timeout or client disconnect. "
+ + "No results were returned. You may retry the same request.";
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs
index 1a5c223798..13835b2fa9 100644
--- a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs
@@ -24,5 +24,16 @@ public static CallToolResult ToolDisabled(string toolName, ILogger? logger, stri
string message = customMessage ?? $"The {toolName} tool is disabled in the configuration.";
return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.ToolDisabled.ToString(), message, logger);
}
+
+ ///
+ /// Returns a model-friendly error when a field name is not found for an entity.
+ /// Guides the model to call describe_entities to discover valid field names.
+ ///
+ public static CallToolResult FieldNotFound(string toolName, string entityName, string fieldName, string parameterName, ILogger? logger)
+ {
+ string message = $"Field '{fieldName}' in '{parameterName}' was not found for entity '{entityName}'. "
+ + $"Call describe_entities to get valid field names for '{entityName}'.";
+ return McpResponseBuilder.BuildErrorResult(toolName, "FieldNotFound", message, logger);
+ }
}
}
diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryErrorCodes.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryErrorCodes.cs
index f69a26fa5d..ecaaad54de 100644
--- a/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryErrorCodes.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryErrorCodes.cs
@@ -37,5 +37,10 @@ internal static class McpTelemetryErrorCodes
/// Operation cancelled error code.
///
public const string OPERATION_CANCELLED = "OperationCancelled";
+
+ ///
+ /// Operation timed out error code.
+ ///
+ public const string OPERATION_TIMEOUT = "OperationTimeout";
}
}
diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryHelper.cs
index 2a60557f8d..c423534816 100644
--- a/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryHelper.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryHelper.cs
@@ -60,7 +60,6 @@ public static async Task ExecuteWithTelemetryAsync(
operation: operation,
dbProcedure: dbProcedure);
- // Execute the tool
CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, cancellationToken);
// Check if the tool returned an error result (tools catch exceptions internally
@@ -124,6 +123,7 @@ public static string InferOperationFromTool(IMcpTool tool, string toolName)
"delete_record" => "delete",
"describe_entities" => "describe",
"execute_entity" => "execute",
+ "aggregate_records" => "aggregate",
_ => "execute" // Fallback for any unknown built-in tools
};
}
@@ -188,6 +188,7 @@ public static string MapExceptionToErrorCode(Exception ex)
return ex switch
{
OperationCanceledException => McpTelemetryErrorCodes.OPERATION_CANCELLED,
+ TimeoutException => McpTelemetryErrorCodes.OPERATION_TIMEOUT,
DataApiBuilderException dabEx when dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.AuthenticationChallenge
=> McpTelemetryErrorCodes.AUTHENTICATION_FAILED,
DataApiBuilderException dabEx when dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed
diff --git a/src/Cli.Tests/InitTests.cs b/src/Cli.Tests/InitTests.cs
index 051bfdf7a7..e92b6f09d1 100644
--- a/src/Cli.Tests/InitTests.cs
+++ b/src/Cli.Tests/InitTests.cs
@@ -493,6 +493,74 @@ public Task VerifyCorrectConfigGenerationWithMultipleMutationOptions(DatabaseTyp
return ExecuteVerifyTest(options, verifySettings);
}
+ ///
+ /// Test that init with/without --mcp.aggregate-records.query-timeout produces a config
+ /// with the correct aggregate-records query-timeout in the DmlTools section.
+ /// When null (not specified), defaults to 30 seconds. When provided, the config reflects the value.
+ ///
+ [DataTestMethod]
+ [DataRow(null, false, DmlToolsConfig.DEFAULT_QUERY_TIMEOUT_SECONDS, DisplayName = "Init without query-timeout uses default 30s")]
+ [DataRow(1, true, 1, DisplayName = "Init with query-timeout 1s (minimum)")]
+ [DataRow(120, true, 120, DisplayName = "Init with query-timeout 120s")]
+ [DataRow(600, true, 600, DisplayName = "Init with query-timeout 600s (maximum)")]
+ public void InitWithAggregateRecordsQueryTimeout_SetsOrDefaultsTimeout(int? inputTimeout, bool expectedUserProvided, int expectedEffectiveTimeout)
+ {
+ InitOptions options = new(
+ databaseType: DatabaseType.MSSQL,
+ connectionString: "testconnectionstring",
+ cosmosNoSqlDatabase: null,
+ cosmosNoSqlContainer: null,
+ graphQLSchemaPath: null,
+ setSessionContext: false,
+ hostMode: HostMode.Development,
+ corsOrigin: null,
+ authenticationProvider: EasyAuthType.AppService.ToString(),
+ mcpAggregateRecordsQueryTimeout: inputTimeout,
+ config: TEST_RUNTIME_CONFIG_FILE);
+
+ Assert.IsTrue(TryCreateRuntimeConfig(options, _runtimeConfigLoader!, _fileSystem!, out RuntimeConfig? runtimeConfig));
+ Assert.IsNotNull(runtimeConfig?.Runtime?.Mcp?.DmlTools);
+ Assert.AreEqual(inputTimeout, runtimeConfig.Runtime.Mcp.DmlTools.AggregateRecordsQueryTimeout);
+ Assert.AreEqual(expectedUserProvided, runtimeConfig.Runtime.Mcp.DmlTools.UserProvidedAggregateRecordsQueryTimeout);
+ Assert.AreEqual(expectedEffectiveTimeout, runtimeConfig.Runtime.Mcp.DmlTools.EffectiveAggregateRecordsQueryTimeoutSeconds);
+ }
+
+ ///
+ /// Test that init with --mcp.aggregate-records.query-timeout produces valid JSON
+ /// that round-trips correctly through serialization/deserialization.
+ ///
+ [TestMethod]
+ public void InitWithAggregateRecordsQueryTimeout_RoundTripsCorrectly()
+ {
+ InitOptions options = new(
+ databaseType: DatabaseType.MSSQL,
+ connectionString: "testconnectionstring",
+ cosmosNoSqlDatabase: null,
+ cosmosNoSqlContainer: null,
+ graphQLSchemaPath: null,
+ setSessionContext: false,
+ hostMode: HostMode.Development,
+ corsOrigin: null,
+ authenticationProvider: EasyAuthType.AppService.ToString(),
+ mcpAggregateRecordsQueryTimeout: 90,
+ config: TEST_RUNTIME_CONFIG_FILE);
+
+ Assert.IsTrue(TryCreateRuntimeConfig(options, _runtimeConfigLoader!, _fileSystem!, out RuntimeConfig? runtimeConfig));
+
+ // Serialize to JSON and deserialize back
+ JsonSerializerOptions serializerOptions = RuntimeConfigLoader.GetSerializationOptions();
+ string json = JsonSerializer.Serialize(runtimeConfig, serializerOptions);
+ RuntimeConfig? deserialized = JsonSerializer.Deserialize(json, serializerOptions);
+
+ Assert.IsNotNull(deserialized?.Runtime?.Mcp?.DmlTools);
+ Assert.AreEqual(90, deserialized.Runtime.Mcp.DmlTools.AggregateRecordsQueryTimeout);
+ Assert.AreEqual(90, deserialized.Runtime.Mcp.DmlTools.EffectiveAggregateRecordsQueryTimeoutSeconds);
+
+ // Verify the JSON contains the object format for aggregate-records
+ Assert.IsTrue(json.Contains("\"query-timeout\""), $"Expected 'query-timeout' in serialized JSON. Got: {json}");
+ Assert.IsTrue(json.Contains("90"), $"Expected timeout value 90 in serialized JSON. Got: {json}");
+ }
+
private Task ExecuteVerifyTest(InitOptions options, VerifySettings? settings = null)
{
Assert.IsTrue(TryCreateRuntimeConfig(options, _runtimeConfigLoader!, _fileSystem!, out RuntimeConfig? runtimeConfig));
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt
index 3fa1fbc14e..11bf762b42 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt
index 76ea01dfca..475149674b 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt
index 3a8c738a70..19c590a7f7 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt
index df2cd4b009..2b4583dc36 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt
index 1b14a3a7f0..9be903b3bd 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt
@@ -28,13 +28,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt
index 62d9e237b5..eae623f5a8 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt
@@ -1,4 +1,4 @@
-{
+{
DataSource: {
DatabaseType: MSSQL,
Options: {
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt
index fa8b16e739..4be8d89e14 100644
--- a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt
+++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt
index 9d5458c0ee..034276178e 100644
--- a/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt
@@ -28,13 +28,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt
index 51f6ad8d95..64b71e6c59 100644
--- a/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt
index 978d1a253b..b89b9c70d1 100644
--- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt
index 402bf4d2bc..ce2d025be4 100644
--- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt
index ab71a40f03..02e6387637 100644
--- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt
index 25e3976685..cca682e8c3 100644
--- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt
index 140f017b78..657afc9ce6 100644
--- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt b/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt
index a3a056ac0a..86ba02fbcd 100644
--- a/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt
index f40350c4da..777642d9e0 100644
--- a/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt b/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt
index b792d41c9f..5a19301e74 100644
--- a/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt b/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt
index 173960d7b1..e40b268f89 100644
--- a/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt b/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt
index 25e3976685..cca682e8c3 100644
--- a/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt
index 63f0da701c..e8193e5f14 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt
index f40350c4da..777642d9e0 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt
index e59070d692..d5b44393ec 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt
@@ -32,13 +32,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt
index f7de35b7ae..9a28bccd06 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt
index 63f0da701c..e8193e5f14 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt
index f7de35b7ae..9a28bccd06 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt
index 75613db959..a8c2329c65 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt
index d93aac7dc6..5ed3cbcffd 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt
@@ -28,13 +28,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt
index 640815babb..09377f9c51 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt
@@ -32,13 +32,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt
index 5900015d5a..51c90e1666 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt
index 63f0da701c..e8193e5f14 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt
@@ -27,13 +27,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt
index d93aac7dc6..5ed3cbcffd 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt
@@ -28,13 +28,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt
index d93aac7dc6..5ed3cbcffd 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt
@@ -28,13 +28,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt
index 75613db959..a8c2329c65 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt
index 5900015d5a..51c90e1666 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt
index 75613db959..a8c2329c65 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt
index f7de35b7ae..9a28bccd06 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt
index 5900015d5a..51c90e1666 100644
--- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt
+++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs
index 262cbc9145..a800591aa3 100644
--- a/src/Cli/Commands/ConfigureOptions.cs
+++ b/src/Cli/Commands/ConfigureOptions.cs
@@ -49,6 +49,8 @@ public ConfigureOptions(
bool? runtimeMcpDmlToolsUpdateRecordEnabled = null,
bool? runtimeMcpDmlToolsDeleteRecordEnabled = null,
bool? runtimeMcpDmlToolsExecuteEntityEnabled = null,
+ bool? runtimeMcpDmlToolsAggregateRecordsEnabled = null,
+ int? runtimeMcpDmlToolsAggregateRecordsQueryTimeout = null,
bool? runtimeCacheEnabled = null,
int? runtimeCacheTtl = null,
CompressionLevel? runtimeCompressionLevel = null,
@@ -109,6 +111,8 @@ public ConfigureOptions(
RuntimeMcpDmlToolsUpdateRecordEnabled = runtimeMcpDmlToolsUpdateRecordEnabled;
RuntimeMcpDmlToolsDeleteRecordEnabled = runtimeMcpDmlToolsDeleteRecordEnabled;
RuntimeMcpDmlToolsExecuteEntityEnabled = runtimeMcpDmlToolsExecuteEntityEnabled;
+ RuntimeMcpDmlToolsAggregateRecordsEnabled = runtimeMcpDmlToolsAggregateRecordsEnabled;
+ RuntimeMcpDmlToolsAggregateRecordsQueryTimeout = runtimeMcpDmlToolsAggregateRecordsQueryTimeout;
// Cache
RuntimeCacheEnabled = runtimeCacheEnabled;
RuntimeCacheTTL = runtimeCacheTtl;
@@ -224,6 +228,12 @@ public ConfigureOptions(
[Option("runtime.mcp.dml-tools.execute-entity.enabled", Required = false, HelpText = "Enable DAB's MCP execute entity tool. Default: true (boolean).")]
public bool? RuntimeMcpDmlToolsExecuteEntityEnabled { get; }
+ [Option("runtime.mcp.dml-tools.aggregate-records.enabled", Required = false, HelpText = "Enable DAB's MCP aggregate records tool. Default: true (boolean).")]
+ public bool? RuntimeMcpDmlToolsAggregateRecordsEnabled { get; }
+
+ [Option("runtime.mcp.dml-tools.aggregate-records.query-timeout", Required = false, HelpText = "Set the execution timeout in seconds for the aggregate-records MCP tool. Default: 30 (integer). Range: 1-600.")]
+ public int? RuntimeMcpDmlToolsAggregateRecordsQueryTimeout { get; }
+
[Option("runtime.cache.enabled", Required = false, HelpText = "Enable DAB's cache globally. (You must also enable each entity's cache separately.). Default: false (boolean).")]
public bool? RuntimeCacheEnabled { get; }
diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs
index e01ad1b774..008d9af87c 100644
--- a/src/Cli/Commands/InitOptions.cs
+++ b/src/Cli/Commands/InitOptions.cs
@@ -42,6 +42,7 @@ public InitOptions(
CliBool mcpEnabled = CliBool.None,
CliBool restRequestBodyStrict = CliBool.None,
CliBool multipleCreateOperationEnabled = CliBool.None,
+ int? mcpAggregateRecordsQueryTimeout = null,
string? config = null)
: base(config)
{
@@ -68,6 +69,7 @@ public InitOptions(
McpEnabled = mcpEnabled;
RestRequestBodyStrict = restRequestBodyStrict;
MultipleCreateOperationEnabled = multipleCreateOperationEnabled;
+ McpAggregateRecordsQueryTimeout = mcpAggregateRecordsQueryTimeout;
}
[Option("database-type", Required = true, HelpText = "Type of database to connect. Supported values: mssql, cosmosdb_nosql, cosmosdb_postgresql, mysql, postgresql, dwsql")]
@@ -141,6 +143,9 @@ public InitOptions(
[Option("graphql.multiple-create.enabled", Required = false, HelpText = "(Default: false) Enables multiple create operation for GraphQL. Supported values: true, false.")]
public CliBool MultipleCreateOperationEnabled { get; }
+ [Option("mcp.aggregate-records.query-timeout", Required = false, HelpText = "Set the execution timeout in seconds for the aggregate-records MCP tool. Default: 30 (integer). Range: 1-600.")]
+ public int? McpAggregateRecordsQueryTimeout { get; }
+
public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem)
{
logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion());
diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs
index 40bb6c7262..d97971ecb5 100644
--- a/src/Cli/ConfigGenerator.cs
+++ b/src/Cli/ConfigGenerator.cs
@@ -268,7 +268,12 @@ public static bool TryCreateRuntimeConfig(InitOptions options, FileSystemRuntime
Runtime: new(
Rest: new(restEnabled, restPath ?? RestRuntimeOptions.DEFAULT_PATH, options.RestRequestBodyStrict is CliBool.False ? false : true),
GraphQL: new(Enabled: graphQLEnabled, Path: graphQLPath, MultipleMutationOptions: multipleMutationOptions),
- Mcp: new(mcpEnabled, mcpPath ?? McpRuntimeOptions.DEFAULT_PATH),
+ Mcp: new(
+ Enabled: mcpEnabled,
+ Path: mcpPath ?? McpRuntimeOptions.DEFAULT_PATH,
+ DmlTools: options.McpAggregateRecordsQueryTimeout is not null
+ ? new DmlToolsConfig(aggregateRecordsQueryTimeout: options.McpAggregateRecordsQueryTimeout)
+ : null),
Host: new(
Cors: new(options.CorsOrigin?.ToArray() ?? Array.Empty()),
Authentication: new(
@@ -886,7 +891,9 @@ private static bool TryUpdateConfiguredRuntimeOptions(
options.RuntimeMcpDmlToolsReadRecordsEnabled != null ||
options.RuntimeMcpDmlToolsUpdateRecordEnabled != null ||
options.RuntimeMcpDmlToolsDeleteRecordEnabled != null ||
- options.RuntimeMcpDmlToolsExecuteEntityEnabled != null)
+ options.RuntimeMcpDmlToolsExecuteEntityEnabled != null ||
+ options.RuntimeMcpDmlToolsAggregateRecordsEnabled != null ||
+ options.RuntimeMcpDmlToolsAggregateRecordsQueryTimeout != null)
{
McpRuntimeOptions updatedMcpOptions = runtimeConfig?.Runtime?.Mcp ?? new();
bool status = TryUpdateConfiguredMcpValues(options, ref updatedMcpOptions);
@@ -1185,6 +1192,8 @@ private static bool TryUpdateConfiguredMcpValues(
bool? updateRecord = currentDmlTools?.UpdateRecord;
bool? deleteRecord = currentDmlTools?.DeleteRecord;
bool? executeEntity = currentDmlTools?.ExecuteEntity;
+ bool? aggregateRecords = currentDmlTools?.AggregateRecords;
+ int? aggregateRecordsQueryTimeout = currentDmlTools?.AggregateRecordsQueryTimeout;
updatedValue = options?.RuntimeMcpDmlToolsDescribeEntitiesEnabled;
if (updatedValue != null)
@@ -1234,20 +1243,35 @@ private static bool TryUpdateConfiguredMcpValues(
_logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.execute-entity as '{updatedValue}'", updatedValue);
}
+ updatedValue = options?.RuntimeMcpDmlToolsAggregateRecordsEnabled;
+ if (updatedValue != null)
+ {
+ aggregateRecords = (bool)updatedValue;
+ hasToolUpdates = true;
+ _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.aggregate-records as '{updatedValue}'", updatedValue);
+ }
+
+ updatedValue = options?.RuntimeMcpDmlToolsAggregateRecordsQueryTimeout;
+ if (updatedValue != null)
+ {
+ aggregateRecordsQueryTimeout = (int)updatedValue;
+ hasToolUpdates = true;
+ _logger.LogInformation("Updated RuntimeConfig with runtime.mcp.dml-tools.aggregate-records.query-timeout as '{updatedValue}'", updatedValue);
+ }
+
if (hasToolUpdates)
{
updatedMcpOptions = updatedMcpOptions! with
{
- DmlTools = new DmlToolsConfig
- {
- AllToolsEnabled = false,
- DescribeEntities = describeEntities,
- CreateRecord = createRecord,
- ReadRecords = readRecord,
- UpdateRecord = updateRecord,
- DeleteRecord = deleteRecord,
- ExecuteEntity = executeEntity
- }
+ DmlTools = new DmlToolsConfig(
+ describeEntities: describeEntities,
+ createRecord: createRecord,
+ readRecords: readRecord,
+ updateRecord: updateRecord,
+ deleteRecord: deleteRecord,
+ executeEntity: executeEntity,
+ aggregateRecords: aggregateRecords,
+ aggregateRecordsQueryTimeout: aggregateRecordsQueryTimeout)
};
}
diff --git a/src/Config/Converters/DmlToolsConfigConverter.cs b/src/Config/Converters/DmlToolsConfigConverter.cs
index 82ac3f6069..16bc0a81c9 100644
--- a/src/Config/Converters/DmlToolsConfigConverter.cs
+++ b/src/Config/Converters/DmlToolsConfigConverter.cs
@@ -44,6 +44,8 @@ internal class DmlToolsConfigConverter : JsonConverter
bool? updateRecord = null;
bool? deleteRecord = null;
bool? executeEntity = null;
+ bool? aggregateRecords = null;
+ int? aggregateRecordsQueryTimeout = null;
while (reader.Read())
{
@@ -57,8 +59,54 @@ internal class DmlToolsConfigConverter : JsonConverter
string? property = reader.GetString();
reader.Read();
- // Handle the property value
- if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False)
+ // aggregate-records supports both boolean and object formats
+ if (property?.ToLowerInvariant() == "aggregate-records")
+ {
+ if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False)
+ {
+ aggregateRecords = reader.GetBoolean();
+ }
+ else if (reader.TokenType is JsonTokenType.StartObject)
+ {
+ // Handle object format: { "enabled": true, "query-timeout": 60 }
+ while (reader.Read())
+ {
+ if (reader.TokenType is JsonTokenType.EndObject)
+ {
+ break;
+ }
+
+ if (reader.TokenType is JsonTokenType.PropertyName)
+ {
+ string? subProperty = reader.GetString();
+ reader.Read();
+
+ switch (subProperty?.ToLowerInvariant())
+ {
+ case "enabled":
+ aggregateRecords = reader.GetBoolean();
+ break;
+ case "query-timeout":
+ if (reader.TokenType is not JsonTokenType.Null)
+ {
+ aggregateRecordsQueryTimeout = reader.GetInt32();
+ }
+
+ break;
+ default:
+ reader.Skip();
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ throw new JsonException("Property 'aggregate-records' must be a boolean or object value.");
+ }
+ }
+ // Handle other properties (must be boolean)
+ else if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False)
{
bool value = reader.GetBoolean();
@@ -110,7 +158,9 @@ internal class DmlToolsConfigConverter : JsonConverter
readRecords: readRecords,
updateRecord: updateRecord,
deleteRecord: deleteRecord,
- executeEntity: executeEntity);
+ executeEntity: executeEntity,
+ aggregateRecords: aggregateRecords,
+ aggregateRecordsQueryTimeout: aggregateRecordsQueryTimeout);
}
// For any other unexpected token type, return default (all enabled)
@@ -135,7 +185,9 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer
value.UserProvidedReadRecords ||
value.UserProvidedUpdateRecord ||
value.UserProvidedDeleteRecord ||
- value.UserProvidedExecuteEntity;
+ value.UserProvidedExecuteEntity ||
+ value.UserProvidedAggregateRecords ||
+ value.UserProvidedAggregateRecordsQueryTimeout;
// Only write the boolean value if it's provided by user
// This prevents writing "dml-tools": true when it's the default
@@ -181,6 +233,32 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer
writer.WriteBoolean("execute-entity", value.ExecuteEntity.Value);
}
+ if (value.UserProvidedAggregateRecords || value.UserProvidedAggregateRecordsQueryTimeout)
+ {
+ if (value.UserProvidedAggregateRecordsQueryTimeout)
+ {
+ // Write as object format: { "enabled": true, "query-timeout": 60 }
+ writer.WritePropertyName("aggregate-records");
+ writer.WriteStartObject();
+
+ if (value.AggregateRecords.HasValue)
+ {
+ writer.WriteBoolean("enabled", value.AggregateRecords.Value);
+ }
+
+ if (value.AggregateRecordsQueryTimeout.HasValue)
+ {
+ writer.WriteNumber("query-timeout", value.AggregateRecordsQueryTimeout.Value);
+ }
+
+ writer.WriteEndObject();
+ }
+ else if (value.AggregateRecords.HasValue)
+ {
+ writer.WriteBoolean("aggregate-records", value.AggregateRecords.Value);
+ }
+ }
+
writer.WriteEndObject();
}
}
diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs
index 2a09e9d53c..7d35b3018d 100644
--- a/src/Config/ObjectModel/DmlToolsConfig.cs
+++ b/src/Config/ObjectModel/DmlToolsConfig.cs
@@ -16,6 +16,16 @@ public record DmlToolsConfig
///
public const bool DEFAULT_ENABLED = true;
+ ///
+ /// Default query timeout in seconds for the aggregate-records tool.
+ ///
+ public const int DEFAULT_QUERY_TIMEOUT_SECONDS = 30;
+
+ ///
+ /// Maximum allowed query timeout in seconds for the aggregate-records tool.
+ ///
+ public const int MAX_QUERY_TIMEOUT_SECONDS = 600;
+
///
/// Indicates if all tools are enabled/disabled uniformly
///
@@ -51,6 +61,17 @@ public record DmlToolsConfig
///
public bool? ExecuteEntity { get; init; }
+ ///
+ /// Whether aggregate-records tool is enabled
+ ///
+ public bool? AggregateRecords { get; init; }
+
+ ///
+ /// Execution timeout in seconds for aggregate-records tool operations.
+ /// Default: 30 seconds.
+ ///
+ public int? AggregateRecordsQueryTimeout { get; init; }
+
[JsonConstructor]
public DmlToolsConfig(
bool? allToolsEnabled = null,
@@ -59,7 +80,9 @@ public DmlToolsConfig(
bool? readRecords = null,
bool? updateRecord = null,
bool? deleteRecord = null,
- bool? executeEntity = null)
+ bool? executeEntity = null,
+ bool? aggregateRecords = null,
+ int? aggregateRecordsQueryTimeout = null)
{
if (allToolsEnabled is not null)
{
@@ -75,6 +98,7 @@ public DmlToolsConfig(
UpdateRecord = updateRecord ?? toolDefault;
DeleteRecord = deleteRecord ?? toolDefault;
ExecuteEntity = executeEntity ?? toolDefault;
+ AggregateRecords = aggregateRecords ?? toolDefault;
}
else
{
@@ -87,6 +111,7 @@ public DmlToolsConfig(
UpdateRecord = updateRecord ?? DEFAULT_ENABLED;
DeleteRecord = deleteRecord ?? DEFAULT_ENABLED;
ExecuteEntity = executeEntity ?? DEFAULT_ENABLED;
+ AggregateRecords = aggregateRecords ?? DEFAULT_ENABLED;
}
// Track user-provided status - only true if the parameter was not null
@@ -96,6 +121,13 @@ public DmlToolsConfig(
UserProvidedUpdateRecord = updateRecord is not null;
UserProvidedDeleteRecord = deleteRecord is not null;
UserProvidedExecuteEntity = executeEntity is not null;
+ UserProvidedAggregateRecords = aggregateRecords is not null;
+
+ if (aggregateRecordsQueryTimeout is not null)
+ {
+ AggregateRecordsQueryTimeout = aggregateRecordsQueryTimeout;
+ UserProvidedAggregateRecordsQueryTimeout = true;
+ }
}
///
@@ -112,7 +144,9 @@ public static DmlToolsConfig FromBoolean(bool enabled)
readRecords: null,
updateRecord: null,
deleteRecord: null,
- executeEntity: null
+ executeEntity: null,
+ aggregateRecords: null,
+ aggregateRecordsQueryTimeout: null
);
}
@@ -127,7 +161,9 @@ public static DmlToolsConfig FromBoolean(bool enabled)
readRecords: null,
updateRecord: null,
deleteRecord: null,
- executeEntity: null
+ executeEntity: null,
+ aggregateRecords: null,
+ aggregateRecordsQueryTimeout: null
);
///
@@ -185,4 +221,26 @@ public static DmlToolsConfig FromBoolean(bool enabled)
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
[MemberNotNullWhen(true, nameof(ExecuteEntity))]
public bool UserProvidedExecuteEntity { get; init; } = false;
+
+ ///
+ /// Flag which informs CLI and JSON serializer whether to write aggregate-records
+ /// property/value to the runtime config file.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.Always)]
+ [MemberNotNullWhen(true, nameof(AggregateRecords))]
+ public bool UserProvidedAggregateRecords { get; init; } = false;
+
+ ///
+ /// Flag which informs CLI and JSON serializer whether to write aggregate-records.query-timeout
+ /// property/value to the runtime config file.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.Always)]
+ public bool UserProvidedAggregateRecordsQueryTimeout { get; init; } = false;
+
+ ///
+ /// Gets the effective query timeout in seconds for the aggregate-records tool,
+ /// using the default if not specified.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.Always)]
+ public int EffectiveAggregateRecordsQueryTimeoutSeconds => AggregateRecordsQueryTimeout ?? DEFAULT_QUERY_TIMEOUT_SECONDS;
}
diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs
index f3c9a4261f..de35bf8ed5 100644
--- a/src/Core/Configurations/RuntimeConfigValidator.cs
+++ b/src/Core/Configurations/RuntimeConfigValidator.cs
@@ -910,6 +910,17 @@ public void ValidateMcpUri(RuntimeConfig runtimeConfig)
statusCode: HttpStatusCode.ServiceUnavailable,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
}
+
+ // Validate aggregate-records query-timeout if provided
+ if (runtimeConfig.Runtime.Mcp.DmlTools?.AggregateRecordsQueryTimeout is not null &&
+ (runtimeConfig.Runtime.Mcp.DmlTools.AggregateRecordsQueryTimeout < 1 || runtimeConfig.Runtime.Mcp.DmlTools.AggregateRecordsQueryTimeout > DmlToolsConfig.MAX_QUERY_TIMEOUT_SECONDS))
+ {
+ HandleOrRecordException(new DataApiBuilderException(
+ message: $"Aggregate-records query-timeout must be between 1 and {DmlToolsConfig.MAX_QUERY_TIMEOUT_SECONDS} seconds. " +
+ $"Provided value: {runtimeConfig.Runtime.Mcp.DmlTools.AggregateRecordsQueryTimeout}.",
+ statusCode: HttpStatusCode.ServiceUnavailable,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
+ }
}
private void ValidateAuthenticationOptions(RuntimeConfig runtimeConfig)
diff --git a/src/Service.Tests/Mcp/AggregateRecordsToolTests.cs b/src/Service.Tests/Mcp/AggregateRecordsToolTests.cs
new file mode 100644
index 0000000000..2e94a7f3e1
--- /dev/null
+++ b/src/Service.Tests/Mcp/AggregateRecordsToolTests.cs
@@ -0,0 +1,615 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Auth;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Authorization;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Mcp.BuiltInTools;
+using Azure.DataApiBuilder.Mcp.Model;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Integration tests for the AggregateRecordsTool MCP tool.
+ /// Covers tool metadata/schema, configuration, input validation,
+ /// alias conventions, cursor/pagination, timeout/cancellation, spec examples,
+ /// and blog scenario validation.
+ ///
+ [TestClass]
+ public class AggregateRecordsToolTests
+ {
+ #region Tool Metadata Tests
+
+ [TestMethod]
+ public void GetToolMetadata_ReturnsCorrectNameAndType()
+ {
+ AggregateRecordsTool tool = new();
+ Tool metadata = tool.GetToolMetadata();
+
+ Assert.AreEqual("aggregate_records", metadata.Name);
+ Assert.AreEqual(McpEnums.ToolType.BuiltIn, tool.ToolType);
+ }
+
+ [TestMethod]
+ public void GetToolMetadata_HasRequiredSchemaProperties()
+ {
+ AggregateRecordsTool tool = new();
+ Tool metadata = tool.GetToolMetadata();
+
+ Assert.AreEqual(JsonValueKind.Object, metadata.InputSchema.ValueKind);
+ Assert.IsTrue(metadata.InputSchema.TryGetProperty("properties", out JsonElement properties));
+ Assert.IsTrue(metadata.InputSchema.TryGetProperty("required", out JsonElement required));
+
+ // Verify required fields
+ List requiredFields = new();
+ foreach (JsonElement r in required.EnumerateArray())
+ {
+ requiredFields.Add(r.GetString()!);
+ }
+
+ CollectionAssert.Contains(requiredFields, "entity");
+ CollectionAssert.Contains(requiredFields, "function");
+ CollectionAssert.Contains(requiredFields, "field");
+
+ // Verify all schema properties exist with correct types
+ AssertSchemaProperty(properties, "entity", "string");
+ AssertSchemaProperty(properties, "function", "string");
+ AssertSchemaProperty(properties, "field", "string");
+ AssertSchemaProperty(properties, "distinct", "boolean");
+ AssertSchemaProperty(properties, "filter", "string");
+ AssertSchemaProperty(properties, "groupby", "array");
+ AssertSchemaProperty(properties, "orderby", "string");
+ AssertSchemaProperty(properties, "having", "object");
+ AssertSchemaProperty(properties, "first", "integer");
+ AssertSchemaProperty(properties, "after", "string");
+ }
+
+ [TestMethod]
+ public void GetToolMetadata_DescriptionDocumentsWorkflowAndAlias()
+ {
+ AggregateRecordsTool tool = new();
+ Tool metadata = tool.GetToolMetadata();
+
+ Assert.IsTrue(metadata.Description!.Contains("describe_entities"),
+ "Tool description must instruct models to call describe_entities first.");
+ Assert.IsTrue(metadata.Description.Contains("1)"),
+ "Tool description must use numbered workflow steps.");
+ Assert.IsTrue(metadata.Description.Contains("{function}_{field}"),
+ "Tool description must document the alias pattern '{function}_{field}'.");
+ Assert.IsTrue(metadata.Description.Contains("'count'"),
+ "Tool description must mention the special 'count' alias for count(*).");
+ }
+
+ #endregion
+
+ #region Configuration Tests
+
+ [DataTestMethod]
+ [DataRow(false, true, DisplayName = "Runtime-level disabled")]
+ [DataRow(true, false, DisplayName = "Entity-level DML disabled")]
+ public async Task AggregateRecords_Disabled_ReturnsToolDisabledError(bool runtimeEnabled, bool entityDmlEnabled)
+ {
+ RuntimeConfig config = entityDmlEnabled
+ ? CreateConfig(aggregateRecordsEnabled: runtimeEnabled)
+ : CreateConfigWithEntityDmlDisabled();
+ IServiceProvider sp = CreateServiceProvider(config);
+
+ CallToolResult result = await ExecuteToolAsync(sp, "{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\"}");
+
+ AssertErrorResult(result, "ToolDisabled");
+ }
+
+ #endregion
+
+ #region Input Validation Tests - Missing/Invalid Arguments
+
+ [DataTestMethod]
+ [DataRow("{\"function\": \"count\", \"field\": \"*\"}", null, DisplayName = "Missing entity")]
+ [DataRow("{\"entity\": \"Book\", \"field\": \"*\"}", null, DisplayName = "Missing function")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\"}", null, DisplayName = "Missing field")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"median\", \"field\": \"price\"}", "median", DisplayName = "Invalid function 'median'")]
+ public async Task AggregateRecords_MissingOrInvalidRequiredArgs_ReturnsInvalidArguments(string json, string expectedInMessage)
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+
+ CallToolResult result = await ExecuteToolAsync(sp, json);
+
+ string message = AssertErrorResult(result, "InvalidArguments");
+ if (!string.IsNullOrEmpty(expectedInMessage))
+ {
+ Assert.IsTrue(message.Contains(expectedInMessage),
+ $"Error message must contain '{expectedInMessage}'. Actual: '{message}'");
+ }
+ }
+
+ [TestMethod]
+ public async Task AggregateRecords_NullArguments_ReturnsInvalidArguments()
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+ AggregateRecordsTool tool = new();
+
+ CallToolResult result = await tool.ExecuteAsync(null, sp, CancellationToken.None);
+
+ AssertErrorResult(result, "InvalidArguments");
+ }
+
+ #endregion
+
+ #region Input Validation Tests - Field/Function Compatibility
+
+ [DataTestMethod]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"avg\", \"field\": \"*\"}", "count",
+ DisplayName = "Star field with avg (must mention count)")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"distinct\": true}", "DISTINCT",
+ DisplayName = "Distinct with count(*)")]
+ public async Task AggregateRecords_InvalidFieldFunctionCombination_ReturnsInvalidArguments(string json, string expectedInMessage)
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+
+ CallToolResult result = await ExecuteToolAsync(sp, json);
+
+ string message = AssertErrorResult(result, "InvalidArguments");
+ Assert.IsTrue(message.Contains(expectedInMessage),
+ $"Error message must contain '{expectedInMessage}'. Actual: '{message}'");
+ }
+
+ #endregion
+
+ #region Input Validation Tests - GroupBy Dependencies
+
+ [DataTestMethod]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"orderby\": \"desc\"}", "groupby",
+ DisplayName = "Orderby without groupby")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"having\": {\"gt\": 5}}", "groupby",
+ DisplayName = "Having without groupby")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"groupby\": [\"title\"], \"orderby\": \"ascending\"}", "'asc' or 'desc'",
+ DisplayName = "Invalid orderby value")]
+ public async Task AggregateRecords_GroupByDependencyViolation_ReturnsInvalidArguments(string json, string expectedInMessage)
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+
+ CallToolResult result = await ExecuteToolAsync(sp, json);
+
+ string message = AssertErrorResult(result, "InvalidArguments");
+ Assert.IsTrue(message.Contains(expectedInMessage),
+ $"Error message must contain '{expectedInMessage}'. Actual: '{message}'");
+ }
+
+ #endregion
+
+ #region Input Validation Tests - Having Clause
+
+ [DataTestMethod]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"groupby\": [\"title\"], \"having\": {\"between\": 5}}",
+ "between", DisplayName = "Unsupported having operator")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"groupby\": [\"title\"], \"having\": {\"eq\": \"ten\"}}",
+ "numeric", DisplayName = "Non-numeric having scalar")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"groupby\": [\"title\"], \"having\": {\"in\": [5, \"abc\"]}}",
+ "numeric", DisplayName = "Non-numeric value in having.in array")]
+ [DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"groupby\": [\"title\"], \"having\": {\"in\": 5}}",
+ "numeric array", DisplayName = "Having.in not an array")]
+ public async Task AggregateRecords_InvalidHaving_ReturnsInvalidArguments(string json, string expectedInMessage)
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+
+ CallToolResult result = await ExecuteToolAsync(sp, json);
+
+ string message = AssertErrorResult(result, "InvalidArguments");
+ Assert.IsTrue(message.Contains(expectedInMessage),
+ $"Error message must contain '{expectedInMessage}'. Actual: '{message}'");
+ }
+
+ #endregion
+
+ #region Alias Convention Tests
+
+ [DataTestMethod]
+ [DataRow("count", "*", "count", DisplayName = "count(*) → 'count'")]
+ [DataRow("count", "supplierId", "count_supplierId", DisplayName = "count(supplierId)")]
+ [DataRow("avg", "unitPrice", "avg_unitPrice", DisplayName = "avg(unitPrice)")]
+ [DataRow("sum", "unitPrice", "sum_unitPrice", DisplayName = "sum(unitPrice)")]
+ [DataRow("min", "price", "min_price", DisplayName = "min(price)")]
+ [DataRow("max", "price", "max_price", DisplayName = "max(price)")]
+ public void ComputeAlias_ReturnsExpectedAlias(string function, string field, string expectedAlias)
+ {
+ Assert.AreEqual(expectedAlias, AggregateRecordsTool.ComputeAlias(function, field));
+ }
+
+ #endregion
+
+ #region Cursor and Pagination Tests
+
+ [DataTestMethod]
+ [DataRow(null, 0, DisplayName = "null → 0")]
+ [DataRow("", 0, DisplayName = "empty → 0")]
+ [DataRow(" ", 0, DisplayName = "whitespace → 0")]
+ public void DecodeCursorOffset_InvalidCursor_ReturnsZero(string cursor, int expected)
+ {
+ Assert.AreEqual(expected, AggregateRecordsTool.DecodeCursorOffset(cursor));
+ }
+
+ [TestMethod]
+ public void DecodeCursorOffset_InvalidBase64_ReturnsZero()
+ {
+ Assert.AreEqual(0, AggregateRecordsTool.DecodeCursorOffset("not-valid-base64!!!"));
+ }
+
+ [DataTestMethod]
+ [DataRow("abc", 0, DisplayName = "non-numeric → 0")]
+ [DataRow("0", 0, DisplayName = "zero → 0")]
+ [DataRow("5", 5, DisplayName = "5 round-trip")]
+ [DataRow("15", 15, DisplayName = "15 round-trip")]
+ [DataRow("1000", 1000, DisplayName = "1000 round-trip")]
+ public void DecodeCursorOffset_Base64Encoded_ReturnsExpectedOffset(string rawValue, int expectedOffset)
+ {
+ string cursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(rawValue));
+ Assert.AreEqual(expectedOffset, AggregateRecordsTool.DecodeCursorOffset(cursor));
+ }
+
+ #endregion
+
+ #region Timeout and Cancellation Tests
+
+ [TestMethod]
+ public async Task AggregateRecords_OperationCanceled_ReturnsExplicitCanceledMessage()
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+ AggregateRecordsTool tool = new();
+
+ CancellationTokenSource cts = new();
+ cts.Cancel();
+
+ JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\"}");
+ CallToolResult result = await tool.ExecuteAsync(args, sp, cts.Token);
+
+ Assert.IsTrue(result.IsError == true);
+ JsonElement content = ParseContent(result);
+ JsonElement error = content.GetProperty("error");
+
+ Assert.AreEqual("OperationCanceled", error.GetProperty("type").GetString());
+ string message = error.GetProperty("message").GetString()!;
+
+ AssertContainsAll(message,
+ ("NOT a tool error", "Message must state this is NOT a tool error."),
+ ("canceled", "Message must mention the operation was canceled."),
+ ("retry", "Message must tell the model it can retry."));
+ }
+
+ [DataTestMethod]
+ [DataRow("Product", DisplayName = "Product entity")]
+ [DataRow("HugeTransactionLog", DisplayName = "HugeTransactionLog entity")]
+ public void BuildTimeoutErrorMessage_ContainsGuidance(string entityName)
+ {
+ string message = AggregateRecordsTool.BuildTimeoutErrorMessage(entityName);
+
+ AssertContainsAll(message,
+ (entityName, "Must include entity name."),
+ ("NOT a tool error", "Must state this is NOT a tool error."),
+ ("database did not respond", "Must explain the cause."),
+ ("large datasets", "Must mention large datasets."),
+ ("filter", "Must suggest filter."),
+ ("groupby", "Must suggest reducing groupby."),
+ ("first", "Must suggest pagination."));
+ }
+
+ [DataTestMethod]
+ [DataRow("LargeProductCatalog", DisplayName = "LargeProductCatalog entity")]
+ public void BuildOperationCanceledErrorMessage_ContainsGuidance(string entityName)
+ {
+ string message = AggregateRecordsTool.BuildOperationCanceledErrorMessage(entityName);
+
+ AssertContainsAll(message,
+ (entityName, "Must include entity name."),
+ ("No results were returned", "Must state no results."));
+ }
+
+ #endregion
+
+ #region Spec Example Tests - Alias Validation
+
+ ///
+ /// Validates the alias convention for all 13 spec examples.
+ /// Examples that compute count(*) expect "count"; all others expect "function_field".
+ ///
+ [DataTestMethod]
+ // Ex 1, 3, 6, 8, 11, 12: COUNT(*) → "count"
+ [DataRow("count", "*", "count", DisplayName = "Spec 01/03/06/08/11/12: count(*) → 'count'")]
+ // Ex 2, 7, 9, 13: AVG(unitPrice) → "avg_unitPrice"
+ [DataRow("avg", "unitPrice", "avg_unitPrice", DisplayName = "Spec 02/07/09/13: avg(unitPrice)")]
+ // Ex 4, 10: SUM(unitPrice) → "sum_unitPrice"
+ [DataRow("sum", "unitPrice", "sum_unitPrice", DisplayName = "Spec 04/10: sum(unitPrice)")]
+ // Ex 5: COUNT(supplierId) → "count_supplierId"
+ [DataRow("count", "supplierId", "count_supplierId", DisplayName = "Spec 05: count(supplierId)")]
+ public void SpecExamples_AliasConvention_IsCorrect(string function, string field, string expectedAlias)
+ {
+ Assert.AreEqual(expectedAlias, AggregateRecordsTool.ComputeAlias(function, field));
+ }
+
+ ///
+ /// Spec Example 11-12: Cursor offset for first-page starts at 0, continuation decodes correctly.
+ ///
+ [TestMethod]
+ public void SpecExample_PaginationCursor_DecodesCorrectly()
+ {
+ // First page: null cursor → offset 0
+ Assert.AreEqual(0, AggregateRecordsTool.DecodeCursorOffset(null));
+
+ // Continuation: cursor encoding "5" → offset 5
+ string cursor = Convert.ToBase64String(Encoding.UTF8.GetBytes("5"));
+ Assert.AreEqual(5, AggregateRecordsTool.DecodeCursorOffset(cursor));
+ }
+
+ #endregion
+
+ #region Blog Scenario Tests
+
+ ///
+ /// Validates that exact JSON payloads from the DAB MCP blog pass input validation.
+ /// The tool will fail at metadata resolution (no real DB) but must NOT return "InvalidArguments".
+ ///
+ [DataTestMethod]
+ [DataRow(
+ @"{""entity"":""Book"",""function"":""sum"",""field"":""totalRevenue"",""filter"":""isActive eq true and orderDate ge 2025-01-01"",""groupby"":[""customerId"",""customerName""],""orderby"":""desc"",""first"":1}",
+ "sum", "totalRevenue",
+ DisplayName = "Blog 1: Strategic customer importance")]
+ [DataRow(
+ @"{""entity"":""Book"",""function"":""sum"",""field"":""totalRevenue"",""filter"":""isActive eq true and inStock gt 0 and orderDate ge 2025-01-01"",""groupby"":[""productId"",""productName""],""orderby"":""asc"",""first"":1}",
+ "sum", "totalRevenue",
+ DisplayName = "Blog 2: Product discontinuation")]
+ [DataRow(
+ @"{""entity"":""Book"",""function"":""avg"",""field"":""quarterlyRevenue"",""filter"":""fiscalYear eq 2025"",""groupby"":[""region""],""having"":{""gt"":2000000},""orderby"":""desc""}",
+ "avg", "quarterlyRevenue",
+ DisplayName = "Blog 3: Quarterly performance")]
+ [DataRow(
+ @"{""entity"":""Book"",""function"":""sum"",""field"":""totalRevenue"",""filter"":""isActive eq true and customerType eq 'Retail' and (region eq 'Midwest' or region eq 'Southwest')"",""groupby"":[""region"",""customerTier""],""having"":{""gt"":5000000},""orderby"":""desc""}",
+ "sum", "totalRevenue",
+ DisplayName = "Blog 4: Revenue concentration")]
+ [DataRow(
+ @"{""entity"":""Book"",""function"":""sum"",""field"":""onHandValue"",""filter"":""discontinued eq true and onHandValue gt 0"",""groupby"":[""productLine"",""warehouseRegion""],""having"":{""gt"":2500000},""orderby"":""desc""}",
+ "sum", "onHandValue",
+ DisplayName = "Blog 5: Risk exposure")]
+ public async Task BlogScenario_PassesInputValidation(string json, string function, string field)
+ {
+ IServiceProvider sp = CreateDefaultServiceProvider();
+
+ CallToolResult result = await ExecuteToolAsync(sp, json);
+
+ Assert.IsTrue(result.IsError == true);
+ JsonElement content = ParseContent(result);
+ string errorType = content.GetProperty("error").GetProperty("type").GetString()!;
+ Assert.AreNotEqual("InvalidArguments", errorType,
+ $"Blog scenario JSON must pass input validation. Got error: {errorType}");
+
+ // Verify alias convention
+ string expectedAlias = $"{function}_{field}";
+ Assert.AreEqual(expectedAlias, AggregateRecordsTool.ComputeAlias(function, field));
+ }
+
+ #endregion
+
+ #region FieldNotFound Error Helper Tests
+
+ [DataTestMethod]
+ [DataRow("Product", "badField", "field", DisplayName = "field parameter")]
+ [DataRow("Product", "invalidCol", "groupby", DisplayName = "groupby parameter")]
+ public void FieldNotFound_ReturnsCorrectErrorWithGuidance(string entity, string fieldName, string paramName)
+ {
+ CallToolResult result = McpErrorHelpers.FieldNotFound("aggregate_records", entity, fieldName, paramName, null);
+
+ Assert.IsTrue(result.IsError == true);
+ JsonElement content = ParseContent(result);
+ JsonElement error = content.GetProperty("error");
+
+ Assert.AreEqual("FieldNotFound", error.GetProperty("type").GetString());
+ string message = error.GetProperty("message").GetString()!;
+
+ AssertContainsAll(message,
+ (fieldName, "Must include the invalid field name."),
+ (entity, "Must include the entity name."),
+ (paramName, "Must identify which parameter was invalid."),
+ ("describe_entities", "Must guide the model to call describe_entities."));
+ }
+
+ #endregion
+
+ #region Reusable Assertion Helpers
+
+ ///
+ /// Parses the JSON content from a .
+ ///
+ private static JsonElement ParseContent(CallToolResult result)
+ {
+ TextContentBlock firstContent = (TextContentBlock)result.Content[0];
+ return JsonDocument.Parse(firstContent.Text).RootElement;
+ }
+
+ ///
+ /// Asserts that the result is an error with the expected error type.
+ /// Returns the error message for further assertions.
+ ///
+ private static string AssertErrorResult(CallToolResult result, string expectedErrorType)
+ {
+ Assert.IsTrue(result.IsError == true, "Result should be an error.");
+ JsonElement content = ParseContent(result);
+ Assert.IsTrue(content.TryGetProperty("error", out JsonElement error), "Content must have an 'error' property.");
+ Assert.AreEqual(expectedErrorType, error.GetProperty("type").GetString(),
+ $"Expected error type '{expectedErrorType}'.");
+ return error.TryGetProperty("message", out JsonElement msg) ? msg.GetString() ?? string.Empty : string.Empty;
+ }
+
+ ///
+ /// Asserts the schema property exists with the given type.
+ ///
+ private static void AssertSchemaProperty(JsonElement properties, string propertyName, string expectedType)
+ {
+ Assert.IsTrue(properties.TryGetProperty(propertyName, out JsonElement prop),
+ $"Schema must include '{propertyName}' property.");
+ Assert.AreEqual(expectedType, prop.GetProperty("type").GetString(),
+ $"Schema property '{propertyName}' must have type '{expectedType}'.");
+ }
+
+ ///
+ /// Asserts that the given text contains all expected substrings, with per-assertion failure messages.
+ ///
+ private static void AssertContainsAll(string text, params (string expected, string failMessage)[] checks)
+ {
+ Assert.IsNotNull(text);
+ foreach (var (expected, failMessage) in checks)
+ {
+ Assert.IsTrue(text.Contains(expected), failMessage);
+ }
+ }
+
+ #endregion
+
+ #region Reusable Execution Helpers
+
+ ///
+ /// Executes the AggregateRecordsTool with the given JSON arguments.
+ ///
+ private static async Task ExecuteToolAsync(IServiceProvider sp, string json)
+ {
+ AggregateRecordsTool tool = new();
+ JsonDocument args = JsonDocument.Parse(json);
+ return await tool.ExecuteAsync(args, sp, CancellationToken.None);
+ }
+
+ ///
+ /// Creates a default service provider with aggregate_records enabled.
+ ///
+ private static IServiceProvider CreateDefaultServiceProvider()
+ {
+ return CreateServiceProvider(CreateConfig());
+ }
+
+ #endregion
+
+ #region Test Infrastructure
+
+ private static RuntimeConfig CreateConfig(bool aggregateRecordsEnabled = true)
+ {
+ Dictionary entities = new()
+ {
+ ["Book"] = new Entity(
+ Source: new("books", EntitySourceType.Table, null, null),
+ GraphQL: new("Book", "Books"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
+ new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
+ }) },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null
+ )
+ };
+
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: new(
+ Enabled: true,
+ Path: "/mcp",
+ DmlTools: new(
+ describeEntities: true,
+ readRecords: true,
+ createRecord: true,
+ updateRecord: true,
+ deleteRecord: true,
+ executeEntity: true,
+ aggregateRecords: aggregateRecordsEnabled
+ )
+ ),
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
+ ),
+ Entities: new(entities)
+ );
+ }
+
+ private static RuntimeConfig CreateConfigWithEntityDmlDisabled()
+ {
+ Dictionary entities = new()
+ {
+ ["Book"] = new Entity(
+ Source: new("books", EntitySourceType.Table, null, null),
+ GraphQL: new("Book", "Books"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
+ new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
+ }) },
+ Mappings: null,
+ Relationships: null,
+ Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false)
+ )
+ };
+
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: new(
+ Enabled: true,
+ Path: "/mcp",
+ DmlTools: new(
+ describeEntities: true,
+ readRecords: true,
+ createRecord: true,
+ updateRecord: true,
+ deleteRecord: true,
+ executeEntity: true,
+ aggregateRecords: true
+ )
+ ),
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
+ ),
+ Entities: new(entities)
+ );
+ }
+
+ private static IServiceProvider CreateServiceProvider(RuntimeConfig config)
+ {
+ ServiceCollection services = new();
+
+ RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+ services.AddSingleton(configProvider);
+
+ Mock mockAuthResolver = new();
+ mockAuthResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true);
+ services.AddSingleton(mockAuthResolver.Object);
+
+ Mock mockHttpContext = new();
+ Mock mockRequest = new();
+ mockRequest.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous");
+ mockHttpContext.Setup(x => x.Request).Returns(mockRequest.Object);
+
+ Mock mockHttpContextAccessor = new();
+ mockHttpContextAccessor.Setup(x => x.HttpContext).Returns(mockHttpContext.Object);
+ services.AddSingleton(mockHttpContextAccessor.Object);
+
+ services.AddLogging();
+
+ return services.BuildServiceProvider();
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs
index 278bc95cfb..aa3ae118dc 100644
--- a/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs
+++ b/src/Service.Tests/Mcp/EntityLevelDmlToolConfigurationTests.cs
@@ -51,12 +51,20 @@ public class EntityLevelDmlToolConfigurationTests
[DataRow("UpdateRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}, \"fields\": {\"title\": \"Updated\"}}", false, DisplayName = "UpdateRecord respects entity-level DmlToolEnabled=false")]
[DataRow("DeleteRecord", "{\"entity\": \"Book\", \"keys\": {\"id\": 1}}", false, DisplayName = "DeleteRecord respects entity-level DmlToolEnabled=false")]
[DataRow("ExecuteEntity", "{\"entity\": \"GetBook\"}", true, DisplayName = "ExecuteEntity respects entity-level DmlToolEnabled=false")]
+ [DataRow("AggregateRecords", "{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\"}", false, DisplayName = "AggregateRecords respects entity-level DmlToolEnabled=false")]
public async Task DmlTool_RespectsEntityLevelDmlToolDisabled(string toolType, string jsonArguments, bool isStoredProcedure)
{
// Arrange
RuntimeConfig config = isStoredProcedure
- ? CreateConfigWithDmlToolDisabledStoredProcedure()
- : CreateConfigWithDmlToolDisabledEntity();
+ ? CreateConfig(
+ entityName: "GetBook", sourceObject: "get_book",
+ sourceType: EntitySourceType.StoredProcedure,
+ mcpOptions: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false),
+ actions: new[] { EntityActionOperation.Execute })
+ : CreateConfig(
+ mcpOptions: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false),
+ actions: new[] { EntityActionOperation.Read, EntityActionOperation.Create,
+ EntityActionOperation.Update, EntityActionOperation.Delete });
IServiceProvider serviceProvider = CreateServiceProvider(config);
IMcpTool tool = CreateTool(toolType);
@@ -87,8 +95,8 @@ public async Task ReadRecords_WorksWhenNotDisabledAtEntityLevel(string scenario,
{
// Arrange
RuntimeConfig config = useMcpConfig
- ? CreateConfigWithDmlToolEnabledEntity()
- : CreateConfigWithEntityWithoutMcpConfig();
+ ? CreateConfig(mcpOptions: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true))
+ : CreateConfig();
IServiceProvider serviceProvider = CreateServiceProvider(config);
ReadRecordsTool tool = new();
@@ -123,7 +131,9 @@ public async Task ReadRecords_WorksWhenNotDisabledAtEntityLevel(string scenario,
public async Task ReadRecords_RuntimeDisabledTakesPrecedenceOverEntityEnabled()
{
// Arrange - Runtime has readRecords=false, but entity has DmlToolEnabled=true
- RuntimeConfig config = CreateConfigWithRuntimeDisabledButEntityEnabled();
+ RuntimeConfig config = CreateConfig(
+ mcpOptions: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true),
+ readRecordsEnabled: false);
IServiceProvider serviceProvider = CreateServiceProvider(config);
ReadRecordsTool tool = new();
@@ -159,7 +169,11 @@ public async Task ReadRecords_RuntimeDisabledTakesPrecedenceOverEntityEnabled()
public async Task DynamicCustomTool_RespectsCustomToolDisabled()
{
// Arrange - Create a stored procedure entity with CustomToolEnabled=false
- RuntimeConfig config = CreateConfigWithCustomToolDisabled();
+ RuntimeConfig config = CreateConfig(
+ entityName: "GetBook", sourceObject: "get_book",
+ sourceType: EntitySourceType.StoredProcedure,
+ mcpOptions: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true),
+ actions: new[] { EntityActionOperation.Execute });
IServiceProvider serviceProvider = CreateServiceProvider(config);
// Create the DynamicCustomTool with the entity that has CustomToolEnabled initially true
@@ -217,7 +231,7 @@ public async Task DmlTool_AllowsTablesAndViews(string toolType, string sourceTyp
// Arrange
RuntimeConfig config = sourceType == "View"
? CreateConfigWithViewEntity()
- : CreateConfigWithDmlToolEnabledEntity();
+ : CreateConfig();
IServiceProvider serviceProvider = CreateServiceProvider(config);
IMcpTool tool = CreateTool(toolType);
@@ -306,258 +320,43 @@ private static IMcpTool CreateTool(string toolType)
"UpdateRecord" => new UpdateRecordTool(),
"DeleteRecord" => new DeleteRecordTool(),
"ExecuteEntity" => new ExecuteEntityTool(),
+ "AggregateRecords" => new AggregateRecordsTool(),
_ => throw new ArgumentException($"Unknown tool type: {toolType}", nameof(toolType))
};
}
///
- /// Creates a runtime config with a table entity that has DmlToolEnabled=false.
+ /// Unified config factory. Creates a RuntimeConfig with a single entity.
+ /// Callers specify only the parameters that differ from their test scenario.
///
- private static RuntimeConfig CreateConfigWithDmlToolDisabledEntity()
+ /// Entity key name (default: "Book").
+ /// Database object (default: "books").
+ /// Table or StoredProcedure (default: Table).
+ /// Entity-level MCP options, or null for no MCP config.
+ /// Entity permissions. Defaults to Read-only.
+ /// Runtime-level readRecords flag (default: true).
+ private static RuntimeConfig CreateConfig(
+ string entityName = "Book",
+ string sourceObject = "books",
+ EntitySourceType sourceType = EntitySourceType.Table,
+ EntityMcpOptions mcpOptions = null,
+ EntityActionOperation[] actions = null,
+ bool readRecordsEnabled = true)
{
- Dictionary entities = new()
- {
- ["Book"] = new Entity(
- Source: new("books", EntitySourceType.Table, null, null),
- GraphQL: new("Book", "Books"),
- Fields: null,
- Rest: new(Enabled: true),
- Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
- new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null),
- new EntityAction(Action: EntityActionOperation.Create, Fields: null, Policy: null),
- new EntityAction(Action: EntityActionOperation.Update, Fields: null, Policy: null),
- new EntityAction(Action: EntityActionOperation.Delete, Fields: null, Policy: null)
- }) },
- Mappings: null,
- Relationships: null,
- Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false)
- )
- };
-
- return new RuntimeConfig(
- Schema: "test-schema",
- DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
- Runtime: new(
- Rest: new(),
- GraphQL: new(),
- Mcp: new(
- Enabled: true,
- Path: "/mcp",
- DmlTools: new(
- describeEntities: true,
- readRecords: true,
- createRecord: true,
- updateRecord: true,
- deleteRecord: true,
- executeEntity: true
- )
- ),
- Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
- ),
- Entities: new(entities)
- );
- }
-
- ///
- /// Creates a runtime config with a stored procedure that has DmlToolEnabled=false.
- ///
- private static RuntimeConfig CreateConfigWithDmlToolDisabledStoredProcedure()
- {
- Dictionary entities = new()
- {
- ["GetBook"] = new Entity(
- Source: new("get_book", EntitySourceType.StoredProcedure, null, null),
- GraphQL: new("GetBook", "GetBook"),
- Fields: null,
- Rest: new(Enabled: true),
- Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
- new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null)
- }) },
- Mappings: null,
- Relationships: null,
- Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: false)
- )
- };
-
- return new RuntimeConfig(
- Schema: "test-schema",
- DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
- Runtime: new(
- Rest: new(),
- GraphQL: new(),
- Mcp: new(
- Enabled: true,
- Path: "/mcp",
- DmlTools: new(
- describeEntities: true,
- readRecords: true,
- createRecord: true,
- updateRecord: true,
- deleteRecord: true,
- executeEntity: true
- )
- ),
- Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
- ),
- Entities: new(entities)
- );
- }
-
- ///
- /// Creates a runtime config with a table entity that has DmlToolEnabled=true.
- ///
- private static RuntimeConfig CreateConfigWithDmlToolEnabledEntity()
- {
- Dictionary entities = new()
- {
- ["Book"] = new Entity(
- Source: new("books", EntitySourceType.Table, null, null),
- GraphQL: new("Book", "Books"),
- Fields: null,
- Rest: new(Enabled: true),
- Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
- new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
- }) },
- Mappings: null,
- Relationships: null,
- Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true)
- )
- };
-
- return new RuntimeConfig(
- Schema: "test-schema",
- DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
- Runtime: new(
- Rest: new(),
- GraphQL: new(),
- Mcp: new(
- Enabled: true,
- Path: "/mcp",
- DmlTools: new(
- describeEntities: true,
- readRecords: true,
- createRecord: true,
- updateRecord: true,
- deleteRecord: true,
- executeEntity: true
- )
- ),
- Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
- ),
- Entities: new(entities)
- );
- }
+ actions ??= new[] { EntityActionOperation.Read };
- ///
- /// Creates a runtime config with a table entity that has no MCP configuration.
- ///
- private static RuntimeConfig CreateConfigWithEntityWithoutMcpConfig()
- {
Dictionary entities = new()
{
- ["Book"] = new Entity(
- Source: new("books", EntitySourceType.Table, null, null),
- GraphQL: new("Book", "Books"),
+ [entityName] = new Entity(
+ Source: new(sourceObject, sourceType, null, null),
+ GraphQL: new(entityName, entityName == "Book" ? "Books" : entityName),
Fields: null,
Rest: new(Enabled: true),
- Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
- new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
- }) },
+ Permissions: new[] { new EntityPermission(Role: "anonymous",
+ Actions: Array.ConvertAll(actions, a => new EntityAction(Action: a, Fields: null, Policy: null))) },
Mappings: null,
Relationships: null,
- Mcp: null
- )
- };
-
- return new RuntimeConfig(
- Schema: "test-schema",
- DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
- Runtime: new(
- Rest: new(),
- GraphQL: new(),
- Mcp: new(
- Enabled: true,
- Path: "/mcp",
- DmlTools: new(
- describeEntities: true,
- readRecords: true,
- createRecord: true,
- updateRecord: true,
- deleteRecord: true,
- executeEntity: true
- )
- ),
- Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
- ),
- Entities: new(entities)
- );
- }
-
- ///
- /// Creates a runtime config with a stored procedure that has CustomToolEnabled=false.
- /// Used to test DynamicCustomTool runtime validation.
- ///
- private static RuntimeConfig CreateConfigWithCustomToolDisabled()
- {
- Dictionary entities = new()
- {
- ["GetBook"] = new Entity(
- Source: new("get_book", EntitySourceType.StoredProcedure, null, null),
- GraphQL: new("GetBook", "GetBook"),
- Fields: null,
- Rest: new(Enabled: true),
- Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
- new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null)
- }) },
- Mappings: null,
- Relationships: null,
- Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true)
- )
- };
-
- return new RuntimeConfig(
- Schema: "test-schema",
- DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
- Runtime: new(
- Rest: new(),
- GraphQL: new(),
- Mcp: new(
- Enabled: true,
- Path: "/mcp",
- DmlTools: new(
- describeEntities: true,
- readRecords: true,
- createRecord: true,
- updateRecord: true,
- deleteRecord: true,
- executeEntity: true
- )
- ),
- Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
- ),
- Entities: new(entities)
- );
- }
-
- ///
- /// Creates a runtime config where runtime-level readRecords is disabled,
- /// but entity-level DmlToolEnabled is true. This tests precedence behavior.
- ///
- private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled()
- {
- Dictionary entities = new()
- {
- ["Book"] = new Entity(
- Source: new("books", EntitySourceType.Table, null, null),
- GraphQL: new("Book", "Books"),
- Fields: null,
- Rest: new(Enabled: true),
- Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] {
- new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
- }) },
- Mappings: null,
- Relationships: null,
- Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: true)
+ Mcp: mcpOptions
)
};
@@ -572,7 +371,7 @@ private static RuntimeConfig CreateConfigWithRuntimeDisabledButEntityEnabled()
Path: "/mcp",
DmlTools: new(
describeEntities: true,
- readRecords: false, // Runtime-level DISABLED
+ readRecords: readRecordsEnabled,
createRecord: true,
updateRecord: true,
deleteRecord: true,
diff --git a/src/Service.Tests/Mcp/McpQueryTimeoutTests.cs b/src/Service.Tests/Mcp/McpQueryTimeoutTests.cs
new file mode 100644
index 0000000000..835527865d
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpQueryTimeoutTests.cs
@@ -0,0 +1,289 @@
+// 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.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Mcp.Model;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Tests for the aggregate-records query-timeout configuration property.
+ /// Verifies:
+ /// - Default value of 30 seconds when not configured
+ /// - Custom value overrides default
+ /// - DmlToolsConfig properties reflect configured timeout
+ /// - JSON serialization/deserialization of aggregate-records with query-timeout
+ ///
+ [TestClass]
+ public class McpQueryTimeoutTests
+ {
+ #region Custom Value Tests
+
+ [DataTestMethod]
+ [DataRow(1, DisplayName = "1 second")]
+ [DataRow(60, DisplayName = "60 seconds")]
+ [DataRow(120, DisplayName = "120 seconds")]
+ public void DmlToolsConfig_CustomTimeout_ReturnsConfiguredValue(int timeoutSeconds)
+ {
+ DmlToolsConfig config = new(aggregateRecordsQueryTimeout: timeoutSeconds);
+ Assert.AreEqual(timeoutSeconds, config.EffectiveAggregateRecordsQueryTimeoutSeconds);
+ Assert.IsTrue(config.UserProvidedAggregateRecordsQueryTimeout);
+ }
+
+ [TestMethod]
+ public void RuntimeConfig_AggregateRecordsQueryTimeout_ExposedInConfig()
+ {
+ RuntimeConfig config = CreateConfig(queryTimeout: 45);
+ Assert.AreEqual(45, config.Runtime?.Mcp?.DmlTools?.AggregateRecordsQueryTimeout);
+ Assert.AreEqual(45, config.Runtime?.Mcp?.DmlTools?.EffectiveAggregateRecordsQueryTimeoutSeconds);
+ }
+
+ [TestMethod]
+ public void RuntimeConfig_AggregateRecordsQueryTimeout_DefaultWhenNotSet()
+ {
+ RuntimeConfig config = CreateConfig();
+ Assert.IsNull(config.Runtime?.Mcp?.DmlTools?.AggregateRecordsQueryTimeout);
+ Assert.AreEqual(DmlToolsConfig.DEFAULT_QUERY_TIMEOUT_SECONDS, config.Runtime?.Mcp?.DmlTools?.EffectiveAggregateRecordsQueryTimeoutSeconds);
+ }
+
+ #endregion
+
+ #region Telemetry No-Timeout Tests
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetry_CompletesSuccessfully_NoTimeout()
+ {
+ // After moving timeout to AggregateRecordsTool, ExecuteWithTelemetryAsync should
+ // no longer apply any timeout wrapping. A fast tool should complete regardless of config.
+ RuntimeConfig config = CreateConfig(queryTimeout: 1);
+ IServiceProvider sp = CreateServiceProviderWithConfig(config);
+ IMcpTool tool = new ImmediateCompletionTool();
+
+ CallToolResult result = await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "test_tool", null, sp, CancellationToken.None);
+
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.IsError != true, "Tool result should not be an error");
+ }
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetry_DoesNotApplyTimeout_AfterRefactor()
+ {
+ // Verify that McpTelemetryHelper no longer applies timeout wrapping.
+ // A slow tool should NOT timeout in the telemetry layer (timeout is now tool-specific).
+ RuntimeConfig config = CreateConfig(queryTimeout: 1);
+ IServiceProvider sp = CreateServiceProviderWithConfig(config);
+
+ // Use a short-delay tool (2 seconds) with 1-second query-timeout.
+ // If McpTelemetryHelper still applied timeout, this would throw TimeoutException.
+ IMcpTool tool = new SlowTool(delaySeconds: 2);
+
+ // Should complete without timeout since McpTelemetryHelper no longer wraps with timeout
+ CallToolResult result = await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "test_tool", null, sp, CancellationToken.None);
+
+ Assert.IsNotNull(result);
+ Assert.IsTrue(result.IsError != true, "Tool should complete without timeout in telemetry layer");
+ }
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetry_ClientCancellation_PropagatesAsCancellation()
+ {
+ // Client cancellation should still propagate as OperationCanceledException.
+ RuntimeConfig config = CreateConfig(queryTimeout: 30);
+ IServiceProvider sp = CreateServiceProviderWithConfig(config);
+ IMcpTool tool = new SlowTool(delaySeconds: 30);
+
+ using CancellationTokenSource cts = new();
+ cts.Cancel(); // Cancel immediately
+
+ try
+ {
+ await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "test_tool", null, sp, cts.Token);
+ Assert.Fail("Expected OperationCanceledException or subclass to be thrown");
+ }
+ catch (TimeoutException)
+ {
+ Assert.Fail("Client cancellation should NOT be converted to TimeoutException");
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: client-initiated cancellation propagates as OperationCanceledException
+ }
+ }
+
+ #endregion
+
+ #region JSON Serialization Tests
+
+ [TestMethod]
+ public void DmlToolsConfig_Serialization_IncludesQueryTimeout_WhenUserProvided()
+ {
+ // When aggregate-records has a query-timeout, it should serialize as object format
+ DmlToolsConfig dmlTools = new(aggregateRecords: true, aggregateRecordsQueryTimeout: 45);
+ McpRuntimeOptions options = new(Enabled: true, DmlTools: dmlTools);
+ JsonSerializerOptions serializerOptions = RuntimeConfigLoader.GetSerializationOptions();
+ string json = JsonSerializer.Serialize(options, serializerOptions);
+ Assert.IsTrue(json.Contains("\"query-timeout\""), $"Expected 'query-timeout' in JSON. Got: {json}");
+ Assert.IsTrue(json.Contains("45"), $"Expected timeout value 45 in JSON. Got: {json}");
+ }
+
+ [TestMethod]
+ public void DmlToolsConfig_Deserialization_ReadsQueryTimeout_ObjectFormat()
+ {
+ string json = @"{""enabled"": true, ""dml-tools"": { ""aggregate-records"": { ""enabled"": true, ""query-timeout"": 60 } }}";
+ JsonSerializerOptions serializerOptions = RuntimeConfigLoader.GetSerializationOptions();
+ McpRuntimeOptions options = JsonSerializer.Deserialize(json, serializerOptions);
+ Assert.IsNotNull(options);
+ Assert.IsNotNull(options.DmlTools);
+ Assert.AreEqual(true, options.DmlTools.AggregateRecords);
+ Assert.AreEqual(60, options.DmlTools.AggregateRecordsQueryTimeout);
+ Assert.AreEqual(60, options.DmlTools.EffectiveAggregateRecordsQueryTimeoutSeconds);
+ }
+
+ [TestMethod]
+ public void DmlToolsConfig_Deserialization_AggregateRecordsBoolean_NoQueryTimeout()
+ {
+ string json = @"{""enabled"": true, ""dml-tools"": { ""aggregate-records"": true }}";
+ JsonSerializerOptions serializerOptions = RuntimeConfigLoader.GetSerializationOptions();
+ McpRuntimeOptions options = JsonSerializer.Deserialize(json, serializerOptions);
+ Assert.IsNotNull(options);
+ Assert.IsNotNull(options.DmlTools);
+ Assert.AreEqual(true, options.DmlTools.AggregateRecords);
+ Assert.IsNull(options.DmlTools.AggregateRecordsQueryTimeout);
+ Assert.AreEqual(DmlToolsConfig.DEFAULT_QUERY_TIMEOUT_SECONDS, options.DmlTools.EffectiveAggregateRecordsQueryTimeoutSeconds);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static RuntimeConfig CreateConfig(int? queryTimeout = null)
+ {
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: new(
+ Enabled: true,
+ Path: "/mcp",
+ DmlTools: new(
+ describeEntities: true,
+ readRecords: true,
+ createRecord: true,
+ updateRecord: true,
+ deleteRecord: true,
+ executeEntity: true,
+ aggregateRecords: true,
+ aggregateRecordsQueryTimeout: queryTimeout
+ )
+ ),
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)
+ ),
+ Entities: new(new Dictionary())
+ );
+ }
+
+ private static IServiceProvider CreateServiceProviderWithConfig(RuntimeConfig config)
+ {
+ ServiceCollection services = new();
+ RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+ services.AddSingleton(configProvider);
+ services.AddLogging();
+ return services.BuildServiceProvider();
+ }
+
+ ///
+ /// A mock tool that completes immediately with a success result.
+ ///
+ private class ImmediateCompletionTool : IMcpTool
+ {
+ public ToolType ToolType { get; } = ToolType.BuiltIn;
+
+ public Tool GetToolMetadata()
+ {
+ using JsonDocument doc = JsonDocument.Parse("{\"type\": \"object\"}");
+ return new Tool
+ {
+ Name = "test_tool",
+ Description = "A test tool that completes immediately",
+ InputSchema = doc.RootElement.Clone()
+ };
+ }
+
+ public Task ExecuteAsync(
+ JsonDocument arguments,
+ IServiceProvider serviceProvider,
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(new CallToolResult
+ {
+ Content = new List
+ {
+ new TextContentBlock { Text = "{\"result\": \"success\"}" }
+ }
+ });
+ }
+ }
+
+ ///
+ /// A mock tool that delays for a specified duration, respecting cancellation.
+ /// Used to test cancellation behavior.
+ ///
+ private class SlowTool : IMcpTool
+ {
+ private readonly int _delaySeconds;
+
+ public SlowTool(int delaySeconds, ToolType toolType = ToolType.BuiltIn)
+ {
+ _delaySeconds = delaySeconds;
+ ToolType = toolType;
+ }
+
+ public ToolType ToolType { get; }
+
+ public Tool GetToolMetadata()
+ {
+ using JsonDocument doc = JsonDocument.Parse("{\"type\": \"object\"}");
+ return new Tool
+ {
+ Name = "slow_tool",
+ Description = "A test tool that takes a long time",
+ InputSchema = doc.RootElement.Clone()
+ };
+ }
+
+ public async Task ExecuteAsync(
+ JsonDocument arguments,
+ IServiceProvider serviceProvider,
+ CancellationToken cancellationToken = default)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(_delaySeconds), cancellationToken);
+ return new CallToolResult
+ {
+ Content = new List
+ {
+ new TextContentBlock { Text = "{\"result\": \"completed\"}" }
+ }
+ };
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt
index 9279da9d59..c9def099f9 100644
--- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt
+++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt
@@ -28,13 +28,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt
index 35fd562c87..d80506e102 100644
--- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt
+++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt
@@ -32,13 +32,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt
index 1490309ece..e7f312ef48 100644
--- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt
+++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt
index ceba40ae63..5dcef0dcdb 100644
--- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt
+++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt
@@ -24,13 +24,17 @@
UpdateRecord: true,
DeleteRecord: true,
ExecuteEntity: true,
+ AggregateRecords: true,
UserProvidedAllTools: false,
UserProvidedDescribeEntities: false,
UserProvidedCreateRecord: false,
UserProvidedReadRecords: false,
UserProvidedUpdateRecord: false,
UserProvidedDeleteRecord: false,
- UserProvidedExecuteEntity: false
+ UserProvidedExecuteEntity: false,
+ UserProvidedAggregateRecords: false,
+ UserProvidedAggregateRecordsQueryTimeout: false,
+ EffectiveAggregateRecordsQueryTimeoutSeconds: 30
}
},
Host: {
diff --git a/src/Service.Tests/UnitTests/AggregateRecordsToolTests.cs b/src/Service.Tests/UnitTests/AggregateRecordsToolTests.cs
new file mode 100644
index 0000000000..da53e0adcc
--- /dev/null
+++ b/src/Service.Tests/UnitTests/AggregateRecordsToolTests.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Text;
+using Azure.DataApiBuilder.Mcp.BuiltInTools;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for AggregateRecordsTool helper methods that supplement
+ /// the integration tests in Service.Tests/Mcp/AggregateRecordsToolTests.cs.
+ /// Covers edge cases (blog aliases, negative offsets) not present in the Mcp test suite.
+ ///
+ [TestClass]
+ public class AggregateRecordsToolTests
+ {
+ #region ComputeAlias - Blog scenario aliases (not covered in Mcp tests)
+
+ [DataTestMethod]
+ [DataRow("sum", "totalRevenue", "sum_totalRevenue", DisplayName = "Blog: sum(totalRevenue)")]
+ [DataRow("avg", "quarterlyRevenue", "avg_quarterlyRevenue", DisplayName = "Blog: avg(quarterlyRevenue)")]
+ [DataRow("sum", "onHandValue", "sum_onHandValue", DisplayName = "Blog: sum(onHandValue)")]
+ public void ComputeAlias_BlogScenarios_ReturnsExpectedAlias(string function, string field, string expectedAlias)
+ {
+ Assert.AreEqual(expectedAlias, AggregateRecordsTool.ComputeAlias(function, field));
+ }
+
+ #endregion
+
+ #region DecodeCursorOffset - Negative offset edge case (not covered in Mcp tests)
+
+ [TestMethod]
+ public void DecodeCursorOffset_NegativeOffset_ReturnsZero()
+ {
+ string cursor = Convert.ToBase64String(Encoding.UTF8.GetBytes("-5"));
+ Assert.AreEqual(0, AggregateRecordsTool.DecodeCursorOffset(cursor));
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/UnitTests/McpTelemetryTests.cs b/src/Service.Tests/UnitTests/McpTelemetryTests.cs
index 9a8130f012..d82e531519 100644
--- a/src/Service.Tests/UnitTests/McpTelemetryTests.cs
+++ b/src/Service.Tests/UnitTests/McpTelemetryTests.cs
@@ -17,7 +17,6 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ModelContextProtocol.Protocol;
using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
-
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
{
///
@@ -337,6 +336,20 @@ public async Task ExecuteWithTelemetryAsync_RecordsExceptionAndRethrows_WhenTool
Assert.IsNotNull(exceptionEvent, "Exception event should be recorded");
}
+ ///
+ /// Test that aggregate_records tool name maps to "aggregate" operation.
+ ///
+ [TestMethod]
+ public void InferOperationFromTool_AggregateRecords_ReturnsAggregate()
+ {
+ CallToolResult dummyResult = CreateToolResult("ok");
+ IMcpTool tool = new MockMcpTool(dummyResult, ToolType.BuiltIn);
+
+ string operation = McpTelemetryHelper.InferOperationFromTool(tool, "aggregate_records");
+
+ Assert.AreEqual("aggregate", operation);
+ }
+
#endregion
#region Test Mocks