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