Skip to content

Commit 78406ab

Browse files
authored
Merge pull request #2704 from Widthdom/fix-issue1727-1729
Add MCP truncation metadata and graph pagination
2 parents a572dec + 9188078 commit 78406ab

9 files changed

Lines changed: 180 additions & 22 deletions

File tree

USER_GUIDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,10 @@ cdidx includes a built-in **MCP (Model Context Protocol) server**. MCP is a stan
16861686

16871687
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.
16881688

1689+
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.
1690+
1691+
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.
1692+
16891693
```mermaid
16901694
flowchart LR
16911695
tools["Claude Code<br/>Cursor<br/>Windsurf"]
@@ -3656,6 +3660,10 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて
36563660

36573661
ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。
36583662

3663+
上限付きの MCP result tool は、要求した `limit` より多くの行がある場合に `structuredContent``truncated``more_available` を返します。これにより、クライアントは上限で切られたページを網羅的な結果として扱わずに済みます。
3664+
3665+
ページング可能な graph tool(`references``callers``callees`)は `offset` と、truncated 時には `next_offset` も返すため、その値を次の呼び出しの `offset` に渡すと、既に取得した行を読み直さずに次ページを取得できます。
3666+
36593667
```mermaid
36603668
flowchart LR
36613669
tools["Claude Code<br/>Cursor<br/>Windsurf"]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1727
5+
affected:
6+
- src/CodeIndex/Mcp/McpToolHandlers.cs
7+
- src/CodeIndex/Mcp/McpToolDefinitions.cs
8+
- tests/CodeIndex.Tests/McpServerTests.cs
9+
- USER_GUIDE.md
10+
---
11+
12+
## English
13+
14+
- **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.
15+
16+
## 日本語
17+
18+
- **MCP の上限付き result payload が truncation を明示するようになりました (#1727)** — 上限付きの search / graph tool response が `truncated``more_available` を返すため、クライアントはちょうど `limit` 件だった結果と未完了ページを区別できます。
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
category: added
3+
issues:
4+
- 1729
5+
affected:
6+
- src/CodeIndex/Database/DbReader.GraphQueries.cs
7+
- src/CodeIndex/Database/DbReader.References.cs
8+
- src/CodeIndex/Mcp/McpToolHandlers.cs
9+
- src/CodeIndex/Mcp/McpToolDefinitions.cs
10+
- tests/CodeIndex.Tests/McpServerTests.cs
11+
- USER_GUIDE.md
12+
---
13+
14+
## English
15+
16+
- **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.
17+
18+
## 日本語
19+
20+
- **MCP graph tool が offset pagination を公開するようになりました (#1729)**`references``callers``callees``offset` を受け取り、truncated page では `next_offset` を返すため、クライアントは既存行を再取得せず次ページを取得できます。

src/CodeIndex/Database/DbReader.GraphQueries.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public partial class DbReader
1111
/// Find callers for a referenced symbol.
1212
/// 指定シンボルを呼び出している呼び出し元を探す。
1313
/// </summary>
14-
public List<CallerResult> GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false)
14+
public List<CallerResult> GetCallers(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, bool excludeSelfReferences = false, int offset = 0)
1515
{
1616
if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query))
1717
return new List<CallerResult>();
@@ -121,7 +121,7 @@ FROM logical_references r
121121
{
122122
sql += " GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name";
123123
}
124-
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";
124+
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";
125125

126126
cmd.CommandText = sql;
127127
string callersQueryParam;
@@ -150,6 +150,7 @@ FROM logical_references r
150150
cmd.Parameters.AddWithValue("@lang", NormalizeQueryLanguage(lang));
151151
AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns);
152152
cmd.Parameters.AddWithValue("@limit", limit);
153+
cmd.Parameters.AddWithValue("@offset", Math.Max(0, offset));
153154

