From f15f37053cca4dc574730de88c59894b088b3ca3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:27:55 +0900 Subject: [PATCH 1/3] Fix MCP batch query failure reporting (#1472 #1615 #1616) --- changelog.d/unreleased/1472.fixed.md | 17 +++++ changelog.d/unreleased/1615.fixed.md | 16 +++++ changelog.d/unreleased/1616.fixed.md | 17 +++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 82 ++++++++++++++++++++++++- tests/CodeIndex.Tests/McpServerTests.cs | 34 ++++++++++ 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/1472.fixed.md create mode 100644 changelog.d/unreleased/1615.fixed.md create mode 100644 changelog.d/unreleased/1616.fixed.md diff --git a/changelog.d/unreleased/1472.fixed.md b/changelog.d/unreleased/1472.fixed.md new file mode 100644 index 0000000000..7550fe9f7d --- /dev/null +++ b/changelog.d/unreleased/1472.fixed.md @@ -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` も返します。 diff --git a/changelog.d/unreleased/1615.fixed.md b/changelog.d/unreleased/1615.fixed.md new file mode 100644 index 0000000000..fcf8fca20f --- /dev/null +++ b/changelog.d/unreleased/1615.fixed.md @@ -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 になります。 diff --git a/changelog.d/unreleased/1616.fixed.md b/changelog.d/unreleased/1616.fixed.md new file mode 100644 index 0000000000..8d61510563 --- /dev/null +++ b/changelog.d/unreleased/1616.fixed.md @@ -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 を走査せず集計失敗を検出できるようにしました。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 292eae76f3..cd4c4a278e 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -372,7 +372,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", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 40e01380cd..38af2a3c63 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -352,11 +352,67 @@ private static List ReadStringList(JsonNode? args, string propertyName) ["unknown_argument"] = property.Key, }; } + + if (ValidateToolArgumentType(toolName, property.Key, property.Value) is JsonObject typeError) + return typeError; } return null; } + private static JsonObject? ValidateToolArgumentType(string toolName, string propertyName, JsonNode? value) + { + if (value is null) + return BuildInvalidToolArgumentType(toolName, propertyName, ExpectedToolArgumentTypeDescription(propertyName)); + + return propertyName switch + { + "limit" or "offset" or "snippetLines" or "maxLineWidth" or "startLine" or "endLine" or + "before" or "after" or "focusLine" or "focusColumn" or "focusLength" or "maxHops" or + "maxDepth" or "depth" or "parallelism" or "maxFileBytes" + => IsJsonInteger(value) ? null : BuildInvalidToolArgumentType(toolName, propertyName, "integer"), + + "includeBody" or "lsp_compatible" or "excludeTests" or "includeGenerated" or "rawQuery" or + "noDedup" or "exactSubstring" or "exact" or "exactName" or "countOnly" or "regex" or + "includeImports" or "withPaths" or "rebuild" or "dry_run" or "dryRun" or "force" or "optimize" + => IsJsonBoolean(value) ? null : BuildInvalidToolArgumentType(toolName, propertyName, "boolean"), + + "path" or "project" or "excludePaths" or "names" or "files" or "commits" or "changedBetween" + => ValidateStringListArgument(new JsonObject { [propertyName] = value.DeepClone() }, propertyName), + + "queries" + => value is JsonArray ? null : BuildInvalidToolArgumentType(toolName, propertyName, "array"), + + "query" or "kind" or "lang" or "since" or "solution" or "rankBy" or "direction" or "symbol" or + "groupBy" or "db" or "category" or "language" or "description" or "context" or "toolInvocationContext" + => IsJsonString(value) ? null : BuildInvalidToolArgumentType(toolName, propertyName, "string"), + + _ => null, + }; + } + + private static JsonObject BuildInvalidToolArgumentType(string toolName, string propertyName, string expectedType) => new() + { + ["message"] = $"Invalid argument '{propertyName}' for tool '{toolName}': expected {expectedType}.", + ["tool"] = toolName, + ["invalid_argument"] = propertyName, + ["expected_type"] = expectedType, + }; + + private static string ExpectedToolArgumentTypeDescription(string propertyName) => + propertyName is "path" or "project" or "excludePaths" or "names" or "files" or "commits" or "changedBetween" + ? "string or array of strings" + : "non-null JSON value"; + + private static bool IsJsonInteger(JsonNode value) => + value is JsonValue scalar && scalar.TryGetValue(out _); + + private static bool IsJsonBoolean(JsonNode value) => + value is JsonValue scalar && scalar.TryGetValue(out _); + + private static bool IsJsonString(JsonNode value) => + value is JsonValue scalar && scalar.TryGetValue(out _); + private static bool IsKnownToolName(string toolName) => toolName switch { "search" or "definition" or "references" or "callers" or "callees" or "symbols" or @@ -1996,9 +2052,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) @@ -2012,10 +2070,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, @@ -2115,6 +2175,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg if (truncated) { slotStopwatch.Stop(); + cascadeStartedAtIndex ??= requestIndex; truncatedQueries.Add(new JsonObject { ["request_index"] = requestIndex, @@ -2318,6 +2379,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, + ["failure_scope"] = GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), + ["cascade_started_at_index"] = cascadeStartedAtIndex, ["metadata"] = new JsonObject { ["submitted"] = queries.Count, @@ -2394,11 +2461,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, + ["failure_scope"] = failureScope, + ["cascade_started_at_index"] = cascadeStartedAtIndex, ["metadata"] = new JsonObject { ["submitted"] = submittedCount, @@ -2421,6 +2494,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(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 4126697727..ffee588824 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6226,6 +6226,12 @@ public void ToolsCall_BatchQuery_ExecutesMultipleQueries() Assert.Equal(2, metadata["submitted"]!.GetValue()); Assert.Equal(2, metadata["executed"]!.GetValue()); Assert.Equal(0, metadata["errors"]!.GetValue()); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(2, structured["total_count"]!.GetValue()); + Assert.Equal(2, structured["success_count"]!.GetValue()); + Assert.Equal(0, structured["failure_count"]!.GetValue()); + Assert.False(structured["partial_failure"]!.GetValue()); + Assert.Equal("none", structured["failure_scope"]!.GetValue()); } [Fact] @@ -6303,6 +6309,11 @@ public void ToolsCall_BatchQuery_CountsFailuresInEnvelope_Issue1537() Assert.Equal(3, metadata["submitted"]!.GetValue()); Assert.Equal(3, metadata["executed"]!.GetValue()); Assert.Equal(2, metadata["errors"]!.GetValue()); + Assert.Equal(3, structured["total_count"]!.GetValue()); + Assert.Equal(1, structured["success_count"]!.GetValue()); + Assert.Equal(2, structured["failure_count"]!.GetValue()); + Assert.True(structured["partial_failure"]!.GetValue()); + Assert.Equal("isolated", structured["failure_scope"]!.GetValue()); var results = structured["results"]!.AsArray(); Assert.Equal(3, results.Count); @@ -6322,6 +6333,27 @@ 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":"ping"}]}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(2, structured["total_count"]!.GetValue()); + Assert.Equal(1, structured["success_count"]!.GetValue()); + Assert.Equal(1, structured["failure_count"]!.GetValue()); + Assert.True(structured["partial_failure"]!.GetValue()); + Assert.Equal("isolated", structured["failure_scope"]!.GetValue()); + + var results = structured["results"]!.AsArray(); + Assert.Equal(2, results.Count); + Assert.False(results[0]!["ok"]!.GetValue()); + Assert.Contains("Invalid argument 'limit'", results[0]!["error"]!.GetValue()); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, results[0]!["category"]!.GetValue()); + Assert.True(results[1]!["ok"]!.GetValue()); + } + [Fact] public void ToolsCall_BatchQuery_ReportsMalformedSlotsAndActualExecutionCounts_Issue1838_1992_1994() { @@ -6402,6 +6434,8 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() Assert.Equal(2, structured["metadata"]!["submitted"]!.GetValue()); Assert.Equal(2, structured["metadata"]!["executed"]!.GetValue()); Assert.Equal(0, structured["metadata"]!["errors"]!.GetValue()); + Assert.Equal("cascading", structured["failure_scope"]!.GetValue()); + Assert.NotNull(structured["cascade_started_at_index"]); var truncatedQueries = structured["truncated_queries"]!.AsArray(); Assert.NotEmpty(truncatedQueries); From 20b5b3da2e0dcbe8d1227dfebb1a7418907ab792 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:35:52 +0900 Subject: [PATCH 2/3] Cover batch query format argument validation (#1615) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 4 ++-- tests/CodeIndex.Tests/McpServerTests.cs | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index ab5a7ea37f..67c108e086 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -406,7 +406,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) "includeBody" or "lsp_compatible" or "excludeTests" or "includeGenerated" or "rawQuery" or "noDedup" or "exactSubstring" or "exact" or "exactName" or "countOnly" or "regex" or - "includeImports" or "withPaths" or "rebuild" or "dry_run" or "dryRun" or "force" or "optimize" + "includeImports" or "withPaths" or "prefix" or "rebuild" or "dry_run" or "dryRun" or "force" or "optimize" => IsJsonBoolean(value) ? null : BuildInvalidToolArgumentType(toolName, propertyName, "boolean"), "path" or "project" or "excludePaths" or "names" or "files" or "commits" or "changedBetween" @@ -416,7 +416,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) => value is JsonArray ? null : BuildInvalidToolArgumentType(toolName, propertyName, "array"), "query" or "kind" or "lang" or "since" or "solution" or "rankBy" or "direction" or "symbol" or - "groupBy" or "db" or "category" or "language" or "description" or "context" or "toolInvocationContext" + "groupBy" or "format" or "db" or "category" or "language" or "description" or "context" or "toolInvocationContext" => IsJsonString(value) ? null : BuildInvalidToolArgumentType(toolName, propertyName, "string"), _ => null, diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 34b1d94030..6b90f38680 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6366,22 +6366,24 @@ public void ToolsCall_BatchQuery_CountsFailuresInEnvelope_Issue1537() [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":"ping"}]}}}""")!; + 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(2, structured["total_count"]!.GetValue()); + Assert.Equal(3, structured["total_count"]!.GetValue()); Assert.Equal(1, structured["success_count"]!.GetValue()); - Assert.Equal(1, structured["failure_count"]!.GetValue()); + Assert.Equal(2, structured["failure_count"]!.GetValue()); Assert.True(structured["partial_failure"]!.GetValue()); Assert.Equal("isolated", structured["failure_scope"]!.GetValue()); var results = structured["results"]!.AsArray(); - Assert.Equal(2, results.Count); + Assert.Equal(3, results.Count); Assert.False(results[0]!["ok"]!.GetValue()); Assert.Contains("Invalid argument 'limit'", results[0]!["error"]!.GetValue()); Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, results[0]!["category"]!.GetValue()); - Assert.True(results[1]!["ok"]!.GetValue()); + Assert.False(results[1]!["ok"]!.GetValue()); + Assert.Contains("Invalid argument 'format'", results[1]!["error"]!.GetValue()); + Assert.True(results[2]!["ok"]!.GetValue()); } [Fact] From b5be6d3ce11bf2c18e0c51d3118ba0619c3f67f1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:38:14 +0900 Subject: [PATCH 3/3] Mark truncated batch queries as partial failures (#1472 #1616) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 4 ++-- tests/CodeIndex.Tests/McpServerTests.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 67c108e086..532ea12963 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2452,7 +2452,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg ["total_count"] = queries.Count, ["success_count"] = successCount, ["failure_count"] = failureCount, - ["partial_failure"] = failureCount > 0, + ["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue, ["failure_scope"] = GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), ["cascade_started_at_index"] = cascadeStartedAtIndex, ["metadata"] = new JsonObject @@ -2539,7 +2539,7 @@ private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submitt ["total_count"] = submittedCount, ["success_count"] = successCount, ["failure_count"] = failureCount, - ["partial_failure"] = failureCount > 0, + ["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue, ["failure_scope"] = failureScope, ["cascade_started_at_index"] = cascadeStartedAtIndex, ["metadata"] = new JsonObject diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 6b90f38680..a3c9acdd7d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6468,6 +6468,7 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() Assert.Equal(0, structured["metadata"]!["errors"]!.GetValue()); Assert.Equal("cascading", structured["failure_scope"]!.GetValue()); Assert.NotNull(structured["cascade_started_at_index"]); + Assert.True(structured["partial_failure"]!.GetValue()); var truncatedQueries = structured["truncated_queries"]!.AsArray(); Assert.NotEmpty(truncatedQueries);