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]