154155
var results = new List<CallerResult>();
155156
using var reader = cmd.ExecuteTrackedReader();
@@ -358,7 +359,7 @@ FROM symbol_references r
358359
/// Find callees used by a caller/container symbol.
359360
/// 呼び出し元シンボルが使っている呼び出し先を探す。
360361
/// </summary>
361-
public List<CalleeResult> GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted)
362+
public List<CalleeResult> GetCallees(string query, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, bool rawKinds = false, ReferenceRankMode rankMode = ReferenceRankMode.Weighted, int offset = 0)
362363
{
363364
if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query))
364365
return new List<CalleeResult>();
@@ -449,7 +450,7 @@ FROM logical_references r
449450
{
450451
sql += " GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind";
451452
}
452-
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";
453+
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";
453454

454455
cmd.CommandText = sql;
455456
string calleesQueryParam;
@@ -481,6 +482,7 @@ FROM logical_references r
481482
cmd.Parameters.AddWithValue("@lang", lang);
482483
AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns);
483484
cmd.Parameters.AddWithValue("@limit", limit);
485+
cmd.Parameters.AddWithValue("@offset", Math.Max(0, offset));
484486

485487
var results = new List<CalleeResult>();
486488
using var reader = cmd.ExecuteTrackedReader();

src/CodeIndex/Database/DbReader.References.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ private sealed record SearchReferenceRawRow(string Path, string? Lang, string Sy
1313
/// Search indexed references such as call sites.
1414
/// 呼び出し箇所などのインデックス済み参照を検索する。
1515
/// </summary>
16-
public List<ReferenceResult> SearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, bool excludeSelfReferences = false)
16+
public List<ReferenceResult> SearchReferences(string? query = null, int limit = 20, string? lang = null, string? referenceKind = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, bool excludeSelfReferences = false, int offset = 0)
1717
{
1818
maxLineWidth = LineWidthFormatter.ClampMaxLineWidth(maxLineWidth);
1919
lang = NormalizeQueryLanguage(lang);
@@ -22,10 +22,12 @@ public List<ReferenceResult> SearchReferences(string? query = null, int limit =
2222
return new List<ReferenceResult>();
2323

2424
if (!ShouldApplyCSharpUsingStaticConstantPatternReferenceFilter(lang, referenceKind, exact))
25-
return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, 0, maxLineWidth, excludeSelfReferences);
25+
return SearchReferencesCore(query, limit, lang, referenceKind, pathPatterns, excludePathPatterns, excludeTests, exact, offset, maxLineWidth, excludeSelfReferences);
2626

2727
var rawLimit = Math.Max(limit, CSharpUsingStaticReferenceFilterChunkSize);
2828
var rawOffset = 0;
29+
var acceptedBeforePage = Math.Max(0, offset);
30+
var accepted = 0;
2931
var filtered = new List<ReferenceResult>();
3032
while (filtered.Count < limit)
3133
{
@@ -38,6 +40,13 @@ public List<ReferenceResult> SearchReferences(string? query = null, int limit =
3840
if (ShouldSuppressCSharpUsingStaticConstantPatternReference(result))
3941
continue;
4042

43+
if (accepted < acceptedBeforePage)
44+
{
45+
accepted++;
46+
continue;
47+
}
48+
49+
accepted++;
4150
filtered.Add(result);
4251
if (filtered.Count >= limit)
4352
break;

src/CodeIndex/Mcp/McpToolDefinitions.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ private JsonNode HandleToolsList(JsonNode? id)
2727
["properties"] = new JsonObject
2828
{
2929
["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." },
30-
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 },
30+
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = 20 },
3131
["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (e.g. csharp, python, javascript)" },
3232
["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines },
3333
["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)
8181
["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" },
8282
["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference)" },
8383
["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" },
84-
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 },
84+
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = 20 },
85+
["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 },
8586
["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 },
8687
["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." },
8788
["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)
106107
["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." },
107108
["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" },
108109
["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" },
109-
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 },
110+
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = 20 },
111+
["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 },
110112
["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." },
111113
["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" },
112114
["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false },
@@ -130,7 +132,8 @@ private JsonNode HandleToolsList(JsonNode? id)
130132
["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." },
131133
["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" },
132134
["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" },
133-
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = 20 },
135+
["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = 20 },
136+
["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 },
134137
["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." },
135138
["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" },
136139
["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false },

0 commit comments

Comments
 (0)