Skip to content

Commit 9cc65fb

Browse files
authored
Merge pull request #2754 from Widthdom/fix-issue1472-1615-1616
Fix MCP batch query failure reporting
2 parents d65ea68 + 52645db commit 9cc65fb

6 files changed

Lines changed: 116 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1472
5+
affected:
6+
- src/CodeIndex/Mcp/McpToolHandlers.cs
7+
- src/CodeIndex/Mcp/McpToolDefinitions.cs
8+
- tests/CodeIndex.Tests/McpServerTests.cs
9+
---
10+
11+
## English
12+
13+
- **MCP `batch_query` now reports failure scope (#1472)** — batch responses include `failure_scope` with `none`, `isolated`, or `cascading`, plus `cascade_started_at_index` when truncation prevents later slots from running.
14+
15+
## 日本語
16+
17+
- **MCP `batch_query` が失敗スコープを返すようになりました (#1472)** — batch response に `failure_scope``none` / `isolated` / `cascading`)を追加し、truncation により後続スロットを実行できない場合は `cascade_started_at_index` も返します。
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1615
5+
affected:
6+
- src/CodeIndex/Mcp/McpToolHandlers.cs
7+
- tests/CodeIndex.Tests/McpServerTests.cs
8+
---
9+
10+
## English
11+
12+
- **MCP `batch_query` now rejects type-mismatched inner arguments (#1615)** — batched tool slots validate argument JSON types before dispatch, so values such as `"limit": "twenty"` produce a slot error instead of silently using defaults.
13+
14+
## 日本語
15+
16+
- **MCP `batch_query` が内側引数の型不一致を拒否するようになりました (#1615)** — batched tool slot は dispatch 前に argument JSON の型を検証するため、`"limit": "twenty"` のような値は既定値に戻らず slot error になります。
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1616
5+
affected:
6+
- src/CodeIndex/Mcp/McpToolHandlers.cs
7+
- src/CodeIndex/Mcp/McpToolDefinitions.cs
8+
- tests/CodeIndex.Tests/McpServerTests.cs
9+
---
10+
11+
## English
12+
13+
- **MCP `batch_query` now exposes top-level partial-failure counts (#1616)** — batch responses include `total_count`, `success_count`, `failure_count`, and `partial_failure` at the top level so clients can detect aggregate failures without scanning every slot.
14+
15+
## 日本語
16+
17+
- **MCP `batch_query` が top-level の部分失敗件数を返すようになりました (#1616)** — batch response に top-level の `total_count``success_count``failure_count``partial_failure` を追加し、クライアントが全 slot を走査せず集計失敗を検出できるようにしました。

src/CodeIndex/Mcp/McpToolDefinitions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ private JsonNode HandleToolsList(JsonNode? id)
377377
ReadOnlyAnnotations()),
378378
CreateToolDefinition(
379379
"batch_query",
380-
"Execute multiple read-only queries in a single call and return all results. Dramatically reduces round-trips for AI agents. / 複数の読み取り専用クエリを1回の呼び出しで実行し、全結果を返す。AIエージェントの往復回数を劇的に削減。",
380+
"Execute multiple read-only queries in a single call and return all results plus top-level success/failure counts, partial_failure, and failure_scope (none/isolated/cascading). Dramatically reduces round-trips for AI agents. / 複数の読み取り専用クエリを1回の呼び出しで実行し、全結果に加えてトップレベルの成功/失敗件数、partial_failure、failure_scope(none/isolated/cascading)を返す。AIエージェントの往復回数を劇的に削減。",
381381
new JsonObject
382382
{
383383
["type"] = "object",

src/CodeIndex/Mcp/McpToolHandlers.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ private static List<string> ReadStringList(JsonNode? args, string propertyName)
384384
["unknown_argument"] = property.Key,
385385
};
386386
}
387+
387388
}
388389

389390
if (ValidateToolArgumentTypes(toolName, obj) is JsonObject typeError)
@@ -416,7 +417,7 @@ private static List<string> ReadStringList(JsonNode? args, string propertyName)
416417

417418
private static bool TryGetExpectedJsonType(string toolName, string argumentName, out string expected)
418419
{
419-
if (argumentName is "path" or "project" or "excludePaths" or "names")
420+
if (argumentName is "path" or "project" or "excludePaths" or "names" or "files" or "commits" or "changedBetween")
420421
{
421422
expected = string.Empty;
422423
return false;
@@ -426,7 +427,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName,
426427
{
427428
"limit" or "offset" or "snippetLines" or "maxLineWidth" or "before" or "after" or
428429
"focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or
429-
"maxHops" or "maxDepth" or "depth" or "parallelism" => "integer",
430+
"maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" => "integer",
430431
"excludeTests" or "includeGenerated" or "rawQuery" or "noDedup" or "exactSubstring" or
431432
"exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or
432433
"regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" => "boolean",
@@ -2145,9 +2146,11 @@ private JsonNode ExecuteBatchQuery(JsonNode? id, JsonNode? args)
21452146
var totalStopwatch = Stopwatch.StartNew();
21462147
int successCount = 0;
21472148
int failureCount = 0;
2149+
int? cascadeStartedAtIndex = null;
21482150
var truncated = false;
21492151
var responseByteLimit = GetBatchQueryResponseByteLimit();
21502152
var estimatedResponseBytes = EstimateBatchResponseBytes(id, "Executed 0 queries.", queries.Count, successCount, failureCount,
2153+
GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), cascadeStartedAtIndex,
21512154
responseByteLimit, resultsArray, truncated: false, truncatedQueries);
21522155

21532156
bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int requestIndex, bool successfulSlot = false, bool failedSlot = false)
@@ -2161,10 +2164,12 @@ bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int
21612164
? $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms (all succeeded)."
21622165
: $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms ({candidateSuccessCount} succeeded, {candidateFailureCount} failed).";
21632166
var candidateBytes = EstimateBatchResponseBytes(id, candidateSummary, queries.Count, candidateSuccessCount, candidateFailureCount,
2167+
GetBatchFailureScope(queries.Count, candidateSuccessCount, candidateFailureCount, cascadeStartedAtIndex), cascadeStartedAtIndex,
21642168
responseByteLimit, candidateResults, truncated: false, truncatedQueries);
21652169
if (candidateBytes > responseByteLimit)
21662170
{
21672171
truncated = true;
2172+
cascadeStartedAtIndex ??= requestIndex;
21682173
truncatedQueries.Add(new JsonObject
21692174
{
21702175
["request_index"] = requestIndex,
@@ -2264,6 +2269,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg
22642269
if (truncated)
22652270
{
22662271
slotStopwatch.Stop();
2272+
cascadeStartedAtIndex ??= requestIndex;
22672273
truncatedQueries.Add(new JsonObject
22682274
{
22692275
["request_index"] = requestIndex,
@@ -2467,6 +2473,12 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg
24672473
JsonObject BuildPayload() => new()
24682474
{
24692475
["count"] = resultsArray.Count,
2476+
["total_count"] = queries.Count,
2477+
["success_count"] = successCount,
2478+
["failure_count"] = failureCount,
2479+
["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue,
2480+
["failure_scope"] = GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex),
2481+
["cascade_started_at_index"] = cascadeStartedAtIndex,
24702482
["metadata"] = new JsonObject
24712483
{
24722484
["submitted"] = queries.Count,
@@ -2543,11 +2555,17 @@ private int EstimateJsonUtf8Bytes(JsonNode node) =>
25432555
Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions));
25442556

25452557
private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount,
2546-
int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries)
2558+
string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries)
25472559
{
25482560
var payload = new JsonObject
25492561
{
25502562
["count"] = resultsArray.Count,
2563+
["total_count"] = submittedCount,
2564+
["success_count"] = successCount,
2565+
["failure_count"] = failureCount,
2566+
["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue,
2567+
["failure_scope"] = failureScope,
2568+
["cascade_started_at_index"] = cascadeStartedAtIndex,
25512569
["metadata"] = new JsonObject
25522570
{
25532571
["submitted"] = submittedCount,
@@ -2570,6 +2588,13 @@ private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submitt
25702588
return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload));
25712589
}
25722590

2591+
private static string GetBatchFailureScope(int submittedCount, int successCount, int failureCount, int? cascadeStartedAtIndex)
2592+
{
2593+
if (cascadeStartedAtIndex.HasValue && cascadeStartedAtIndex.Value < submittedCount)
2594+
return "cascading";
2595+
return failureCount == 0 ? "none" : "isolated";
2596+
}
2597+
25732598
private static JsonArray CloneJsonArray(JsonArray source)
25742599
{
25752600
var clone = new JsonArray();

tests/CodeIndex.Tests/McpServerTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6381,6 +6381,12 @@ public void ToolsCall_BatchQuery_ExecutesMultipleQueries()
63816381
Assert.Equal(2, metadata["submitted"]!.GetValue<int>());
63826382
Assert.Equal(2, metadata["executed"]!.GetValue<int>());
63836383
Assert.Equal(0, metadata["errors"]!.GetValue<int>());
6384+
var structured = response["result"]!["structuredContent"]!;
6385+
Assert.Equal(2, structured["total_count"]!.GetValue<int>());
6386+
Assert.Equal(2, structured["success_count"]!.GetValue<int>());
6387+
Assert.Equal(0, structured["failure_count"]!.GetValue<int>());
6388+
Assert.False(structured["partial_failure"]!.GetValue<bool>());
6389+
Assert.Equal("none", structured["failure_scope"]!.GetValue<string>());
63846390
}
63856391

63866392
[Fact]
@@ -6458,6 +6464,11 @@ public void ToolsCall_BatchQuery_CountsFailuresInEnvelope_Issue1537()
64586464
Assert.Equal(3, metadata["submitted"]!.GetValue<int>());
64596465
Assert.Equal(3, metadata["executed"]!.GetValue<int>());
64606466
Assert.Equal(2, metadata["errors"]!.GetValue<int>());
6467+
Assert.Equal(3, structured["total_count"]!.GetValue<int>());
6468+
Assert.Equal(1, structured["success_count"]!.GetValue<int>());
6469+
Assert.Equal(2, structured["failure_count"]!.GetValue<int>());
6470+
Assert.True(structured["partial_failure"]!.GetValue<bool>());
6471+
Assert.Equal("isolated", structured["failure_scope"]!.GetValue<string>());
64616472

64626473
var results = structured["results"]!.AsArray();
64636474
Assert.Equal(3, results.Count);
@@ -6477,6 +6488,29 @@ public void ToolsCall_BatchQuery_CountsFailuresInEnvelope_Issue1537()
64776488
Assert.Contains("1 succeeded, 2 failed", text);
64786489
}
64796490

6491+
[Fact]
6492+
public void ToolsCall_BatchQuery_RejectsTypeMismatchedInnerArguments_Issue1615()
6493+
{
6494+
var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[{"tool":"search","arguments":{"query":"App","limit":"twenty"}},{"tool":"search","arguments":{"query":"App","format":false}},{"tool":"ping"}]}}}""")!;
6495+
var response = _server.HandleMessage(request)!;
6496+
6497+
var structured = response["result"]!["structuredContent"]!;
6498+
Assert.Equal(3, structured["total_count"]!.GetValue<int>());
6499+
Assert.Equal(1, structured["success_count"]!.GetValue<int>());
6500+
Assert.Equal(2, structured["failure_count"]!.GetValue<int>());
6501+
Assert.True(structured["partial_failure"]!.GetValue<bool>());
6502+
Assert.Equal("isolated", structured["failure_scope"]!.GetValue<string>());
6503+
6504+
var results = structured["results"]!.AsArray();
6505+
Assert.Equal(3, results.Count);
6506+
Assert.False(results[0]!["ok"]!.GetValue<bool>());
6507+
Assert.Contains("Invalid type for argument 'limit'", results[0]!["error"]!.GetValue<string>());
6508+
Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, results[0]!["category"]!.GetValue<string>());
6509+
Assert.False(results[1]!["ok"]!.GetValue<bool>());
6510+
Assert.Contains("Invalid type for argument 'format'", results[1]!["error"]!.GetValue<string>());
6511+
Assert.True(results[2]!["ok"]!.GetValue<bool>());
6512+
}
6513+
64806514
[Fact]
64816515
public void ToolsCall_BatchQuery_ReportsMalformedSlotsAndActualExecutionCounts_Issue1838_1992_1994()
64826516
{
@@ -6557,6 +6591,9 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416()
65576591
Assert.Equal(2, structured["metadata"]!["submitted"]!.GetValue<int>());
65586592
Assert.Equal(2, structured["metadata"]!["executed"]!.GetValue<int>());
65596593
Assert.Equal(0, structured["metadata"]!["errors"]!.GetValue<int>());
6594+
Assert.Equal("cascading", structured["failure_scope"]!.GetValue<string>());
6595+
Assert.NotNull(structured["cascade_started_at_index"]);
6596+
Assert.True(structured["partial_failure"]!.GetValue<bool>());
65606597

65616598
var truncatedQueries = structured["truncated_queries"]!.AsArray();
65626599
Assert.NotEmpty(truncatedQueries);

0 commit comments

Comments
 (0)