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 5e167e09e5..b7159b870a 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -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", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3c07ae5c59..04f62d3fe7 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -384,6 +384,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) ["unknown_argument"] = property.Key, }; } + } if (ValidateToolArgumentTypes(toolName, obj) is JsonObject typeError) @@ -416,7 +417,7 @@ private static List 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; @@ -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", @@ -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) @@ -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, @@ -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, @@ -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, @@ -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, @@ -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(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index bd028c52d8..9a811158b8 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6381,6 +6381,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] @@ -6458,6 +6464,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); @@ -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()); + 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); + Assert.False(results[0]!["ok"]!.GetValue()); + Assert.Contains("Invalid type for argument 'limit'", results[0]!["error"]!.GetValue()); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, results[0]!["category"]!.GetValue()); + Assert.False(results[1]!["ok"]!.GetValue()); + Assert.Contains("Invalid type for argument 'format'", results[1]!["error"]!.GetValue()); + Assert.True(results[2]!["ok"]!.GetValue()); + } + [Fact] public void ToolsCall_BatchQuery_ReportsMalformedSlotsAndActualExecutionCounts_Issue1838_1992_1994() { @@ -6557,6 +6591,9 @@ 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"]); + Assert.True(structured["partial_failure"]!.GetValue()); var truncatedQueries = structured["truncated_queries"]!.AsArray(); Assert.NotEmpty(truncatedQueries);