diff --git a/changelog.d/unreleased/1732.added.md b/changelog.d/unreleased/1732.added.md new file mode 100644 index 0000000000..236621a782 --- /dev/null +++ b/changelog.d/unreleased/1732.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1732 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Added MCP `countOnly` probing for large result sets (#1732)** — `search`, `references`, `callers`, `callees`, and `impact_analysis` can now return count metadata plus a small top-file histogram without row payloads. + +## 日本語 + +- **大きな結果セット向けに MCP `countOnly` プローブを追加しました (#1732)** — `search`、`references`、`callers`、`callees`、`impact_analysis` が、行 payload を返さずに件数 metadata と小さな top-file histogram を返せるようになりました。 diff --git a/changelog.d/unreleased/1745.fixed.md b/changelog.d/unreleased/1745.fixed.md new file mode 100644 index 0000000000..d36ad8f736 --- /dev/null +++ b/changelog.d/unreleased/1745.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1745 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP tool responses now have a server-wide byte ceiling (#1745)** — oversized responses fail with structured `response_too_large` metadata instead of being serialized and written unbounded. + +## 日本語 + +- **MCP ツール応答にサーバー全体の byte 上限を追加しました (#1745)** — 過大な応答は無制限に直列化・書き込みされず、構造化された `response_too_large` metadata を持つエラーになります。 diff --git a/changelog.d/unreleased/1808.fixed.md b/changelog.d/unreleased/1808.fixed.md new file mode 100644 index 0000000000..23ea0308f8 --- /dev/null +++ b/changelog.d/unreleased/1808.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1808 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP search and graph responses now expose result envelope metadata (#1808)** — `search`, `definition`, `references`, `callers`, and `callees` include `truncated` and `total` fields so clients can distinguish complete result sets from server-limited responses. + +## 日本語 + +- **MCP の検索・グラフ応答が結果 envelope metadata を返すようになりました (#1808)** — `search`、`definition`、`references`、`callers`、`callees` に `truncated` と `total` を追加し、クライアントが完全な結果とサーバー上限で切られた応答を区別できるようにしました。 diff --git a/changelog.d/unreleased/2027.fixed.md b/changelog.d/unreleased/2027.fixed.md new file mode 100644 index 0000000000..eac49817cb --- /dev/null +++ b/changelog.d/unreleased/2027.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2027 +affected: + - src/CodeIndex/Cli/SearchSnippetFormatter.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs +--- + +## English + +- **Search snippet compaction now supports lazy enumeration (#2027)** — MCP search uses a formatter iterator so compact snippet rows are produced only as the response array is built. + +## 日本語 + +- **検索スニペットの compact 化が lazy enumeration に対応しました (#2027)** — MCP search は formatter iterator を使い、応答配列を組み立てるタイミングでのみ compact snippet 行を生成します。 diff --git a/changelog.d/unreleased/2029.fixed.md b/changelog.d/unreleased/2029.fixed.md new file mode 100644 index 0000000000..b3eddcafd1 --- /dev/null +++ b/changelog.d/unreleased/2029.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 2029 +affected: + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Models/QueryResults.cs +--- + +## English + +- **Bounded grouped reference-kind aggregates in MCP graph results (#2029)** — caller and callee rows now cap parsed `GROUP_CONCAT` reference-kind aggregates and surface `aggregate_truncated` when an aggregate is trimmed before JSON serialization. + +## 日本語 + +- **MCP graph 結果の reference-kind 集約に上限を設けました (#2029)** — caller / callee 行は `GROUP_CONCAT` された reference-kind 集約の解析量を制限し、JSON 直列化前に切り詰めた場合は `aggregate_truncated` を返します。 diff --git a/src/CodeIndex/Cli/SearchSnippetFormatter.cs b/src/CodeIndex/Cli/SearchSnippetFormatter.cs index 5a58ea6904..0a01f8f7aa 100644 --- a/src/CodeIndex/Cli/SearchSnippetFormatter.cs +++ b/src/CodeIndex/Cli/SearchSnippetFormatter.cs @@ -54,6 +54,12 @@ public static CompactSearchResult ToCompactResult(SearchResult result, string qu }; } + public static IEnumerable ToCompactResults(IEnumerable results, string query, int maxLines = DefaultSnippetLines, bool caseSensitive = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, string? lang = null, SearchSnippetFocusMode focusMode = SearchSnippetFocusMode.Quality) + { + foreach (var result in results) + yield return ToCompactResult(result, query, maxLines, caseSensitive, maxLineWidth, lang ?? result.Lang, focusMode); + } + public static SearchSnippetExcerpt BuildExcerpt(string content, string query, int absoluteStartLine, int maxLines = DefaultSnippetLines, bool caseSensitive = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, string? lang = null, SearchSnippetFocusMode focusMode = SearchSnippetFocusMode.Quality) { maxLines = ClampSnippetLines(maxLines); diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 5bf9446146..657c2d896d 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -156,8 +156,10 @@ FROM logical_references r while (reader.TrackedRead()) { var primaryKind = reader.GetString(5); - var kinds = ParseDistinctReferenceKinds(GetNullableString(reader, 8), primaryKind); - var counts = ParseReferenceKindCounts(GetNullableString(reader, 9), primaryKind, reader.GetInt32(7)); + var kindAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 8), out var kindsTruncated); + var countAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 9), out var countsTruncated); + var kinds = ParseDistinctReferenceKinds(kindAggregate, primaryKind); + var counts = ParseReferenceKindCounts(countAggregate, primaryKind, reader.GetInt32(7)); results.Add(new CallerResult { Path = reader.GetString(0), @@ -169,6 +171,7 @@ FROM logical_references r ReferenceKinds = kinds, HasMixedReferenceKinds = kinds.Count > 1, ReferenceKindCounts = counts, + AggregateTruncated = kindsTruncated || countsTruncated, ReferenceWeightScore = reader.GetDouble(10), FirstLine = reader.GetInt32(6), ReferenceCount = reader.GetInt32(7), @@ -484,8 +487,10 @@ FROM logical_references r while (reader.TrackedRead()) { var primaryKind = reader.GetString(5); - var kinds = ParseDistinctReferenceKinds(GetNullableString(reader, 8), primaryKind); - var counts = ParseReferenceKindCounts(GetNullableString(reader, 9), primaryKind, reader.GetInt32(7)); + var kindAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 8), out var kindsTruncated); + var countAggregate = TruncateReferenceKindAggregate(GetNullableString(reader, 9), out var countsTruncated); + var kinds = ParseDistinctReferenceKinds(kindAggregate, primaryKind); + var counts = ParseReferenceKindCounts(countAggregate, primaryKind, reader.GetInt32(7)); results.Add(new CalleeResult { Path = reader.GetString(0), @@ -497,6 +502,7 @@ FROM logical_references r ReferenceKinds = kinds, HasMixedReferenceKinds = kinds.Count > 1, ReferenceKindCounts = counts, + AggregateTruncated = kindsTruncated || countsTruncated, ReferenceWeightScore = reader.GetDouble(10), FirstLine = reader.GetInt32(6), ReferenceCount = reader.GetInt32(7), diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index aae743c85f..043f124689 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -31,6 +31,7 @@ public readonly record struct SqlGraphContractSignal( public partial class DbReader { public const string VerifyFoldReadyRowsEnvironmentVariable = "CDIDX_VERIFY_FOLD_READY_ROWS"; + internal const int MaxReferenceKindAggregateCharacters = 16 * 1024; private static readonly Regex ImpactSignatureIdentifierRegex = new(@"[\p{L}_][\p{L}\p{Nd}_]*", RegexOptions.Compiled); private static readonly Regex CSharpUsingStaticImportRegex = new(@"^\s*(?:global\s+)?using\s+static\s+(?[^;]+)", RegexOptions.Compiled); @@ -332,6 +333,16 @@ private static IReadOnlyList ParseDistinctReferenceKinds(string? aggrega return set.ToArray(); } + private static string? TruncateReferenceKindAggregate(string? aggregate, out bool truncated) + { + truncated = false; + if (aggregate == null || aggregate.Length <= MaxReferenceKindAggregateCharacters) + return aggregate; + + truncated = true; + return aggregate[..MaxReferenceKindAggregateCharacters]; + } + public DbReader(SqliteConnection connection, bool isReadOnly = false) : this(connection, isReadOnly, schemaCache: null, cancellation: CancellationToken.None) { diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 9960c44045..2bc982b152 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -137,6 +137,8 @@ public partial class McpServer : IDisposable private const int MaxContextLines = 1000; internal const int MaxLineCharacterCount = 1_000_000; internal const int MaxLineByteLength = 1_048_576; + internal const int DefaultMaxResponseBytes = 10 * 1024 * 1024; + private const string MaxResponseBytesEnvVar = "CDIDX_MCP_RESPONSE_MAX_BYTES"; internal const int MaxJsonDepth = 32; internal const int MaxBatchRequestCount = 100; // Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query) @@ -728,7 +730,13 @@ private string SerializeResponseOrFallback(JsonNode response, bool hasId, JsonNo { try { - return _serializeResponse(response); + var serialized = _serializeResponse(response); + var responseBytes = Encoding.UTF8.GetByteCount(serialized); + var responseLimit = GetMaxResponseBytes(); + if (responseBytes <= responseLimit) + return serialized; + + return CreateResponseTooLargeError(hasId, id, responseBytes, responseLimit).ToJsonString(_jsonOptions); } catch (Exception ex) { @@ -2232,7 +2240,39 @@ private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? }; if (structuredContent != null) result["structuredContent"] = structuredContent; - return CreateSuccessResponse(true, id, result); + var response = CreateSuccessResponse(true, id, result); + var responseBytes = Encoding.UTF8.GetByteCount(response.ToJsonString()); + var responseLimit = GetMaxResponseBytes(); + if (responseBytes <= responseLimit) + return response; + + return CreateResponseTooLargeError(true, id, responseBytes, responseLimit); + } + + private static JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, int responseBytes, int responseLimit) + { + return CreateErrorResponse( + hasId: hasId, + id: id, + code: -32603, + message: $"MCP response exceeded the server byte limit ({responseBytes} > {responseLimit}). Narrow the query or lower the result limit.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Narrow the query, add path/language filters, lower limit, or use countOnly for a summary-first probe.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "response_too_large", + ["limit_bytes"] = responseLimit, + ["actual_bytes"] = responseBytes, + }); + } + + private static int GetMaxResponseBytes() + { + var configured = Environment.GetEnvironmentVariable(MaxResponseBytesEnvVar); + if (int.TryParse(configured, out var limit) && limit > 0) + return limit; + return DefaultMaxResponseBytes; } /// diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index da399aca27..7ffb033baf 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -40,7 +40,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["noDedup"] = new JsonObject { ["type"] = "boolean", ["description"] = "Disable overlapping-chunk deduplication for raw results", ["default"] = false }, ["exactSubstring"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for search's exact mode: case-sensitive exact substring match (bypasses FTS5).", ["default"] = false }, ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactSubstring`.", ["default"] = false }, - ["prefix"] = new JsonObject { ["type"] = "boolean", ["description"] = "Opt into FTS5 prefix expansion for every token in `query`. Cannot be combined with `exact`/`exactSubstring`.", ["default"] = false } + ["prefix"] = new JsonObject { ["type"] = "boolean", ["description"] = "Opt into FTS5 prefix expansion for every token in `query`. Cannot be combined with `exact`/`exactSubstring`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false } }, ["required"] = new JsonArray { "query" } }, @@ -87,7 +88,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact referenced-symbol equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false } + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false } }, ["required"] = new JsonArray { "query" } }, @@ -110,7 +112,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact callee-name equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false } + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false } }, ["required"] = new JsonArray { "query" } }, @@ -133,7 +136,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact caller/container equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false } + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false } }, ["required"] = new JsonArray { "query" } }, @@ -280,7 +284,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["withPaths"] = new JsonObject { ["type"] = "boolean", ["description"] = "When true, each caller carries a `paths` array of shortest call chains [resolvedRoot, intermediate..., callerName]; diamond convergence surfaces every shortest route (per-row cap; `pathsTruncated` flag indicates overflow).", ["default"] = false } + ["withPaths"] = new JsonObject { ["type"] = "boolean", ["description"] = "When true, each caller carries a `paths` array of shortest call chains [resolvedRoot, intermediate..., callerName]; diamond convergence surfaces every shortest route (per-row cap; `pathsTruncated` flag indicates overflow).", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit caller and file-impact row payloads.", ["default"] = false } }, ["required"] = new JsonArray { "query" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3741a0b2b1..12d130b871 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -388,6 +388,59 @@ private JsonArray ToJsonArray(IEnumerable items, Func return array; } + private static int FetchLimitForEnvelope(int limit) => limit >= int.MaxValue ? int.MaxValue : limit + 1; + + private static bool TrimToRequestedLimit(List results, int limit) + { + if (results.Count <= limit) + return false; + + results.RemoveRange(limit, results.Count - limit); + return true; + } + + private static void AddResultEnvelope(JsonObject payload, int returnedCount, int? total, bool truncated) + { + payload["count"] = returnedCount; + payload["truncated"] = truncated; + payload["total"] = total.HasValue ? JsonValue.Create(total.Value) : null; + } + + private static bool ReadCountOnly(JsonNode? args) => args?["countOnly"]?.GetValue() ?? args?["count_only"]?.GetValue() ?? false; + + private JsonArray BuildTopFileHistogram(IEnumerable results, Func pathSelector) + { + var histogram = new JsonArray(); + foreach (var group in results + .Select(pathSelector) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .GroupBy(path => path!, StringComparer.Ordinal) + .OrderByDescending(group => group.Count()) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .Take(5)) + { + histogram.Add(new JsonObject + { + ["path"] = group.Key, + ["count"] = group.Count(), + }); + } + + return histogram; + } + + private JsonObject BuildCountOnlyPayload(int count, int? total, bool truncated, IEnumerable histogramSource, Func pathSelector) + { + var payload = new JsonObject + { + ["count_only"] = true, + ["top_files"] = BuildTopFileHistogram(histogramSource, pathSelector), + ["results"] = new JsonArray(), + }; + AddResultEnvelope(payload, count, total, truncated); + return payload; + } + private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) { var payload = new JsonObject @@ -590,6 +643,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, $"Invalid 'since' timestamp: '{sinceStr}'. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); } var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); + var countOnly = ReadCountOnly(args); if (!TryResolveSearchExactArgument(args, out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); var prefix = args?["prefix"]?.GetValue() ?? false; @@ -598,7 +652,20 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.Search(query, limit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix); + if (countOnly) + { + var countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix); + var truncatedCount = countResults.Count >= MaxLimit; + var payload = BuildCountOnlyPayload(countResults.Count, truncatedCount ? null : countResults.Count, truncatedCount, countResults, result => result.Path); + payload["query"] = query; + payload["rawQuery"] = rawQuery; + payload["path"] = PathEcho(pathPatterns); + payload["excludeTests"] = excludeTests; + return CreateToolResult(id, $"Counted {countResults.Count} search result(s).", payload); + } + + var results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix); + var truncated = TrimToRequestedLimit(results, limit); if (results.Count == 0) { var payload = new JsonObject @@ -609,9 +676,9 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, - ["count"] = 0, ["results"] = new JsonArray() }; + AddResultEnvelope(payload, 0, 0, truncated: false); AddFreshnessHint(payload, reader); return CreateToolResult(id, "No results found.", payload); } @@ -624,9 +691,9 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, - ["count"] = results.Count, - ["results"] = ToJsonArray(results, result => SearchSnippetFormatter.ToCompactResult(result, query, snippetLines, exact, maxLineWidth)) + ["results"] = ToJsonArray(SearchSnippetFormatter.ToCompactResults(results, query, snippetLines, exact, maxLineWidth)) }; + AddResultEnvelope(structured, results.Count, truncated ? null : results.Count, truncated); // Include top file paths in summary for quick AI orientation // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める var topPaths = results.Select(r => r.Path).Distinct().Take(3); @@ -770,7 +837,8 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.GetDefinitions(query, limit, kind, lang, includeBody, pathPatterns, excludePaths, excludeTests, since, exact); + var results = reader.GetDefinitions(query, FetchLimitForEnvelope(limit), kind, lang, includeBody, pathPatterns, excludePaths, excludeTests, since, exact); + var truncated = TrimToRequestedLimit(results, limit); var exactSignal = reader.GetDefinitionExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( exact, @@ -786,9 +854,9 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) ["includeBody"] = includeBody, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddResultEnvelope(payload, results.Count, truncated ? null : results.Count, truncated); if (exact) AddExactGraphSignal(payload, exactSignal); if (results.Count == 0) @@ -819,12 +887,32 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var countOnly = ReadCountOnly(args); if (!TryResolveNameExactArgument(args, "references", out var exact, out var exactError)) return CreateToolErrorResponse(id, exactError!); return WithDbReader(id, args, reader => { - var results = reader.SearchReferences(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + if (countOnly) + { + var countOnlyTotal = reader.CountSearchReferences(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact); + var histogramResults = countOnlyTotal > 0 + ? reader.SearchReferences(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth) + : []; + var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); + countOnlyPayload["query"] = query; + countOnlyPayload["kind"] = kind; + countOnlyPayload["lang"] = lang; + countOnlyPayload["path"] = PathEcho(pathPatterns); + countOnlyPayload["excludeTests"] = excludeTests; + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "reference")}.", countOnlyPayload); + } + + var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + var truncated = TrimToRequestedLimit(results, limit); + var total = truncated + ? reader.CountSearchReferences(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) + : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -849,9 +937,9 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["graphLanguage"] = graphSupport.GraphLanguage, ["graphSupported"] = graphSupport.GraphSupported, ["graphSupportReason"] = graphSupport.GraphSupportReason, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddResultEnvelope(payload, results.Count, total, truncated); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); @@ -887,10 +975,30 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, exactError!); if (!TryReadReferenceRankMode(args, out var rankMode, out var rankModeError)) return CreateToolErrorResponse(id, rankModeError!); + var countOnly = ReadCountOnly(args); return WithDbReader(id, args, reader => { - var results = reader.GetCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + if (countOnly) + { + var countOnlyTotal = reader.CountCallers(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact); + var histogramResults = countOnlyTotal > 0 + ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode) + : []; + var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); + countOnlyPayload["query"] = query; + countOnlyPayload["kind"] = kind; + countOnlyPayload["lang"] = lang; + countOnlyPayload["path"] = PathEcho(pathPatterns); + countOnlyPayload["excludeTests"] = excludeTests; + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "caller")}.", countOnlyPayload); + } + + var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + var truncated = TrimToRequestedLimit(results, limit); + var total = truncated + ? reader.CountCallers(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) + : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -915,9 +1023,10 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["graphLanguage"] = graphSupport.GraphLanguage, ["graphSupported"] = graphSupport.GraphSupported, ["graphSupportReason"] = graphSupport.GraphSupportReason, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddResultEnvelope(payload, results.Count, total, truncated); + payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); @@ -953,10 +1062,30 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, exactError!); if (!TryReadReferenceRankMode(args, out var rankMode, out var rankModeError)) return CreateToolErrorResponse(id, rankModeError!); + var countOnly = ReadCountOnly(args); return WithDbReader(id, args, reader => { - var results = reader.GetCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + if (countOnly) + { + var countOnlyTotal = reader.CountCallees(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact); + var histogramResults = countOnlyTotal > 0 + ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode) + : []; + var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); + countOnlyPayload["query"] = query; + countOnlyPayload["kind"] = kind; + countOnlyPayload["lang"] = lang; + countOnlyPayload["path"] = PathEcho(pathPatterns); + countOnlyPayload["excludeTests"] = excludeTests; + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "callee")}.", countOnlyPayload); + } + + var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + var truncated = TrimToRequestedLimit(results, limit); + var total = truncated + ? reader.CountCallees(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) + : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -981,9 +1110,10 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["graphLanguage"] = graphSupport.GraphLanguage, ["graphSupported"] = graphSupport.GraphSupported, ["graphSupportReason"] = graphSupport.GraphSupportReason, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddResultEnvelope(payload, results.Count, total, truncated); + payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); @@ -1356,6 +1486,7 @@ private JsonNode ExecuteStatus(JsonNode? id) { ["max_request_characters"] = MaxLineCharacterCount, ["max_request_bytes"] = MaxLineByteLength, + ["max_response_bytes"] = GetMaxResponseBytes(), ["max_json_depth"] = MaxJsonDepth, ["max_batch_requests"] = MaxBatchRequestCount, } @@ -2103,6 +2234,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; var withPaths = args?["withPaths"]?.GetValue() ?? false; + var countOnly = ReadCountOnly(args); return WithDbReader(id, args, reader => { @@ -2121,6 +2253,36 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) var count = hasHeuristicHints ? hintCount : confirmedCount; var fileCount = hasHeuristicHints ? hintFileCount : confirmedFileCount; var maxActualDepth = analysis.Callers.Count > 0 ? analysis.Callers.Max(r => r.Depth) : 0; + if (countOnly) + { + var topFiles = hasHeuristicHints + ? BuildTopFileHistogram(analysis.FileImpacts, impact => impact.SourcePath) + : BuildTopFileHistogram(analysis.Callers, caller => caller.Path); + var countOnlyPayload = new JsonObject + { + ["query"] = query, + ["resolved_name"] = analysis.ResolvedName, + ["count_only"] = true, + ["count"] = count, + ["file_count"] = fileCount, + ["confirmed_count"] = confirmedCount, + ["confirmed_file_count"] = confirmedFileCount, + ["hint_count"] = hintCount, + ["hint_file_count"] = hintFileCount, + ["max_hops"] = maxDepth, + ["actual_depth"] = maxActualDepth, + ["truncated"] = analysis.Truncated, + ["total"] = analysis.Truncated ? null : JsonValue.Create(count), + ["termination_reason"] = analysis.TerminationReason, + ["impact_mode"] = analysis.ImpactMode, + ["heuristic"] = analysis.Heuristic, + ["top_files"] = topFiles, + ["results"] = new JsonArray(), + }; + AddSqlGraphContractSignal(countOnlyPayload, sqlGraphSignal); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(count, "impact result")}.", countOnlyPayload); + } + var payload = new JsonObject { ["query"] = query, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index fbc738c58d..5e2ece6f6a 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -234,6 +234,8 @@ public class CallerResult public IReadOnlyList ReferenceKinds { get; set; } = Array.Empty(); public bool HasMixedReferenceKinds { get; set; } public IReadOnlyDictionary ReferenceKindCounts { get; set; } = new Dictionary(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool AggregateTruncated { get; set; } public double ReferenceWeightScore { get; set; } public int FirstLine { get; set; } public int ReferenceCount { get; set; } @@ -262,6 +264,8 @@ public class CalleeResult public IReadOnlyList ReferenceKinds { get; set; } = Array.Empty(); public bool HasMixedReferenceKinds { get; set; } public IReadOnlyDictionary ReferenceKindCounts { get; set; } = new Dictionary(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool AggregateTruncated { get; set; } public double ReferenceWeightScore { get; set; } public int FirstLine { get; set; } public int ReferenceCount { get; set; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index b908a8c253..7b7051a80d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4278,6 +4278,119 @@ public void ToolsCall_Search_RejectsExactNameAlias() Assert.Contains("exactSubstring", text); } + [Fact] + public void ToolsCall_Search_IncludesTruncatedAndTotalEnvelope() + { + InsertIndexedFile("src/search-a.cs", "csharp", "public class SearchA { public void Target() { } }"); + InsertIndexedFile("src/search-b.cs", "csharp", "public class SearchB { public void Target() { } }"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"Target","limit":1}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.True(structured["truncated"]!.GetValue()); + Assert.Null(structured["total"]); + Assert.Single(structured["results"]!.AsArray()); + } + + [Fact] + public void ToolsCall_References_IncludesTruncatedAndTotalEnvelope() + { + InsertIndexedFile( + "src/reference-envelope.cs", + "csharp", + """ + public class CallerOne { public void Run(App app) { app.Run(); } } + public class CallerTwo { public void Run(App app) { app.Run(); } } + """); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"references","arguments":{"query":"Run","lang":"csharp","limit":1}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1, structured["count"]!.GetValue()); + Assert.True(structured["truncated"]!.GetValue()); + Assert.True(structured["total"]!.GetValue() >= 2); + Assert.Single(structured["results"]!.AsArray()); + } + + [Fact] + public void ToolsCall_References_CountOnly_OmitsRowsAndReturnsHistogram() + { + InsertIndexedFile( + "src/count-only.cs", + "csharp", + """ + public class CountOnlyCaller { public void Run(App app) { app.Run(); app.Run(); } } + """); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"references","arguments":{"query":"Run","lang":"csharp","countOnly":true}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["count_only"]!.GetValue()); + Assert.True(structured["count"]!.GetValue() >= 2); + Assert.Empty(structured["results"]!.AsArray()); + Assert.NotEmpty(structured["top_files"]!.AsArray()); + } + + [Fact] + public void ToolsCall_ImpactAnalysis_CountOnly_OmitsCallerRows() + { + InsertIndexedFile( + "src/impact-count-only.cs", + "csharp", + """ + public class ImpactCountOnlyCaller { public void Hit(App app) { app.Run(); } } + """); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"impact_analysis","arguments":{"query":"Run","lang":"csharp","countOnly":true}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["count_only"]!.GetValue()); + Assert.Empty(structured["results"]!.AsArray()); + Assert.NotNull(structured["top_files"]); + } + + [Fact] + public void ToolsCall_ResponseOverByteLimit_ReturnsStructuredError() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_RESPONSE_MAX_BYTES"); + Environment.SetEnvironmentVariable("CDIDX_MCP_RESPONSE_MAX_BYTES", "256"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status"}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Equal(-32603, response["error"]!["code"]!.GetValue()); + Assert.Equal("response_too_large", response["error"]!["data"]!["reason"]!.GetValue()); + Assert.Equal(256, response["error"]!["data"]!["limit_bytes"]!.GetValue()); + Assert.True(response["error"]!["data"]!["actual_bytes"]!.GetValue() > 256); + } + + [Fact] + public async Task ProcessFrameAsync_BatchResponseOverByteLimit_ReturnsStructuredError() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_RESPONSE_MAX_BYTES"); + Environment.SetEnvironmentVariable("CDIDX_MCP_RESPONSE_MAX_BYTES", "128"); + + var frame = "[" + + string.Join(",", Enumerable.Range(1, 10).Select(id => $$"""{"jsonrpc":"2.0","id":{{id}},"method":"ping"}""")) + + "]"; + var responseText = await _server.ProcessFrameAsync(frame); + using var response = JsonDocument.Parse(responseText!); + var root = response.RootElement; + + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + var error = root.GetProperty("error"); + Assert.Equal(-32603, error.GetProperty("code").GetInt32()); + var data = error.GetProperty("data"); + Assert.Equal("response_too_large", data.GetProperty("reason").GetString()); + Assert.Equal(128, data.GetProperty("limit_bytes").GetInt32()); + Assert.True(data.GetProperty("actual_bytes").GetInt32() > 128); + } + [Fact] public void ToolsCall_Search_AllowsFalseExactNameAlias() { diff --git a/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs b/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs index 26f3bb9193..9a847b27f6 100644 --- a/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs +++ b/tests/CodeIndex.Tests/SearchSnippetFormatterTests.cs @@ -58,6 +58,41 @@ public void ToCompactResult_UsesSnippetInsteadOfFullChunkContent() Assert.Equal(-1.5, compact.Score); } + [Fact] + public void ToCompactResults_IsLazy() + { + var produced = 0; + IEnumerable Results() + { + produced++; + yield return new SearchResult + { + Path = "src/a.cs", + Lang = "csharp", + StartLine = 1, + EndLine = 1, + Content = "Target();", + }; + produced++; + yield return new SearchResult + { + Path = "src/b.cs", + Lang = "csharp", + StartLine = 1, + EndLine = 1, + Content = "Target();", + }; + } + + var compact = SearchSnippetFormatter.ToCompactResults(Results(), "Target"); + + Assert.Equal(0, produced); + using var enumerator = compact.GetEnumerator(); + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, produced); + Assert.Equal("src/a.cs", enumerator.Current.Path); + } + [Fact] public void Format_AddsTruncationMarkers_WhenExcerptIsTruncated() {