From 28390195e4cf1d05274a9b11e3e285598f0a0740 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 16:39:10 +0900 Subject: [PATCH 1/5] Report MCP result truncation (#1727) --- USER_GUIDE.md | 4 +++ changelog.d/unreleased/1727.fixed.md | 18 +++++++++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 8 ++--- src/CodeIndex/Mcp/McpToolHandlers.cs | 40 ++++++++++++++++++++----- tests/CodeIndex.Tests/McpServerTests.cs | 31 +++++++++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/1727.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index aa54a7cb64..2fc5d7f131 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1686,6 +1686,8 @@ 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. + ```mermaid flowchart LR tools["Claude Code
Cursor
Windsurf"] @@ -3656,6 +3658,8 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 +上限付きの MCP result tool は、要求した `limit` より多くの行がある場合に `structuredContent` へ `truncated` と `more_available` を返します。これにより、クライアントは上限で切られたページを網羅的な結果として扱わずに済みます。 + ```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/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 13af83f623..8113121c38 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` / `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 }, @@ -80,7 +80,7 @@ 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` when more rows exist.", ["default"] = 20 }, ["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" }, @@ -104,7 +104,7 @@ 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` when more rows exist.", ["default"] = 20 }, ["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 }, @@ -127,7 +127,7 @@ 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` when more rows exist.", ["default"] = 20 }, ["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 dbca703f0f..ca882aeacd 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -169,6 +169,19 @@ private static void AddExactZeroHint(JsonObject payload, ExactZeroHintResult? ex /// private static int ClampLimit(int limit) => Math.Clamp(limit, 1, MaxLimit); + private static bool AddLimitMetadata(JsonObject payload, List results, int limit, int offset) + { + var truncated = results.Count > limit; + if (truncated) + results.RemoveRange(limit, results.Count - limit); + + payload["offset"] = offset; + payload["count"] = results.Count; + payload["truncated"] = truncated; + payload["more_available"] = truncated; + 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`) — @@ -573,6 +586,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); + const int offset = 0; var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var snippetLines = SearchSnippetFormatter.ClampSnippetLines(args?["snippetLines"]?.GetValue() ?? SearchSnippetFormatter.DefaultSnippetLines); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) @@ -599,7 +613,7 @@ 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); + var results = reader.Search(query, limit + 1, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix); if (results.Count == 0) { var payload = new JsonObject @@ -610,7 +624,10 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, + ["offset"] = offset, ["count"] = 0, + ["truncated"] = false, + ["more_available"] = false, ["results"] = new JsonArray() }; AddFreshnessHint(payload, reader); @@ -625,9 +642,10 @@ 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)) }; + AddLimitMetadata(structured, results, limit, offset); + structured["results"] = ToJsonArray(results, result => SearchSnippetFormatter.ToCompactResult(result, query, snippetLines, exact, maxLineWidth)); // Include top file paths in summary for quick AI orientation // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める var topPaths = results.Select(r => r.Path).Distinct().Take(3); @@ -815,6 +833,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); + const int offset = 0; if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var pathPatterns = ReadScopedPathList(args); @@ -825,7 +844,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.SearchReferences(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + var results = reader.SearchReferences(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -850,9 +869,10 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["graphLanguage"] = graphSupport.GraphLanguage, ["graphSupported"] = graphSupport.GraphSupported, ["graphSupportReason"] = graphSupport.GraphSupportReason, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddLimitMetadata(payload, results, limit, offset); + payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); @@ -881,6 +901,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); + const int offset = 0; var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -891,7 +912,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.GetCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + var results = reader.GetCallers(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -916,9 +937,10 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["graphLanguage"] = graphSupport.GraphLanguage, ["graphSupported"] = graphSupport.GraphSupported, ["graphSupportReason"] = graphSupport.GraphSupportReason, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddLimitMetadata(payload, results, limit, offset); + payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); @@ -947,6 +969,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); + const int offset = 0; var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -957,7 +980,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.GetCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + var results = reader.GetCallees(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -982,9 +1005,10 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["graphLanguage"] = graphSupport.GraphLanguage, ["graphSupported"] = graphSupport.GraphSupported, ["graphSupportReason"] = graphSupport.GraphSupportReason, - ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddLimitMetadata(payload, results, limit, offset); + payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); AddSqlGraphContractSignal(payload, sqlGraphSignal); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 27599343e6..25aad1aad3 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -117,6 +117,37 @@ private void MarkFoldReady() writer.MarkCSharpSymbolNameContractReady(); } + [Fact] + public void ToolsCall_Callers_LimitHitReportsTruncated() + { + 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.Null(first["next_offset"]); + var firstNames = first["results"]!.AsArray() + .Select(row => row!["callerName"]!.GetValue()) + .ToArray(); + + Assert.Equal(firstNames.Distinct().Count(), firstNames.Length); + } + // --- Protocol tests / プロトコルテスト --- [Fact] From 94ba0f9ff312439ba8c91b67e08c366632eb19f2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 16:43:10 +0900 Subject: [PATCH 2/5] Add MCP graph pagination (#1729) --- USER_GUIDE.md | 4 ++++ changelog.d/unreleased/1729.added.md | 20 ++++++++++++++++ .../Database/DbReader.GraphQueries.cs | 10 ++++---- src/CodeIndex/Database/DbReader.References.cs | 6 ++--- src/CodeIndex/Mcp/McpToolDefinitions.cs | 12 ++++++---- src/CodeIndex/Mcp/McpToolHandlers.cs | 17 +++++++++----- tests/CodeIndex.Tests/McpServerTests.cs | 23 ++++++++++++++++--- 7 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 changelog.d/unreleased/1729.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2fc5d7f131..1575afc67b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1688,6 +1688,8 @@ Tool results include structured JSON in `structuredContent` plus a short text su 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"] @@ -3660,6 +3662,8 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて 上限付きの 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/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 5bf9446146..e48bc2007f 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(); @@ -355,7 +356,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(); @@ -446,7 +447,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; @@ -478,6 +479,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..e0d1725ec0 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,10 @@ 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 rawOffset = Math.Max(0, offset); var filtered = new List(); while (filtered.Count < limit) { diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 8113121c38..7a42cb6562 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -27,7 +27,8 @@ 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). Responses include `truncated` / `more_available` when more rows exist.", ["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 }, ["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 }, @@ -80,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). Responses include `truncated` / `more_available` when more rows exist.", ["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" }, @@ -104,7 +106,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). Responses include `truncated` / `more_available` when more rows exist.", ["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 }, @@ -127,7 +130,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). Responses include `truncated` / `more_available` when more rows exist.", ["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 ca882aeacd..ab4026095a 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -169,6 +169,9 @@ 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) { var truncated = results.Count > limit; @@ -179,6 +182,8 @@ private static bool AddLimitMetadata(JsonObject payload, List results, int payload["count"] = results.Count; payload["truncated"] = truncated; payload["more_available"] = truncated; + if (truncated) + payload["next_offset"] = offset + results.Count; return truncated; } @@ -833,7 +838,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); - const int offset = 0; + var offset = ReadOffset(args); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var pathPatterns = ReadScopedPathList(args); @@ -844,7 +849,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.SearchReferences(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + var results = reader.SearchReferences(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset); var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -901,7 +906,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); - const int offset = 0; + var offset = ReadOffset(args); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -912,7 +917,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.GetCallers(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + var results = reader.GetCallers(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), @@ -969,7 +974,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); - const int offset = 0; + var offset = ReadOffset(args); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -980,7 +985,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.GetCallees(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode); + var results = reader.GetCallees(query, limit + 1, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rankMode: rankMode, offset: offset); var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 25aad1aad3..f253f1b2f4 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -118,7 +118,7 @@ private void MarkFoldReady() } [Fact] - public void ToolsCall_Callers_LimitHitReportsTruncated() + public void ToolsCall_Callers_TruncatedResponseIncludesNextOffsetAndPages() { InsertIndexedFile( "src/paged-callers.cs", @@ -140,12 +140,29 @@ void Target() { } Assert.Equal(2, first["count"]!.GetValue()); Assert.True(first["truncated"]!.GetValue()); Assert.True(first["more_available"]!.GetValue()); - Assert.Null(first["next_offset"]); + Assert.Equal(2, first["next_offset"]!.GetValue()); var firstNames = first["results"]!.AsArray() .Select(row => row!["callerName"]!.GetValue()) .ToArray(); - Assert.Equal(firstNames.Distinct().Count(), firstNames.Length); + 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 / プロトコルテスト --- From ff9583c911db652f1f055b64fcd8f98ac9f4b2df Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 16:57:13 +0900 Subject: [PATCH 3/5] Restrict next offset metadata to paged graph tools (#1729) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index ab4026095a..ad9c1ab16f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -172,7 +172,7 @@ private static void AddExactZeroHint(JsonObject payload, ExactZeroHintResult? ex 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) + private static bool AddLimitMetadata(JsonObject payload, List results, int limit, int offset, bool includeNextOffset = false) { var truncated = results.Count > limit; if (truncated) @@ -182,7 +182,7 @@ private static bool AddLimitMetadata(JsonObject payload, List results, int payload["count"] = results.Count; payload["truncated"] = truncated; payload["more_available"] = truncated; - if (truncated) + if (truncated && includeNextOffset) payload["next_offset"] = offset + results.Count; return truncated; } @@ -876,7 +876,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddLimitMetadata(payload, results, limit, offset); + AddLimitMetadata(payload, results, limit, offset, includeNextOffset: true); payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); @@ -944,7 +944,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddLimitMetadata(payload, results, limit, offset); + AddLimitMetadata(payload, results, limit, offset, includeNextOffset: true); payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); @@ -1012,7 +1012,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddLimitMetadata(payload, results, limit, offset); + AddLimitMetadata(payload, results, limit, offset, includeNextOffset: true); payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); From 2e6d071da08985062557f7406af799ceecce404b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 17:16:27 +0900 Subject: [PATCH 4/5] Limit MCP pagination metadata to graph tools (#1727 #1729) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 88b53ed734..317620487e 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -172,18 +172,21 @@ private static void AddExactZeroHint(JsonObject payload, ExactZeroHintResult? ex 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, bool includeNextOffset = false) + 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["offset"] = offset; payload["count"] = results.Count; payload["truncated"] = truncated; payload["more_available"] = truncated; - if (truncated && includeNextOffset) - payload["next_offset"] = offset + results.Count; + if (includePagination) + { + payload["offset"] = offset; + if (truncated) + payload["next_offset"] = offset + results.Count; + } return truncated; } @@ -591,7 +594,6 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); var limit = ClampLimit(args?["limit"]?.GetValue() ?? 20); - const int offset = 0; var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var snippetLines = SearchSnippetFormatter.ClampSnippetLines(args?["snippetLines"]?.GetValue() ?? SearchSnippetFormatter.DefaultSnippetLines); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) @@ -629,7 +631,6 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, - ["offset"] = offset, ["count"] = 0, ["truncated"] = false, ["more_available"] = false, @@ -649,7 +650,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["excludeTests"] = excludeTests, ["results"] = ToJsonArray(results, result => SearchSnippetFormatter.ToCompactResult(result, query, snippetLines, exact, maxLineWidth)) }; - AddLimitMetadata(structured, results, limit, offset); + AddLimitMetadata(structured, results, limit); structured["results"] = ToJsonArray(results, result => SearchSnippetFormatter.ToCompactResult(result, query, snippetLines, exact, maxLineWidth)); // Include top file paths in summary for quick AI orientation // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める @@ -876,7 +877,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddLimitMetadata(payload, results, limit, offset, includeNextOffset: true); + AddLimitMetadata(payload, results, limit, offset, includePagination: true); payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); @@ -944,7 +945,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddLimitMetadata(payload, results, limit, offset, includeNextOffset: true); + AddLimitMetadata(payload, results, limit, offset, includePagination: true); payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); @@ -1012,7 +1013,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["graphSupportReason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; - AddLimitMetadata(payload, results, limit, offset, includeNextOffset: true); + AddLimitMetadata(payload, results, limit, offset, includePagination: true); payload["results"] = ToJsonArray(results); if (exact) AddExactGraphSignal(payload, exactSignal); From 9993e359de24729e577e5a52932f87bb03a67782 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 17:23:36 +0900 Subject: [PATCH 5/5] Fix MCP pagination review findings (#1727 #1729) --- src/CodeIndex/Database/DbReader.References.cs | 11 ++++++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 3 +-- tests/CodeIndex.Tests/DbReaderTests.cs | 23 ++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.References.cs b/src/CodeIndex/Database/DbReader.References.cs index e0d1725ec0..20df6bf14e 100644 --- a/src/CodeIndex/Database/DbReader.References.cs +++ b/src/CodeIndex/Database/DbReader.References.cs @@ -25,7 +25,9 @@ public List SearchReferences(string? query = null, int limit = return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, maxLineWidth, excludeSelfReferences); var rawLimit = Math.Max(limit, CSharpUsingStaticReferenceFilterChunkSize); - var rawOffset = Math.Max(0, offset); + 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 7a42cb6562..93d01d509c 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -27,8 +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). 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 }, + ["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 }, diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index d955b9b483..eacf740446 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]