Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>Cursor<br/>Windsurf"]
Expand Down Expand Up @@ -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<br/>Cursor<br/>Windsurf"]
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/1727.fixed.md
Original file line number Diff line number Diff line change
@@ -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` 件だった結果と未完了ページを区別できます。
20 changes: 20 additions & 0 deletions changelog.d/unreleased/1729.added.md
Original file line number Diff line number Diff line change
@@ -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` を返すため、クライアントは既存行を再取得せず次ページを取得できます。
10 changes: 6 additions & 4 deletions src/CodeIndex/Database/DbReader.GraphQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public partial class DbReader
/// Find callers for a referenced symbol.
/// 指定シンボルを呼び出している呼び出し元を探す。
/// </summary>
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)
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)
{
if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query))
return new List<CallerResult>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<CallerResult>();
using var reader = cmd.ExecuteTrackedReader();
Expand Down Expand Up @@ -358,7 +359,7 @@ FROM symbol_references r
/// Find callees used by a caller/container symbol.
/// 呼び出し元シンボルが使っている呼び出し先を探す。
/// </summary>
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)
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)
{
if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query))
return new List<CalleeResult>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<CalleeResult>();
using var reader = cmd.ExecuteTrackedReader();
Expand Down
13 changes: 11 additions & 2 deletions src/CodeIndex/Database/DbReader.References.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ private sealed record SearchReferenceRawRow(string Path, string? Lang, string Sy
/// Search indexed references such as call sites.
/// 呼び出し箇所などのインデックス済み参照を検索する。
/// </summary>
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)
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)
{
maxLineWidth = LineWidthFormatter.ClampMaxLineWidth(maxLineWidth);
lang = NormalizeQueryLanguage(lang);
Expand All @@ -22,10 +22,12 @@ public List<ReferenceResult> SearchReferences(string? query = null, int limit =
return new List<ReferenceResult>();

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<ReferenceResult>();
while (filtered.Count < limit)
{
Expand All @@ -38,6 +40,13 @@ public List<ReferenceResult> SearchReferences(string? query = null, int limit =
if (ShouldSuppressCSharpUsingStaticConstantPatternReference(result))
continue;

if (accepted < acceptedBeforePage)
{
accepted++;
continue;
}

accepted++;
filtered.Add(result);
if (filtered.Count >= limit)
break;
Expand Down
11 changes: 7 additions & 4 deletions src/CodeIndex/Mcp/McpToolDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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" },
Expand All @@ -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 },
Expand All @@ -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 },
Expand Down
Loading
Loading