diff --git a/USER_GUIDE.md b/USER_GUIDE.md index aa54a7cb64..1575afc67b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1686,6 +1686,10 @@ cdidx includes a built-in **MCP (Model Context Protocol) server**. MCP is a stan Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. +Capped MCP result tools report `truncated` and `more_available` in `structuredContent` when more rows exist than the requested `limit`, so clients can avoid treating a capped page as exhaustive. + +Graph tools that can page through result sets (`references`, `callers`, and `callees`) also return `offset` and, when truncated, `next_offset`; pass that value back as `offset` to fetch the next page without re-reading earlier rows. + ```mermaid flowchart LR tools["Claude Code
Cursor
Windsurf"] @@ -3656,6 +3660,10 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 +上限付きの MCP result tool は、要求した `limit` より多くの行がある場合に `structuredContent` へ `truncated` と `more_available` を返します。これにより、クライアントは上限で切られたページを網羅的な結果として扱わずに済みます。 + +ページング可能な graph tool(`references`、`callers`、`callees`)は `offset` と、truncated 時には `next_offset` も返すため、その値を次の呼び出しの `offset` に渡すと、既に取得した行を読み直さずに次ページを取得できます。 + ```mermaid flowchart LR tools["Claude Code
Cursor
Windsurf"] diff --git a/changelog.d/unreleased/1727.fixed.md b/changelog.d/unreleased/1727.fixed.md new file mode 100644 index 0000000000..e06d5298aa --- /dev/null +++ b/changelog.d/unreleased/1727.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1727 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP capped result payloads now disclose truncation (#1727)** — capped search and graph tool responses now include `truncated` and `more_available` so clients can distinguish exact-limit result sets from incomplete pages. + +## 日本語 + +- **MCP の上限付き result payload が truncation を明示するようになりました (#1727)** — 上限付きの search / graph tool response が `truncated` と `more_available` を返すため、クライアントはちょうど `limit` 件だった結果と未完了ページを区別できます。 diff --git a/changelog.d/unreleased/1729.added.md b/changelog.d/unreleased/1729.added.md new file mode 100644 index 0000000000..9a3744b609 --- /dev/null +++ b/changelog.d/unreleased/1729.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 1729 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Database/DbReader.References.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP graph tools now expose offset pagination (#1729)** — `references`, `callers`, and `callees` accept `offset` and return `next_offset` on truncated pages so clients can fetch subsequent pages without re-fetching earlier rows. + +## 日本語 + +- **MCP graph tool が offset pagination を公開するようになりました (#1729)** — `references`、`callers`、`callees` は `offset` を受け取り、truncated page では `next_offset` を返すため、クライアントは既存行を再取得せず次ページを取得できます。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 657c2d896d..ff810d1f88 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -11,7 +11,7 @@ public partial class DbReader /// Find callers for a referenced symbol. /// 指定シンボルを呼び出している呼び出し元を探す。 /// - public List GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false) + public List GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false, int offset = 0) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) return new List(); @@ -121,7 +121,7 @@ FROM logical_references r { sql += " GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name"; } - sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.symbol_name = @rawQuery THEN 0 ELSE 1 END, {(referenceKind == null ? GetPathBucketOrderSql("r.path") : PathBucketOrder)}, CASE WHEN lower(r.symbol_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, {(referenceKind == null ? "r.path" : "f.path")}, first_line LIMIT @limit"; + sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.symbol_name = @rawQuery THEN 0 ELSE 1 END, {(referenceKind == null ? GetPathBucketOrderSql("r.path") : PathBucketOrder)}, CASE WHEN lower(r.symbol_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, {(referenceKind == null ? "r.path" : "f.path")}, first_line LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; string callersQueryParam; @@ -150,6 +150,7 @@ FROM logical_references r cmd.Parameters.AddWithValue("@lang", NormalizeQueryLanguage(lang)); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); cmd.Parameters.AddWithValue("@limit", limit); + cmd.Parameters.AddWithValue("@offset", Math.Max(0, offset)); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); @@ -358,7 +359,7 @@ FROM symbol_references r /// Find callees used by a caller/container symbol. /// 呼び出し元シンボルが使っている呼び出し先を探す。 /// - public List GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted) + public List GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, int offset = 0) { if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) return new List(); @@ -449,7 +450,7 @@ FROM logical_references r { sql += " GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind"; } - sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.container_name = @rawQuery THEN 0 ELSE 1 END, {(referenceKind == null ? GetPathBucketOrderSql("r.path") : PathBucketOrder)}, CASE WHEN lower(r.container_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, {(referenceKind == null ? "r.path" : "f.path")}, first_line LIMIT @limit"; + sql += $" ORDER BY CASE WHEN @preferExactCase = 1 AND r.container_name = @rawQuery THEN 0 ELSE 1 END, {(referenceKind == null ? GetPathBucketOrderSql("r.path") : PathBucketOrder)}, CASE WHEN lower(r.container_name) = lower(@rankingQuery) THEN 0 ELSE 1 END, {BuildReferenceRankOrderSql(rankMode)}, {(referenceKind == null ? "r.path" : "f.path")}, first_line LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; string calleesQueryParam; @@ -481,6 +482,7 @@ FROM logical_references r cmd.Parameters.AddWithValue("@lang", lang); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); cmd.Parameters.AddWithValue("@limit", limit); + cmd.Parameters.AddWithValue("@offset", Math.Max(0, offset)); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index c03c527910..20df6bf14e 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -13,7 +13,7 @@ private sealed record SearchReferenceRawRow(string Path, string? Lang, string Sy /// Search indexed references such as call sites. /// 呼び出し箇所などのインデックス済み参照を検索する。 /// - public List SearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, bool excludeSelfReferences = false) + public List SearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, bool excludeSelfReferences = false, int offset = 0) { maxLineWidth = LineWidthFormatter.ClampMaxLineWidth(maxLineWidth); lang = NormalizeQueryLanguage(lang); @@ -22,10 +22,12 @@ public List SearchReferences(string? query = null, int limit = return new List(); if (!ShouldApplyCSharpUsingStaticConstantPatternReferenceFilter(lang, referenceKind, exact)) - return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, 0, maxLineWidth, excludeSelfReferences); + return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, maxLineWidth, excludeSelfReferences); var rawLimit = Math.Max(limit, CSharpUsingStaticReferenceFilterChunkSize); var rawOffset = 0; + var acceptedBeforePage = Math.Max(0, offset); + var accepted = 0; var filtered = new List(); while (filtered.Count < limit) { @@ -38,6 +40,13 @@ public List SearchReferences(string? query = null, int limit = if (ShouldSuppressCSharpUsingStaticConstantPatternReference(result)) continue; + if (accepted < acceptedBeforePage) + { + accepted++; + continue; + } + + accepted++; filtered.Add(result); if (filtered.Count >= limit) break; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index c576eb7324..d89cd23f5e 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -27,7 +27,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = 20 }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (e.g. csharp, python, javascript)" }, ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping). Match lines are clamped around the first match; non-match lines are clamped from the head. Each clamp inserts a `...(+N)...` marker showing how many chars were elided.", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, @@ -81,7 +81,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" }, ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference)" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = 20 }, + ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line context payloads per result (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, @@ -106,7 +107,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by call-graph reference kind (call, instantiate, subscribe, friend). Non-call-graph kinds — metadata (attribute, annotation) and type-position (type_reference) — are rejected here; use `references` with the desired kind instead." }, ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1, friend=0.3), count, or kind.", ["default"] = "weighted" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = 20 }, + ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["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 }, @@ -130,7 +132,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by call-graph reference kind (call, instantiate, subscribe). Non-call-graph kinds — metadata (attribute, annotation) and type-position (type_reference) — are rejected here; use `references` with the desired kind instead." }, ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = 20 }, + ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["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 }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index f2f17ecd19..7f41bcd97f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -169,6 +169,27 @@ private static void AddExactZeroHint(JsonObject payload, ExactZeroHintResult? ex /// private static int ClampLimit(int limit) => Math.Clamp(limit, 1, MaxLimit); + private static int ReadOffset(JsonNode? args) + => Math.Max(0, args?["offset"]?.GetValue() ?? 0); + + private static bool AddLimitMetadata(JsonObject payload, List results, int limit, int offset = 0, bool includePagination = false) + { + var truncated = results.Count > limit; + if (truncated) + results.RemoveRange(limit, results.Count - limit); + + payload["count"] = results.Count; + payload["truncated"] = truncated; + payload["more_available"] = truncated; + if (includePagination) + { + payload["offset"] = offset; + if (truncated) + payload["next_offset"] = offset + results.Count; + } + return truncated; + } + /// /// Return true when the requested reference kind is NOT a call-graph kind (i.e. metadata /// `attribute` / `annotation`, compile-time `type_reference`, or structural `import`) — @@ -404,9 +425,18 @@ private static void AddResultEnvelope(JsonObject payload, int returnedCount, int { payload["count"] = returnedCount; payload["truncated"] = truncated; + payload["more_available"] = truncated; payload["total"] = total.HasValue ? JsonValue.Create(total.Value) : null; } + private static void AddPaginatedResultEnvelope(JsonObject payload, int returnedCount, int? total, bool truncated, int offset) + { + AddResultEnvelope(payload, returnedCount, total, truncated); + payload["offset"] = offset; + if (truncated) + payload["next_offset"] = offset + returnedCount; + } + private static bool ReadCountOnly(JsonNode? args) => args?["countOnly"]?.GetValue() ?? args?["count_only"]?.GetValue() ?? false; private JsonArray BuildTopFileHistogram(IEnumerable results, Func pathSelector) @@ -883,6 +913,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var offset = ReadOffset(args); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var pathPatterns = ReadScopedPathList(args); @@ -909,9 +940,9 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "reference")}.", countOnlyPayload); } - var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset); var truncated = TrimToRequestedLimit(results, limit); - var total = truncated + var total = truncated || offset > 0 ? reader.CountSearchReferences(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); @@ -940,7 +971,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddResultEnvelope(payload, results.Count, total, truncated); + AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); @@ -969,6 +1000,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callers", kind!)); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var offset = ReadOffset(args); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -995,9 +1027,9 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) 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 results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); var truncated = TrimToRequestedLimit(results, limit); - var total = truncated + var total = truncated || offset > 0 ? reader.CountCallers(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); @@ -1026,7 +1058,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddResultEnvelope(payload, results.Count, total, truncated); + AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); if (exact) AddExactGraphSignal(payload, exactSignal); @@ -1056,6 +1088,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callees", kind!)); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + var offset = ReadOffset(args); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1082,9 +1115,9 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) 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 results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); var truncated = TrimToRequestedLimit(results, limit); - var total = truncated + var total = truncated || offset > 0 ? reader.CountCallees(query, int.MaxValue, lang, kind, pathPatterns, excludePaths, excludeTests, exact) : results.Count; var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); @@ -1113,7 +1146,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddResultEnvelope(payload, results.Count, total, truncated); + AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); if (exact) AddExactGraphSignal(payload, exactSignal); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 6edc4c14fc..e60aafee2f 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -10245,11 +10245,12 @@ class Demo // call site. const int suppressedReferenceCount = 64; const int callReferenceLine = suppressedReferenceCount + 10; + const int secondCallReferenceLine = callReferenceLine + 1; using (var updateFileCmd = _db.Connection.CreateCommand()) { updateFileCmd.CommandText = "UPDATE files SET lines = @lines WHERE path = 'src/Use.cs'"; - updateFileCmd.Parameters.AddWithValue("@lines", callReferenceLine + 5); + updateFileCmd.Parameters.AddWithValue("@lines", secondCallReferenceLine + 5); updateFileCmd.ExecuteNonQuery(); } @@ -10307,13 +10308,29 @@ LIMIT 1 ContainerKind = "function", ContainerName = "Match", }); + syntheticReferences.Add(new ReferenceRecord + { + FileId = useFileId, + SymbolName = "Red", + ReferenceKind = "call", + Line = secondCallReferenceLine, + Column = 9, + Context = " Red();", + ContainerKind = "function", + ContainerName = "Match", + }); _writer.InsertReferences(syntheticReferences); var result = Assert.Single(_reader.SearchReferences("Red", limit: 1, lang: "csharp", exact: true, pathPatterns: ["src/Use.cs"])); Assert.Equal("call", result.ReferenceKind); Assert.Equal(callReferenceLine, result.Line); - Assert.Equal(1, _reader.CountSearchReferences("Red", limit: 1, lang: "csharp", exact: true, pathPatterns: ["src/Use.cs"])); - Assert.Equal(new QueryCountResult(1, 1), _reader.CountSearchReferencesTotal("Red", lang: "csharp", exact: true, pathPatterns: ["src/Use.cs"])); + + var nextPage = Assert.Single(_reader.SearchReferences("Red", limit: 1, lang: "csharp", exact: true, pathPatterns: ["src/Use.cs"], offset: 1)); + Assert.Equal("call", nextPage.ReferenceKind); + Assert.Equal(secondCallReferenceLine, nextPage.Line); + + Assert.Equal(2, _reader.CountSearchReferences("Red", limit: 2, lang: "csharp", exact: true, pathPatterns: ["src/Use.cs"])); + Assert.Equal(new QueryCountResult(2, 1), _reader.CountSearchReferencesTotal("Red", lang: "csharp", exact: true, pathPatterns: ["src/Use.cs"])); } [Fact] diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 92ef750cdb..99094e125d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -117,6 +117,54 @@ private void MarkFoldReady() writer.MarkCSharpSymbolNameContractReady(); } + [Fact] + public void ToolsCall_Callers_TruncatedResponseIncludesNextOffsetAndPages() + { + InsertIndexedFile( + "src/paged-callers.cs", + "csharp", + """ + class PagedCallers { + void Alpha() { Target(); } + void Beta() { Target(); } + void Gamma() { Target(); } + void Target() { } + } + """); + + var firstRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-callers.cs","limit":2}}}""")!; + var firstResponse = _server.HandleMessage(firstRequest)!; + var first = firstResponse["result"]!["structuredContent"]!; + + Assert.Equal(2, first["count"]!.GetValue()); + Assert.True(first["truncated"]!.GetValue()); + Assert.True(first["more_available"]!.GetValue()); + Assert.Equal(2, first["next_offset"]!.GetValue()); + var firstNames = first["results"]!.AsArray() + .Select(row => row!["callerName"]!.GetValue()) + .ToArray(); + + var secondRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Target","lang":"csharp","exactName":true,"path":"src/paged-callers.cs","limit":2,"offset":2}}}""")!; + var secondResponse = _server.HandleMessage(secondRequest)!; + var second = secondResponse["result"]!["structuredContent"]!; + + Assert.Equal(2, second["offset"]!.GetValue()); + Assert.False(second["truncated"]!.GetValue()); + Assert.False(second["more_available"]!.GetValue()); + Assert.Null(second["next_offset"]); + var secondNames = second["results"]!.AsArray() + .Select(row => row!["callerName"]!.GetValue()) + .ToArray(); + + var allNames = firstNames.Concat(secondNames).ToArray(); + Assert.Equal(allNames.Distinct().Count(), allNames.Length); + Assert.Contains("Alpha", allNames); + Assert.Contains("Beta", allNames); + Assert.Contains("Gamma", allNames); + } + // --- Protocol tests / プロトコルテスト --- [Fact]