Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1472.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 1472
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- src/CodeIndex/Mcp/McpToolDefinitions.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **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.

## 日本語

- **MCP `batch_query` が失敗スコープを返すようになりました (#1472)** — batch response に `failure_scope`(`none` / `isolated` / `cascading`)を追加し、truncation により後続スロットを実行できない場合は `cascade_started_at_index` も返します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/1615.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 1615
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **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.

## 日本語

- **MCP `batch_query` が内側引数の型不一致を拒否するようになりました (#1615)** — batched tool slot は dispatch 前に argument JSON の型を検証するため、`"limit": "twenty"` のような値は既定値に戻らず slot error になります。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1616.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: fixed
issues:
- 1616
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- src/CodeIndex/Mcp/McpToolDefinitions.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **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.

## 日本語

- **MCP `batch_query` が top-level の部分失敗件数を返すようになりました (#1616)** — batch response に top-level の `total_count`、`success_count`、`failure_count`、`partial_failure` を追加し、クライアントが全 slot を走査せず集計失敗を検出できるようにしました。
2 changes: 1 addition & 1 deletion src/CodeIndex/Mcp/McpToolDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ private JsonNode HandleToolsList(JsonNode? id)
ReadOnlyAnnotations()),
CreateToolDefinition(
"batch_query",
"Execute multiple read-only queries in a single call and return all results. Dramatically reduces round-trips for AI agents. / 複数の読み取り専用クエリを1回の呼び出しで実行し、全結果を返す。AIエージェントの往復回数を劇的に削減。",
"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エージェントの往復回数を劇的に削減。",
new JsonObject
{
["type"] = "object",
Expand Down
31 changes: 28 additions & 3 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ private static List<string> ReadStringList(JsonNode? args, string propertyName)
["unknown_argument"] = property.Key,
};
}

}

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

private static bool TryGetExpectedJsonType(string toolName, string argumentName, out string expected)
{
if (argumentName is "path" or "project" or "excludePaths" or "names")
if (argumentName is "path" or "project" or "excludePaths" or "names" or "files" or "commits" or "changedBetween")
{
expected = string.Empty;
return false;
Expand All @@ -426,7 +427,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName,
{
"limit" or "offset" or "snippetLines" or "maxLineWidth" or "before" or "after" or
"focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or
"maxHops" or "maxDepth" or "depth" or "parallelism" => "integer",
"maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" => "integer",
"excludeTests" or "includeGenerated" or "rawQuery" or "noDedup" or "exactSubstring" or
"exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or
"regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" => "boolean",
Expand Down Expand Up @@ -2145,9 +2146,11 @@ private JsonNode ExecuteBatchQuery(JsonNode? id, JsonNode? args)
var totalStopwatch = Stopwatch.StartNew();
int successCount = 0;
int failureCount = 0;
int? cascadeStartedAtIndex = null;
var truncated = false;
var responseByteLimit = GetBatchQueryResponseByteLimit();
var estimatedResponseBytes = EstimateBatchResponseBytes(id, "Executed 0 queries.", queries.Count, successCount, failureCount,
GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), cascadeStartedAtIndex,
responseByteLimit, resultsArray, truncated: false, truncatedQueries);

bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int requestIndex, bool successfulSlot = false, bool failedSlot = false)
Expand All @@ -2161,10 +2164,12 @@ bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int
? $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms (all succeeded)."
: $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms ({candidateSuccessCount} succeeded, {candidateFailureCount} failed).";
var candidateBytes = EstimateBatchResponseBytes(id, candidateSummary, queries.Count, candidateSuccessCount, candidateFailureCount,
GetBatchFailureScope(queries.Count, candidateSuccessCount, candidateFailureCount, cascadeStartedAtIndex), cascadeStartedAtIndex,
responseByteLimit, candidateResults, truncated: false, truncatedQueries);
if (candidateBytes > responseByteLimit)
{
truncated = true;
cascadeStartedAtIndex ??= requestIndex;
truncatedQueries.Add(new JsonObject
{
["request_index"] = requestIndex,
Expand Down Expand Up @@ -2264,6 +2269,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg
if (truncated)
{
slotStopwatch.Stop();
cascadeStartedAtIndex ??= requestIndex;
truncatedQueries.Add(new JsonObject
{
["request_index"] = requestIndex,
Expand Down Expand Up @@ -2467,6 +2473,12 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg
JsonObject BuildPayload() => new()
{
["count"] = resultsArray.Count,
["total_count"] = queries.Count,
["success_count"] = successCount,
["failure_count"] = failureCount,
["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue,
["failure_scope"] = GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex),
["cascade_started_at_index"] = cascadeStartedAtIndex,
["metadata"] = new JsonObject
{
["submitted"] = queries.Count,
Expand Down Expand Up @@ -2543,11 +2555,17 @@ private int EstimateJsonUtf8Bytes(JsonNode node) =>
Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions));

private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount,
int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries)
string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries)
{
var payload = new JsonObject
{
["count"] = resultsArray.Count,
["total_count"] = submittedCount,
["success_count"] = successCount,
["failure_count"] = failureCount,
["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue,
["failure_scope"] = failureScope,
["cascade_started_at_index"] = cascadeStartedAtIndex,
["metadata"] = new JsonObject
{
["submitted"] = submittedCount,
Expand All @@ -2570,6 +2588,13 @@ private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submitt
return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload));
}

private static string GetBatchFailureScope(int submittedCount, int successCount, int failureCount, int? cascadeStartedAtIndex)
{
if (cascadeStartedAtIndex.HasValue && cascadeStartedAtIndex.Value < submittedCount)
return "cascading";
return failureCount == 0 ? "none" : "isolated";
}

private static JsonArray CloneJsonArray(JsonArray source)
{
var clone = new JsonArray();
Expand Down
37 changes: 37 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6381,6 +6381,12 @@ public void ToolsCall_BatchQuery_ExecutesMultipleQueries()
Assert.Equal(2, metadata["submitted"]!.GetValue<int>());
Assert.Equal(2, metadata["executed"]!.GetValue<int>());
Assert.Equal(0, metadata["errors"]!.GetValue<int>());
var structured = response["result"]!["structuredContent"]!;
Assert.Equal(2, structured["total_count"]!.GetValue<int>());
Assert.Equal(2, structured["success_count"]!.GetValue<int>());
Assert.Equal(0, structured["failure_count"]!.GetValue<int>());
Assert.False(structured["partial_failure"]!.GetValue<bool>());
Assert.Equal("none", structured["failure_scope"]!.GetValue<string>());
}

[Fact]
Expand Down Expand Up @@ -6458,6 +6464,11 @@ public void ToolsCall_BatchQuery_CountsFailuresInEnvelope_Issue1537()
Assert.Equal(3, metadata["submitted"]!.GetValue<int>());
Assert.Equal(3, metadata["executed"]!.GetValue<int>());
Assert.Equal(2, metadata["errors"]!.GetValue<int>());
Assert.Equal(3, structured["total_count"]!.GetValue<int>());
Assert.Equal(1, structured["success_count"]!.GetValue<int>());
Assert.Equal(2, structured["failure_count"]!.GetValue<int>());
Assert.True(structured["partial_failure"]!.GetValue<bool>());
Assert.Equal("isolated", structured["failure_scope"]!.GetValue<string>());

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

[Fact]
public void ToolsCall_BatchQuery_RejectsTypeMismatchedInnerArguments_Issue1615()
{
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"}]}}}""")!;
var response = _server.HandleMessage(request)!;

var structured = response["result"]!["structuredContent"]!;
Assert.Equal(3, structured["total_count"]!.GetValue<int>());
Assert.Equal(1, structured["success_count"]!.GetValue<int>());
Assert.Equal(2, structured["failure_count"]!.GetValue<int>());
Assert.True(structured["partial_failure"]!.GetValue<bool>());
Assert.Equal("isolated", structured["failure_scope"]!.GetValue<string>());

var results = structured["results"]!.AsArray();
Assert.Equal(3, results.Count);
Assert.False(results[0]!["ok"]!.GetValue<bool>());
Assert.Contains("Invalid type for argument 'limit'", results[0]!["error"]!.GetValue<string>());
Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, results[0]!["category"]!.GetValue<string>());
Assert.False(results[1]!["ok"]!.GetValue<bool>());
Assert.Contains("Invalid type for argument 'format'", results[1]!["error"]!.GetValue<string>());
Assert.True(results[2]!["ok"]!.GetValue<bool>());
}

[Fact]
public void ToolsCall_BatchQuery_ReportsMalformedSlotsAndActualExecutionCounts_Issue1838_1992_1994()
{
Expand Down Expand Up @@ -6557,6 +6591,9 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416()
Assert.Equal(2, structured["metadata"]!["submitted"]!.GetValue<int>());
Assert.Equal(2, structured["metadata"]!["executed"]!.GetValue<int>());
Assert.Equal(0, structured["metadata"]!["errors"]!.GetValue<int>());
Assert.Equal("cascading", structured["failure_scope"]!.GetValue<string>());
Assert.NotNull(structured["cascade_started_at_index"]);
Assert.True(structured["partial_failure"]!.GetValue<bool>());

var truncatedQueries = structured["truncated_queries"]!.AsArray();
Assert.NotEmpty(truncatedQueries);
Expand Down
Loading