Skip to content

Commit 79f7e36

Browse files
CopilotJerryNixon
andcommitted
Add max query-timeout validation and refactor MCP tests
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
1 parent d340cb4 commit 79f7e36

7 files changed

Lines changed: 144 additions & 116 deletions

File tree

schemas/dab.draft.schema.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,9 +277,10 @@
277277
},
278278
"query-timeout": {
279279
"type": "integer",
280-
"description": "Execution timeout in seconds for MCP tool operations. Applies to all MCP tools.",
280+
"description": "Execution timeout in seconds for MCP tool operations. Applies to all MCP tools. Maximum: 600 seconds.",
281281
"default": 30,
282-
"minimum": 1
282+
"minimum": 1,
283+
"maximum": 600
283284
},
284285
"dml-tools": {
285286
"oneOf": [

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public class AggregateRecordsTool : IMcpTool
3636
public ToolType ToolType { get; } = ToolType.BuiltIn;
3737

3838
private static readonly HashSet<string> _validFunctions = new(StringComparer.OrdinalIgnoreCase) { "count", "avg", "sum", "min", "max" };
39+
private static readonly HashSet<string> _validHavingOperators = new(StringComparer.OrdinalIgnoreCase) { "eq", "neq", "gt", "gte", "lt", "lte", "in" };
3940

4041
public Tool GetToolMetadata()
4142
{
@@ -247,18 +248,44 @@ public async Task<CallToolResult> ExecuteAsync(
247248
havingOps = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
248249
foreach (JsonProperty prop in havingEl.EnumerateObject())
249250
{
251+
if (!_validHavingOperators.Contains(prop.Name))
252+
{
253+
return McpResponseBuilder.BuildErrorResult(
254+
toolName,
255+
"InvalidArguments",
256+
$"Unsupported having operator '{prop.Name}'. Supported operators are: eq, neq, gt, gte, lt, lte, in.",
257+
logger);
258+
}
259+
250260
if (prop.Name.Equals("in", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.Array)
251261
{
252262
havingIn = new List<double>();
253263
foreach (JsonElement item in prop.Value.EnumerateArray())
254264
{
265+
if (item.ValueKind != JsonValueKind.Number)
266+
{
267+
return McpResponseBuilder.BuildErrorResult(
268+
toolName,
269+
"InvalidArguments",
270+
"Argument 'having.in' must contain only numeric values.",
271+
logger);
272+
}
273+
255274
havingIn.Add(item.GetDouble());
256275
}
257276
}
258277
else if (prop.Value.ValueKind == JsonValueKind.Number)
259278
{
260279
havingOps[prop.Name] = prop.Value.GetDouble();
261280
}
281+
else
282+
{
283+
return McpResponseBuilder.BuildErrorResult(
284+
toolName,
285+
"InvalidArguments",
286+
$"Argument 'having.{prop.Name}' must be a numeric value.",
287+
logger);
288+
}
262289
}
263290
}
264291

@@ -374,7 +401,7 @@ public async Task<CallToolResult> ExecuteAsync(
374401
// Apply pagination if first is specified with groupby
375402
if (first.HasValue && groupby.Count > 0)
376403
{
377-
PaginationResult paginatedResult = ApplyPagination(aggregatedResults, first.Value, after);
404+
PaginationResult paginatedResult = ApplyPagination(aggregatedResults, first.Value, after, logger);
378405
return McpResponseBuilder.BuildSuccessResult(
379406
new Dictionary<string, object?>
380407
{
@@ -578,7 +605,8 @@ internal sealed class PaginationResult
578605
internal static PaginationResult ApplyPagination(
579606
List<Dictionary<string, object?>> allResults,
580607
int first,
581-
string? after)
608+
string? after,
609+
ILogger? logger = null)
582610
{
583611
int startIndex = 0;
584612

@@ -595,7 +623,7 @@ internal static PaginationResult ApplyPagination(
595623
}
596624
catch (FormatException)
597625
{
598-
// Invalid cursor format; start from beginning
626+
logger?.LogWarning("Invalid pagination cursor provided to aggregate_records; starting from beginning.");
599627
}
600628
}
601629

src/Azure.DataApiBuilder.Mcp/Utils/McpTelemetryHelper.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ public static async Task<CallToolResult> ExecuteWithTelemetryAsync(
6969
timeoutSeconds = config.Runtime?.Mcp?.EffectiveQueryTimeoutSeconds ?? McpRuntimeOptions.DEFAULT_QUERY_TIMEOUT_SECONDS;
7070
}
7171

72+
if (timeoutSeconds < 1 || timeoutSeconds > McpRuntimeOptions.MAX_QUERY_TIMEOUT_SECONDS)
73+
{
74+
throw new InvalidOperationException(
75+
$"MCP query-timeout must be within 1 and {McpRuntimeOptions.MAX_QUERY_TIMEOUT_SECONDS} seconds. " +
76+
$"Provided value: {timeoutSeconds}.");
77+
}
78+
7279
// Wrap tool execution with the configured timeout using a linked CancellationTokenSource.
7380
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
7481
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));

src/Config/ObjectModel/McpRuntimeOptions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public record McpRuntimeOptions
1111
{
1212
public const string DEFAULT_PATH = "/mcp";
1313
public const int DEFAULT_QUERY_TIMEOUT_SECONDS = 30;
14+
public const int MAX_QUERY_TIMEOUT_SECONDS = 600;
1415

1516
/// <summary>
1617
/// Whether MCP endpoints are enabled
@@ -41,7 +42,7 @@ public record McpRuntimeOptions
4142
/// Execution timeout in seconds for MCP tool operations.
4243
/// This timeout wraps the entire tool execution including database queries.
4344
/// It applies to all MCP tools, not just aggregation.
44-
/// Default: 30 seconds.
45+
/// Default: 30 seconds. Maximum: 600 seconds.
4546
/// </summary>
4647
[JsonPropertyName("query-timeout")]
4748
public int? QueryTimeout { get; init; }

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -916,11 +916,13 @@ public void ValidateMcpUri(RuntimeConfig runtimeConfig)
916916
}
917917

918918
// Validate query-timeout if provided
919-
if (runtimeConfig.Runtime.Mcp.QueryTimeout is not null && runtimeConfig.Runtime.Mcp.QueryTimeout < 1)
919+
if (runtimeConfig.Runtime.Mcp.QueryTimeout is not null &&
920+
(runtimeConfig.Runtime.Mcp.QueryTimeout < 1 ||
921+
runtimeConfig.Runtime.Mcp.QueryTimeout > McpRuntimeOptions.MAX_QUERY_TIMEOUT_SECONDS))
920922
{
921923
HandleOrRecordException(new DataApiBuilderException(
922-
message: "MCP query-timeout must be a positive integer (>= 1 second). " +
923-
$"Provided value: {runtimeConfig.Runtime.Mcp.QueryTimeout}.",
924+
message: $"MCP query-timeout must be within 1 and {McpRuntimeOptions.MAX_QUERY_TIMEOUT_SECONDS} seconds. " +
925+
$"Provided value: {runtimeConfig.Runtime.Mcp.QueryTimeout}.",
924926
statusCode: HttpStatusCode.ServiceUnavailable,
925927
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
926928
}

src/Service.Tests/Mcp/AggregateRecordsToolTests.cs

Lines changed: 31 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -83,30 +83,15 @@ public void GetToolMetadata_HasInputSchema()
8383

8484
#region Configuration Tests
8585

86-
[TestMethod]
87-
public async Task AggregateRecords_DisabledAtRuntimeLevel_ReturnsToolDisabledError()
88-
{
89-
RuntimeConfig config = CreateConfig(aggregateRecordsEnabled: false);
90-
IServiceProvider sp = CreateServiceProvider(config);
91-
AggregateRecordsTool tool = new();
92-
93-
JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\"}");
94-
CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None);
95-
96-
Assert.IsTrue(result.IsError == true);
97-
JsonElement content = ParseContent(result);
98-
AssertToolDisabledError(content);
99-
}
100-
101-
[TestMethod]
102-
public async Task AggregateRecords_DisabledAtEntityLevel_ReturnsToolDisabledError()
86+
[DataTestMethod]
87+
[DataRow(false, false, DisplayName = "Disabled at runtime level returns ToolDisabled")]
88+
[DataRow(true, true, DisplayName = "Disabled at entity level returns ToolDisabled")]
89+
public async Task AggregateRecords_DisabledConfigurations_ReturnToolDisabledError(bool runtimeEnabled, bool disableAtEntityLevel)
10390
{
104-
RuntimeConfig config = CreateConfigWithEntityDmlDisabled();
105-
IServiceProvider sp = CreateServiceProvider(config);
106-
AggregateRecordsTool tool = new();
107-
108-
JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\"}");
109-
CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None);
91+
RuntimeConfig config = disableAtEntityLevel
92+
? CreateConfigWithEntityDmlDisabled()
93+
: CreateConfig(aggregateRecordsEnabled: runtimeEnabled);
94+
CallToolResult result = await ExecuteWithConfigAsync(config, "{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\"}");
11095

11196
Assert.IsTrue(result.IsError == true);
11297
JsonElement content = ParseContent(result);
@@ -117,75 +102,28 @@ public async Task AggregateRecords_DisabledAtEntityLevel_ReturnsToolDisabledErro
117102

118103
#region Input Validation Tests
119104

120-
[TestMethod]
121-
public async Task AggregateRecords_NullArguments_ReturnsInvalidArguments()
105+
[DataTestMethod]
106+
[DataRow(null, null, DisplayName = "Null arguments return InvalidArguments")]
107+
[DataRow("{\"function\": \"count\", \"field\": \"*\"}", null, DisplayName = "Missing entity returns InvalidArguments")]
108+
[DataRow("{\"entity\": \"Book\", \"field\": \"*\"}", null, DisplayName = "Missing function returns InvalidArguments")]
109+
[DataRow("{\"entity\": \"Book\", \"function\": \"count\"}", null, DisplayName = "Missing field returns InvalidArguments")]
110+
[DataRow("{\"entity\": \"Book\", \"function\": \"median\", \"field\": \"price\"}", "median", DisplayName = "Invalid function returns InvalidArguments")]
111+
[DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"having\": { \"between\": 10 }}", "Unsupported having operator", DisplayName = "Unsupported having operator returns InvalidArguments")]
112+
[DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"having\": { \"eq\": \"10\" }}", "must be a numeric value", DisplayName = "Non-numeric having value returns InvalidArguments")]
113+
[DataRow("{\"entity\": \"Book\", \"function\": \"count\", \"field\": \"*\", \"having\": { \"in\": [1, \"x\"] }}", "must contain only numeric values", DisplayName = "Non-numeric having.in value returns InvalidArguments")]
114+
public async Task AggregateRecords_InvalidArguments_ReturnInvalidArguments(string? argsJson, string? expectedMessageFragment)
122115
{
123116
RuntimeConfig config = CreateConfig();
124-
IServiceProvider sp = CreateServiceProvider(config);
125-
AggregateRecordsTool tool = new();
117+
CallToolResult result = await ExecuteWithConfigAsync(config, argsJson);
126118

127-
CallToolResult result = await tool.ExecuteAsync(null, sp, CancellationToken.None);
128-
Assert.IsTrue(result.IsError == true);
129-
JsonElement content = ParseContent(result);
130-
Assert.IsTrue(content.TryGetProperty("error", out JsonElement error));
131-
Assert.AreEqual("InvalidArguments", error.GetProperty("type").GetString());
132-
}
133-
134-
[TestMethod]
135-
public async Task AggregateRecords_MissingEntity_ReturnsInvalidArguments()
136-
{
137-
RuntimeConfig config = CreateConfig();
138-
IServiceProvider sp = CreateServiceProvider(config);
139-
AggregateRecordsTool tool = new();
140-
141-
JsonDocument args = JsonDocument.Parse("{\"function\": \"count\", \"field\": \"*\"}");
142-
CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None);
143119
Assert.IsTrue(result.IsError == true);
144120
JsonElement content = ParseContent(result);
145121
Assert.AreEqual("InvalidArguments", content.GetProperty("error").GetProperty("type").GetString());
146-
}
147122

148-
[TestMethod]
149-
public async Task AggregateRecords_MissingFunction_ReturnsInvalidArguments()
150-
{
151-
RuntimeConfig config = CreateConfig();
152-
IServiceProvider sp = CreateServiceProvider(config);
153-
AggregateRecordsTool tool = new();
154-
155-
JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\", \"field\": \"*\"}");
156-
CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None);
157-
Assert.IsTrue(result.IsError == true);
158-
JsonElement content = ParseContent(result);
159-
Assert.AreEqual("InvalidArguments", content.GetProperty("error").GetProperty("type").GetString());
160-
}
161-
162-
[TestMethod]
163-
public async Task AggregateRecords_MissingField_ReturnsInvalidArguments()
164-
{
165-
RuntimeConfig config = CreateConfig();
166-
IServiceProvider sp = CreateServiceProvider(config);
167-
AggregateRecordsTool tool = new();
168-
169-
JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\", \"function\": \"count\"}");
170-
CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None);
171-
Assert.IsTrue(result.IsError == true);
172-
JsonElement content = ParseContent(result);
173-
Assert.AreEqual("InvalidArguments", content.GetProperty("error").GetProperty("type").GetString());
174-
}
175-
176-
[TestMethod]
177-
public async Task AggregateRecords_InvalidFunction_ReturnsInvalidArguments()
178-
{
179-
RuntimeConfig config = CreateConfig();
180-
IServiceProvider sp = CreateServiceProvider(config);
181-
AggregateRecordsTool tool = new();
182-
183-
JsonDocument args = JsonDocument.Parse("{\"entity\": \"Book\", \"function\": \"median\", \"field\": \"price\"}");
184-
CallToolResult result = await tool.ExecuteAsync(args, sp, CancellationToken.None);
185-
Assert.IsTrue(result.IsError == true);
186-
JsonElement content = ParseContent(result);
187-
Assert.AreEqual("InvalidArguments", content.GetProperty("error").GetProperty("type").GetString());
188-
Assert.IsTrue(content.GetProperty("error").GetProperty("message").GetString()!.Contains("median"));
123+
if (!string.IsNullOrWhiteSpace(expectedMessageFragment))
124+
{
125+
Assert.IsTrue(content.GetProperty("error").GetProperty("message").GetString()!.Contains(expectedMessageFragment));
126+
}
189127
}
190128

191129
#endregion
@@ -1194,6 +1132,14 @@ private static JsonElement ParseContent(CallToolResult result)
11941132
return JsonDocument.Parse(firstContent.Text).RootElement;
11951133
}
11961134

1135+
private static async Task<CallToolResult> ExecuteWithConfigAsync(RuntimeConfig config, string? argsJson, CancellationToken cancellationToken = default)
1136+
{
1137+
IServiceProvider serviceProvider = CreateServiceProvider(config);
1138+
AggregateRecordsTool tool = new();
1139+
JsonDocument? args = argsJson is null ? null : JsonDocument.Parse(argsJson);
1140+
return await tool.ExecuteAsync(args, serviceProvider, cancellationToken);
1141+
}
1142+
11971143
private static void AssertToolDisabledError(JsonElement content)
11981144
{
11991145
Assert.IsTrue(content.TryGetProperty("error", out JsonElement error));

0 commit comments

Comments
 (0)