Skip to content

Commit e2028cf

Browse files
Merge branch 'main' into dev/aaronburtle/fix-schema-validation-no-entities
2 parents 1fa3dc0 + b84418d commit e2028cf

10 files changed

Lines changed: 254 additions & 32 deletions

File tree

schemas/dab.draft.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@
280280
"description": "Allow enabling/disabling MCP requests for all entities.",
281281
"default": true
282282
},
283+
"description": {
284+
"type": "string",
285+
"description": "Description of the MCP server, exposed as the 'instructions' field in the MCP initialize response to provide behavioral context to MCP clients and agents."
286+
},
283287
"dml-tools": {
284288
"description": "Configuration for MCP Data Manipulation Language (DML) tools. Set to true/false to enable/disable all tools, or use an object to configure individual tools.",
285289
"oneOf": [

src/Azure.DataApiBuilder.Mcp/BuiltInTools/AggregateRecordsTool.cs

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,24 @@ public class AggregateRecordsTool : IMcpTool
4545
{
4646
Name = "aggregate_records",
4747
Description = "Computes aggregations (count, avg, sum, min, max) on entity data. "
48-
+ "WORKFLOW: 1) Call describe_entities first to get entity names and field names. "
49-
+ "2) Call this tool with entity, function, and field from step 1. "
50-
+ "RULES: field '*' is ONLY valid with count. "
51-
+ "orderby, having, first, and after ONLY apply when groupby is provided. "
52-
+ "RESPONSE: Result is aliased as '{function}_{field}' (e.g. avg_unitPrice). "
53-
+ "For count(*), the alias is 'count'. "
54-
+ "With groupby and first, response includes items, endCursor, and hasNextPage for pagination.",
48+
+ "Prerequisite: 1) Call describe_entities in the current session to discover valid entity names and field names. "
49+
+ "2) Call this tool using only names returned by that call. "
50+
+ "Do not use entity or field names from memory, prior conversations, or assumptions. "
51+
+ "count supports field * to count all rows. "
52+
+ "avg, sum, min, and max must reference a field with a numeric data type as returned by describe_entities. "
53+
+ "distinct removes duplicate values before aggregation and is not valid with field *. "
54+
+ "filter applies an OData WHERE clause before aggregation. Supported operators: eq, ne, gt, ge, lt, le, and, or, not. "
55+
+ "String pattern operators such as startswith, endswith, contains, and regex are NOT supported. "
56+
+ "Use groupby to compute aggregated rows per group. All groupby fields must exist on the entity and must be discovered through describe_entities. "
57+
+ "Grouped results include the groupby fields and the aggregated value. "
58+
+ "orderby, having, first, and after apply only when groupby is present. "
59+
+ "orderby controls the sort direction of groups based on the aggregation result value. Allowed values are 'asc' and 'desc'; if omitted, 'desc' is used. It cannot sort by entity fields. "
60+
+ "having filters groups based on the aggregated value produced by the function. Supported operators: eq, neq, gt, gte, lt, lte, in. "
61+
+ "When groupby and first are used together, the response includes items, endCursor, and hasNextPage. "
62+
+ "To retrieve the next page, pass the returned endCursor value as the after parameter in a subsequent call with the same inputs. "
63+
+ "Aggregated values are aliased as {function}_{field} (example: avg_unitPrice). "
64+
+ "count(*) is the sole exception to this pattern and returns the alias 'count', not count_*. "
65+
+ "If the call fails due to an unrecognized entity or field name, call describe_entities again to verify valid names, then retry with corrected inputs.",
5566
InputSchema = JsonSerializer.Deserialize<JsonElement>(
5667
@"{
5768
""type"": ""object"",
@@ -67,32 +78,32 @@ public class AggregateRecordsTool : IMcpTool
6778
},
6879
""field"": {
6980
""type"": ""string"",
70-
""description"": ""Field name to aggregate, or '*' with count to count all rows.""
81+
""description"": ""Field name to aggregate, or * with count to count all rows.""
7182
},
7283
""distinct"": {
7384
""type"": ""boolean"",
74-
""description"": ""Remove duplicate values before aggregating. Not valid with field '*'."",
85+
""description"": ""Remove duplicate values before aggregating. Not valid with field *."",
7586
""default"": false
7687
},
7788
""filter"": {
7889
""type"": ""string"",
79-
""description"": ""OData WHERE clause applied before aggregating. Operators: eq, ne, gt, ge, lt, le, and, or, not. Example: 'unitPrice lt 10'."",
90+
""description"": ""OData WHERE clause applied before aggregating. Operators: eq, ne, gt, ge, lt, le, and, or, not. String pattern operators such as startswith, endswith, contains, and regex are not supported. Example: unitPrice lt 10."",
8091
""default"": """"
8192
},
8293
""groupby"": {
8394
""type"": ""array"",
8495
""items"": { ""type"": ""string"" },
85-
""description"": ""Field names to group by. Each unique combination produces one aggregated row. Enables orderby, having, first, and after."",
96+
""description"": ""Field names to group by. Each unique combination produces one aggregated row that includes the group fields and the aggregated value. Enables orderby, having, first, and after."",
8697
""default"": []
8798
},
8899
""orderby"": {
89100
""type"": ""string"",
90101
""enum"": [""asc"", ""desc""],
91-
""description"": ""Sort direction for grouped results by the aggregated value. Only applies when groupby is provided; ignored otherwise.""
102+
""description"": ""Sort direction for grouped results by the aggregated value (ascending or descending). Requires groupby. Cannot sort by entity fields. If omitted, the default sort direction is used.""
92103
},
93104
""having"": {
94105
""type"": ""object"",
95-
""description"": ""Filter groups by the aggregated value (HAVING clause). Requires groupby. Multiple operators are AND-ed."",
106+
""description"": ""Filter groups by the aggregated value (HAVING clause). Supported operators: eq, neq, gt, gte, lt, lte, in. Requires groupby. Multiple operators are AND-ed."",
96107
""properties"": {
97108
""eq"": { ""type"": ""number"", ""description"": ""Equals."" },
98109
""neq"": { ""type"": ""number"", ""description"": ""Not equals."" },
@@ -109,7 +120,7 @@ public class AggregateRecordsTool : IMcpTool
109120
},
110121
""first"": {
111122
""type"": ""integer"",
112-
""description"": ""Max grouped results to return. Requires groupby. Enables paginated response with endCursor and hasNextPage."",
123+
""description"": ""Max grouped results to return. Requires groupby. Enables cursor pagination with endCursor and hasNextPage."",
113124
""minimum"": 1
114125
},
115126
""after"": {

src/Cli/ConfigGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3288,7 +3288,7 @@ public static bool TrySimulateAutoentities(AutoConfigSimulateOptions options, Fi
32883288

32893289
if (runtimeConfig.DataSource.DatabaseType != DatabaseType.MSSQL)
32903290
{
3291-
_logger.LogError("Autoentities simulation is only supported for MSSQL databases. Current database type: {DatabaseType}.", runtimeConfig.DataSource.DatabaseType);
3291+
_logger.LogError("The autoentities simulation is only supported for MSSQL databases. Current database type: {DatabaseType}.", runtimeConfig.DataSource.DatabaseType);
32923292
return false;
32933293
}
32943294

@@ -3380,7 +3380,7 @@ public static bool TrySimulateAutoentities(AutoConfigSimulateOptions options, Fi
33803380
/// <param name="results">The simulation results keyed by filter (definition) name.</param>
33813381
private static void WriteSimulationResultsToConsole(Dictionary<string, List<(string EntityName, string SchemaName, string ObjectName)>> results)
33823382
{
3383-
Console.WriteLine("AutoEntities Simulation Results");
3383+
Console.WriteLine("Autoentities Simulation Results");
33843384
Console.WriteLine();
33853385

33863386
foreach ((string filterName, List<(string EntityName, string SchemaName, string ObjectName)> matches) in results)

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ public string GetDataSourceNameFromAutoentityName(string autoentityName)
507507
if (!_autoentityNameToDataSourceName.TryGetValue(autoentityName, out string? autoentityDataSource))
508508
{
509509
throw new DataApiBuilderException(
510-
message: $"{autoentityName} is not a valid autoentity.",
510+
message: $"'{autoentityName}' is not a valid autoentities definition.",
511511
statusCode: HttpStatusCode.NotFound,
512512
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
513513
}

src/Core/Resolvers/QueryExecutor.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,11 @@ public virtual TConnection CreateConnection(string dataSourceName)
293293
TResult? result = default(TResult);
294294
try
295295
{
296-
using DbDataReader dbDataReader = ConfigProvider.GetConfig().MaxResponseSizeLogicEnabled() ?
297-
await cmd.ExecuteReaderAsync(CommandBehavior.SequentialAccess) : await cmd.ExecuteReaderAsync(CommandBehavior.CloseConnection);
296+
CommandBehavior commandBehavior = ConfigProvider.GetConfig().MaxResponseSizeLogicEnabled() ? CommandBehavior.SequentialAccess : CommandBehavior.CloseConnection;
297+
// CancellationToken is passed to ExecuteReaderAsync to ensure that if the client times out while the query is executing, the execution will be cancelled and resources will be freed up.
298+
CancellationToken cancellationToken = httpContext?.RequestAborted ?? CancellationToken.None;
299+
using DbDataReader dbDataReader = await cmd.ExecuteReaderAsync(commandBehavior, cancellationToken);
300+
298301
if (dataReaderHandler is not null && dbDataReader is not null)
299302
{
300303
result = await dataReaderHandler(dbDataReader, args);

src/Core/Services/MetadataProviders/MsSqlMetadataProvider.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
305305
foreach ((string autoentityName, Autoentity autoentity) in autoentities)
306306
{
307307
int addedEntities = 0;
308-
JsonArray? resultArray = await QueryAutoentitiesAsync(autoentity);
308+
JsonArray? resultArray = await QueryAutoentitiesAsync(autoentityName, autoentity);
309309
if (resultArray is null)
310310
{
311311
continue;
@@ -316,7 +316,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
316316
if (resultObject is null)
317317
{
318318
throw new DataApiBuilderException(
319-
message: $"Cannot create new entity from autoentity pattern due to an internal error.",
319+
message: $"Cannot create new entity from autoentities definition '{autoentityName}' due to an internal error.",
320320
statusCode: HttpStatusCode.InternalServerError,
321321
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
322322
}
@@ -329,7 +329,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
329329

330330
if (string.IsNullOrWhiteSpace(entityName) || string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(schemaName))
331331
{
332-
_logger.LogError("Skipping autoentity generation: entity_name or object is null or empty for autoentity pattern '{AutoentityName}'.", autoentityName);
332+
_logger.LogError("Skipping autoentity generation: 'entity_name', 'object', or 'schema' is null or empty for autoentities definition '{autoentityName}'.", autoentityName);
333333
continue;
334334
}
335335

@@ -357,7 +357,7 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
357357
if (!entities.TryAdd(entityName, generatedEntity) || !runtimeConfig.TryAddGeneratedAutoentityNameToDataSourceName(entityName, autoentityName))
358358
{
359359
throw new DataApiBuilderException(
360-
message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentity pattern with definition-name '{autoentityName}'.",
360+
message: $"Entity with name '{entityName}' already exists. Cannot create new entity from autoentities definition '{autoentityName}'.",
361361
statusCode: HttpStatusCode.BadRequest,
362362
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
363363
}
@@ -376,14 +376,14 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
376376

377377
if (addedEntities == 0)
378378
{
379-
_logger.LogWarning("No new entities were generated from the autoentity {autoentityName} defined in the configuration.", autoentityName);
379+
_logger.LogWarning("No new entities were generated from the autoentities definition '{autoentityName}'.", autoentityName);
380380
}
381381
}
382382

383383
_runtimeConfigProvider.AddMergedEntitiesToConfig(entities);
384384
}
385385

386-
public async Task<JsonArray?> QueryAutoentitiesAsync(Autoentity autoentity)
386+
public async Task<JsonArray?> QueryAutoentitiesAsync(string autoentityName, Autoentity autoentity)
387387
{
388388
string include = string.Join(",", autoentity.Patterns.Include);
389389
string exclude = string.Join(",", autoentity.Patterns.Exclude);
@@ -396,10 +396,10 @@ protected override async Task GenerateAutoentitiesIntoEntities(IReadOnlyDictiona
396396
{ $"{BaseQueryStructure.PARAM_NAME_PREFIX}name_pattern", new(namePattern, null, SqlDbType.NVarChar) }
397397
};
398398

399-
_logger.LogInformation("Query for Autoentities is being executed with the following parameters.");
400-
_logger.LogInformation($"Autoentities include pattern: {include}");
401-
_logger.LogInformation($"Autoentities exclude pattern: {exclude}");
402-
_logger.LogInformation($"Autoentities name pattern: {namePattern}");
399+
_logger.LogDebug("Query for autoentities is being executed with the following parameters.");
400+
_logger.LogDebug("The autoentities definition '{autoentityName}' include pattern: {include}", autoentityName, include);
401+
_logger.LogDebug("The autoentities definition '{autoentityName}' exclude pattern: {exclude}", autoentityName, exclude);
402+
_logger.LogDebug("The autoentities definition '{autoentityName}' name pattern: {namePattern}", autoentityName, namePattern);
403403

404404
JsonArray? resultArray = await QueryExecutor.ExecuteQueryAsync(
405405
sqltext: getAutoentitiesQuery,

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ private void GenerateDatabaseObjectForEntities()
717717
/// </summary>
718718
protected virtual Task GenerateAutoentitiesIntoEntities(IReadOnlyDictionary<string, Autoentity>? autoentities)
719719
{
720-
throw new NotSupportedException($"{GetType().Name} does not support Autoentities yet.");
720+
throw new NotSupportedException($"{GetType().Name} does not support autoentities yet.");
721721
}
722722

723723
/// <summary>

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5608,7 +5608,7 @@ public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities, int
56085608
/// <returns></returns>
56095609
[TestCategory(TestCategory.MSSQL)]
56105610
[DataTestMethod]
5611-
[DataRow("publishers", "uniqueSingularPublisher", "uniquePluralPublishers", "/unique/publisher", "Entity with name 'publishers' already exists. Cannot create new entity from autoentity pattern with definition-name 'PublisherAutoEntity'.", DisplayName = "Autoentities fail due to entity name")]
5611+
[DataRow("publishers", "uniqueSingularPublisher", "uniquePluralPublishers", "/unique/publisher", "Entity with name 'publishers' already exists. Cannot create new entity from autoentities definition 'PublisherAutoEntity'.", DisplayName = "Autoentities fail due to entity name")]
56125612
[DataRow("UniquePublisher", "publishers", "uniquePluralPublishers", "/unique/publisher", "Entity publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql singular type")]
56135613
[DataRow("UniquePublisher", "uniqueSingularPublisher", "publishers", "/unique/publisher", "Entity publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql plural type")]
56145614
[DataRow("UniquePublisher", "uniqueSingularPublisher", "uniquePluralPublishers", "/publishers", "The rest path: publishers specified for entity: publishers is already used by another entity.", DisplayName = "Autoentities fail due to rest path")]

src/Service.Tests/UnitTests/SqlMetadataProviderUnitTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ public async Task CheckAutoentitiesQuery(string[] include, string[] exclude, str
634634

635635
// Act
636636
MsSqlMetadataProvider metadataProvider = (MsSqlMetadataProvider)_sqlMetadataProvider;
637-
JsonArray resultArray = await metadataProvider.QueryAutoentitiesAsync(autoentity);
637+
JsonArray resultArray = await metadataProvider.QueryAutoentitiesAsync("autoentity", autoentity);
638638

639639
// Assert
640640
Assert.IsNotNull(resultArray);

0 commit comments

Comments
 (0)