From 38156b2ec9f7a82b1fe7d4708dd6f0d2f158ece2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 12:59:39 +0900 Subject: [PATCH 1/8] Add MCP discovery cursors (#4853) --- DEVELOPER_GUIDE.md | 12 +- TESTING_GUIDE.md | 2 + changelog.d/unreleased/4853.added.md | 28 ++ .../Cli/JsonEnvelopeWrapper.Bounded.cs | 4 +- src/CodeIndex/Database/DbReader.cs | 44 ++- .../Database/DbSymbolReader.Search.cs | 8 +- src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 6 +- src/CodeIndex/Mcp/McpToolCatalog.cs | 9 +- src/CodeIndex/Mcp/McpToolHandlers.Graph.cs | 78 ++++- .../Mcp/McpToolHandlers.Pagination.cs | 163 +++++++++ .../Mcp/McpToolHandlers.Query.Status.cs | 1 + .../Mcp/McpToolHandlers.Query.Symbols.cs | 97 +++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 78 ++++- .../McpServerIssue4853Tests.cs | 321 ++++++++++++++++++ tests/CodeIndex.Tests/McpToolContractTests.cs | 19 +- 15 files changed, 837 insertions(+), 33 deletions(-) create mode 100644 changelog.d/unreleased/4853.added.md create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs create mode 100644 tests/CodeIndex.Tests/McpServerIssue4853Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2d416f878..4dd935e64 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -895,12 +895,16 @@ Each JSON-RPC MCP request gets a server-generated `correlation_id` in addition t MCP stderr diagnostics are prefixed with `[rid= rid_type= rid_length= cid=]` when a request context has an id. Every `tools/call` also emits one structured JSON line with `event: "mcp.tool.invocation"`, the same opaque `request_id` / `request_id_type` / `request_id_length` tuple, the tool name, elapsed milliseconds, status, result count when available, error metadata, argument keys, and argument lengths. Neither the raw request id nor argument values are logged in this telemetry. -### MCP search pagination +### MCP query pagination MCP `search` responses include `result_stable_at`, copied from the index freshness timestamp for the database snapshot used by that call. Clients that page through search results should compare `result_stable_at` across calls; if it changes, an intervening index mutation may have shifted the result set and the client should restart pagination. Non-empty `search` responses also include `next_cursor`. Passing that value back as the `cursor` argument with the same query and filters continues after the last returned `(score, chunk rowid)` anchor. The cursor is an opaque response value; clients should not construct or edit it. +The high-volume discovery tools `symbols`, `files`, and `validate` also accept an opaque `cursor`. Every non-count response reports `returned_count`, authoritative `total_count`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `result_stable_at`, and `next_cursor`; the final or empty page returns `has_more: false` and `next_cursor: null`. Each page reads its generation, total, and rows in one SQLite snapshot, and deterministic ordering lets clients enumerate all rows without gaps or duplicates. + +Pass `next_cursor` back to the same tool with the exact same filters, `format`, and `limit`. These tokens are stateless and bound to both that normalized query and the index generation. Invalid tokens return `cursor_malformed`, changed arguments return `cursor_query_mismatch`, out-of-range offsets return `cursor_offset_out_of_range`, and an intervening index generation returns `cursor_stale` with the `index_stale` category. In all four cases, discard the token and restart without `cursor`. `countOnly: true` and `format: "count"` do not accept a cursor. The `status` tool publishes the token input bound as `mcp.limits.max_query_cursor_characters`. + ### MCP health probes The MCP JSON-RPC `ping` method returns a structured health object with `status`, `uptime_s`, `last_request_at`, `db_open`, `last_db_check_at`, and `transport_ready`. HTTP MCP transports expose the same object at `GET /healthz` on the existing listener. If the HTTP transport is protected by a bearer token, `/healthz` uses the same `Authorization: Bearer ` requirement as POST and `/events`. @@ -4112,12 +4116,16 @@ operator は environment variable で既定値を上書きできる。 MCP stderr diagnostic は request context に id がある場合、`[rid= rid_type= rid_length= cid=]` prefix を付ける。すべての `tools/call` は同じ opaque な `request_id` / `request_id_type` / `request_id_length` tuple、`event: "mcp.tool.invocation"`、tool name、elapsed milliseconds、status、可能な場合の result count、error metadata、argument key、argument length を含む structured JSON line も出す。request id の生値と argument value はこの telemetry に記録しない。 -### MCP 検索ページング +### MCP クエリページング MCP `search` response には、その call が使った DB snapshot の index freshness timestamp からコピーした `result_stable_at` を含める。client が search result を page する場合は、call 間で `result_stable_at` を比較すること。値が変わっていれば、途中の index mutation により result set がずれた可能性があるため、pagination を最初からやり直すべきである。 non-empty な `search` response には `next_cursor` も含める。同じ query と filter でその値を `cursor` argument として渡すと、最後に返した `(score, chunk rowid)` anchor の後から継続する。cursor は opaque な response value であり、client が構築・編集してはいけない。 +大量の discovery 結果を返す `symbols`、`files`、`validate` も opaque な `cursor` を受け付ける。count 以外の全 response は `returned_count`、authoritative な `total_count`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`result_stable_at`、`next_cursor` を報告し、最終 page または空 page は `has_more: false` と `next_cursor: null` を返す。各 page は generation、total、row を単一 SQLite snapshot で読み、決定的な順序により gap や duplicate なしで全 row を列挙できる。 + +`next_cursor` は、filter、`format`、`limit` を一切変えずに同じ tool へ渡す。token は stateless で、正規化済み query と index generation の両方に束縛される。不正 token は `cursor_malformed`、argument 変更は `cursor_query_mismatch`、範囲外 offset は `cursor_offset_out_of_range`、途中の index generation 変更は `index_stale` category の `cursor_stale` を返す。いずれも token を破棄し、`cursor` なしで最初からやり直す。`countOnly: true` と `format: "count"` は cursor を受け付けない。`status` tool は token 入力上限を `mcp.limits.max_query_cursor_characters` として公開する。 + ### MCP ヘルスプローブ MCP JSON-RPC `ping` method は `status`、`uptime_s`、`last_request_at`、`db_open`、`last_db_check_at`、`transport_ready` を持つ structured health object を返す。HTTP MCP transport は同じ object を既存 listener の `GET /healthz` でも公開する。HTTP transport が bearer token で保護されている場合、`/healthz` も POST と `/events` と同じ `Authorization: Bearer ` requirement を使う。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1a9218e46..a51036ac0 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -530,6 +530,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding MCP JSON-RPC behavior and tool outputs. Large server coverage is split into focused partial suites for tool calls, tool listing, protocol/session handling, and error handling while the root `McpServerTests` part keeps shared seeded fixture state. Request-timeout tests use signal-gated delay hooks instead of fixed sleeps: start the request, confirm the hook has begun, then await the timeout response with a bounded wait so they pay only the configured timeout while still proving in-flight actions drain after the timeout response. The single-request timeout-lease regression uses an ID-specific dispatch signal, a one-second execution timeout for scheduler headroom, typed response-node assertions, and `TestDeterminism.AssertTaskRemainsBlockedAsync` for the queued request. Keep those checks together so full-suite load produces an actionable assertion instead of a null dereference (#4807). The root seeded database/server fixture is initialized through a thread-safe `Lazy` only when a test accesses its default fixture path. Static helpers and tests that build their own server or transport must not pay schema creation and seed cost; concurrent fixture access must still publish exactly one database/server pair. + High-volume discovery cursor coverage must consume `symbols`, `files`, and `validate` through the final page and assert authoritative totals, deterministic no-gap/no-duplicate enumeration, empty/final-page metadata, bounded opaque tokens, stateless reuse by concurrent server instances, and typed malformed, query-mismatch, and stale-generation failures. Seed only the rows needed by that focused partial suite and change the persisted generation before opening a fresh server for stale-token assertions. Protocol negotiation coverage keeps `2025-06-18`, `2025-03-26`, and `2024-11-05` in one shared version-echo fixture and asserts the exact server-side capability keys for every version. The Codex compatibility regression separately uses the lifecycle-enforcing transport to send a `2025-06-18` initialize, `notifications/initialized`, and `tools/list`, because a direct handler assertion would not catch initialization-gate failures. Transport transcripts must also prove that a second initialize receives `duplicate_initialize` without mutating the session, and that `notifications/initialized` triggers `roots/list` only when the client advertised roots support. Signal-gated coverage must repeat initialized while the first roots response is blocked, prove only one client request starts and teardown drains it, and force both bounded drain deadlines to expire while a late roots write remains blocked to prove stdio resource disposal stays deferred. Release a timeout-delayed initialize worker after its frame cleanup to prove a corrected retry is accepted. Request-id telemetry coverage uses credential-shaped and high-cardinality ids to prove raw values never reach stderr prefixes/events, Activity tags, MCP metrics, audit records, or timeout logs/status. Assert the fixed opaque token plus consistent type and decoded string-value UTF-16 code-unit length (`null` = `0`), different injected process salts, JSON type domain separation for equal textual values, and collapse to one overflow token after the distinct-id budget (including concurrent creation). Keep a CLI metrics negative case that omits all request-id fields. Keep failed-then-success initialize isolation in the protocol/session suite: assert caller, client info, roots, capabilities, initialization lifecycle, and session ID after negotiation or success-response serialization failure and again after the corrected handshake, so failed metadata cannot poison the accepted session (#4540). Signal-gated duplicate-initialize coverage must hold a concurrent status reader on its captured snapshot and an in-flight `roots/list` response from the accepted handshake, proving readers see one complete state and a rejected duplicate cannot replace caller metadata or roots. @@ -1425,6 +1426,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" MCP の JSON-RPC 挙動とツール出力のテスト。大きな server coverage は tool call、tool listing、protocol/session handling、error handling ごとの focused partial suite に分割し、共有の seed 済み fixture 状態は root 側の `McpServerTests` に残します。request-timeout test は固定 sleep ではなく signal-gated delay hook を使います。request を開始し、hook が始まったことを確認してから timeout response を bounded wait で待つことで、timeout response 後に in-flight action が drain されることは保ったまま、設定した timeout 分だけを待つようにします。 single-request の timeout-lease 回帰テストでは、ID 別の dispatch signal、scheduler の余裕を確保する 1 秒の execution timeout、型付き response-node assertion、queue 待ち request に対する `TestDeterminism.AssertTaskRemainsBlockedAsync` を使います。full-suite 負荷でも null 参照ではなく対応可能な assertion を返すよう、これらの検証をまとめて維持してください(#4807)。 root の seed 済み database/server fixture は、test が既定 fixture path へアクセスした場合だけ thread-safe な `Lazy` で初期化します。static helper や独自 server / transport を構築する test は未使用 schema の作成・seed cost を支払わず、並行 fixture access でも database/server pair を必ず1組だけ公開してください。 + 大量 discovery 用 cursor の coverage では、`symbols`、`files`、`validate` を最終 page まで消費し、authoritative な total、gap・duplicate のない決定的列挙、空・最終 page metadata、上限内の opaque token、並行 server instance による stateless reuse、不正・query mismatch・stale generation の型付き failure を検証してください。focused partial suite に必要な row だけを seed し、stale token の assertion では永続化 generation を変更してから新しい server を開いてください。 protocol negotiation coverage は `2025-06-18`、`2025-03-26`、`2024-11-05` を共通の version-echo fixture にまとめ、全 version で server-side capability の正確な key を検証します。Codex 互換性の回帰テストでは別途 lifecycle を強制する transport を使い、`2025-06-18` の initialize、`notifications/initialized`、`tools/list` までを送ります。direct handler の assertion だけでは initialization gate の失敗を検出できないためです。transport transcript では、2回目の initialize が session を変更せず `duplicate_initialize` を返すことと、client が roots support を提示した場合だけ `notifications/initialized` の後に `roots/list` を送ることも検証してください。signal-gated coverage では最初の roots response を block したまま initialized を再送し、client request が1件だけ開始され teardown で drain されることを確認します。さらに、遅い roots write を block したまま両 bounded drain deadline を期限切れにし、stdio resource の dispose が引き続き defer されることを証明してください。また、frame cleanup 後に timeout で遅れた initialize worker を解放し、修正済み retry が受理されることを証明してください。 request-id telemetry coverage では credential 風および high-cardinality な id を使い、生値が stderr の prefix / event、Activity tag、MCP metrics、audit record、timeout log / status のどこにも出ないことを検証します。固定長 opaque token と、型および decode 後の string 値の UTF-16 code unit 数(`null` は `0`)が全 surface で一致することに加え、注入した process salt ごとの差、text が同じ JSON type 間の domain separation、concurrent creation を含む distinct-id budget 超過後の単一 overflow token への集約を確認してください。CLI metrics では request-id field をすべて省略する negative case を維持してください。 failed-then-success initialize の分離は protocol/session suite に維持し、交渉失敗または success response の serialization 失敗直後と、修正した handshake 後の caller、client info、roots、capabilities、initialization lifecycle、session ID を検証して、失敗 metadata が受理済み session を汚染できないようにします(#4540)。signal-gated な duplicate-initialize coverage では、並行 status reader を取得済み snapshot で保持し、受理済み handshake の進行中 `roots/list` response を保持することで、reader が完全な 1 state だけを見ることと、拒否された重複 initialize が caller metadata や roots を置き換えられないことも検証します。 diff --git a/changelog.d/unreleased/4853.added.md b/changelog.d/unreleased/4853.added.md new file mode 100644 index 000000000..e7f7d162b --- /dev/null +++ b/changelog.d/unreleased/4853.added.md @@ -0,0 +1,28 @@ +--- +category: added +issues: + - 4853 +affected: + - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Database/DbSymbolReader.Search.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolCatalog.cs + - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs + - src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerIssue4853Tests.cs + - tests/CodeIndex.Tests/McpToolContractTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **High-volume MCP discovery tools now provide stable continuation tokens (#4853)** — `symbols`, `files`, and `validate` return authoritative pagination metadata and stateless, generation-bound `next_cursor` values, with deterministic full enumeration and typed errors for malformed, mismatched, or stale tokens. + +## 日本語 + +- **大量の結果を返す MCP discovery tool に安定した continuation token を追加しました (#4853)** — `symbols`、`files`、`validate` は authoritative な pagination metadata と generation に束縛された stateless な `next_cursor` を返し、決定的な全件列挙と、不正・不一致・stale token に対する型付き error を提供します。 diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index 0a0d05132..64354537d 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -1250,7 +1250,7 @@ private static void RemoveOptionWithValue(List args, string option) } } - private static string FormatResponseCursor( + internal static string FormatResponseCursor( int offset, string queryFingerprint, string generationFingerprint, @@ -1274,7 +1274,7 @@ private static string FormatResponseCursor( return ResponseCursorPrefix + encoded; } - private static bool TryParseResponseCursor( + internal static bool TryParseResponseCursor( string cursor, out int offset, out string? queryFingerprint, diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 6a9a34dd0..d46e8f6fd 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -2241,8 +2241,11 @@ string text when DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeS IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int? limit = null, - string? severity = null) + string? severity = null, + int offset = 0) { + if (offset < 0) + throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be non-negative."); if (!_hasIssuesTable) return new List(); using var cmd = _conn.CreateCommand(); var originColumn = _issueColumns.Contains("origin") ? "i.origin" : "NULL"; @@ -2257,9 +2260,9 @@ FROM file_issues i if (severity != null) sql += " AND " + severityColumn + " = @severity"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); - sql += " ORDER BY f.path, i.line"; + sql += " ORDER BY f.path, i.line, i.kind, i.message, i.rowid"; if (limit.HasValue) - sql += " LIMIT @limit"; + sql += " LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; if (kind != null) @@ -2268,7 +2271,10 @@ FROM file_issues i SqliteCommandPolicy.Add(cmd, "@severity", severity); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); if (limit.HasValue) + { SqliteCommandPolicy.Add(cmd, "@limit", limit.Value); + SqliteCommandPolicy.Add(cmd, "@offset", offset); + } var results = new List(); using var reader = cmd.ExecuteTrackedReader(); @@ -2286,6 +2292,38 @@ FROM file_issues i } return results; } + + public int CountIssues( + string? kind = null, + IReadOnlyList? pathPatterns = null, + IReadOnlyList? excludePathPatterns = null, + bool excludeTests = false, + string? severity = null) + { + if (!_hasIssuesTable) + return 0; + + using var cmd = _conn.CreateCommand(); + var severityColumn = _issueColumns.Contains("severity") ? "i.severity" : "NULL"; + var sql = @" + SELECT COUNT(*) + FROM file_issues i + JOIN files f ON i.file_id = f.id + WHERE 1=1"; + if (kind != null) + sql += " AND i.kind = @kind"; + if (severity != null) + sql += " AND " + severityColumn + " = @severity"; + AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + + cmd.CommandText = sql; + if (kind != null) + SqliteCommandPolicy.Add(cmd, "@kind", kind); + if (severity != null) + SqliteCommandPolicy.Add(cmd, "@severity", severity); + AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + return Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); + } } // Result DTOs are in Models/QueryResults.cs / 結果DTOは Models/QueryResults.cs に分離 diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 0305afd3a..95f0ceb23 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -442,7 +442,11 @@ public List SearchSymbols(IReadOnlyList? queries, int limi foreach (var q in validQueries) perName.Add(SearchSymbols(new[] { q! }, requestedPrefix, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters, sortMode, startLine, endLine, groupPartials)); - var seen = new HashSet<(string Path, int Line, string Name, string Kind)>(); + // De-duplicate the same physical symbol returned by multiple query branches while + // preserving distinct indexed rows that happen to share a path/line/name/kind tuple. + // 複数 query branch が返した同一 physical symbol は除外する一方、path/line/name/kind が + // 偶然一致する別の indexed row は保持する。 + var seen = new HashSet(); var merged = new List(); var cursors = new int[perName.Count]; bool advanced; @@ -454,7 +458,7 @@ public List SearchSymbols(IReadOnlyList? queries, int limi while (cursors[i] < perName[i].Count) { var r = perName[i][cursors[i]++]; - if (seen.Add((r.Path, r.Line, r.Name, r.Kind))) + if (seen.Add(r.SymbolId)) { merged.Add(r); advanced = true; diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index b46931a83..b47bd0c35 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -18,8 +18,8 @@ public partial class McpServer "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "visibility", "excludeVisibility", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rawKinds", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "visibility", "excludeVisibility", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "countOnly", "format", "project", "solution" }, - "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "orderBySize", "rawBytes", "project", "solution" }, + "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "visibility", "excludeVisibility", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "orderBySize", "rawBytes", "project", "solution" }, "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "minEntrypointConfidence", "project", "solution" }, @@ -30,7 +30,7 @@ public partial class McpServer "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "graphBudget", "cursor", "excludePaths", "excludeTests", "includeGenerated", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, "languages" => new HashSet(StringComparer.Ordinal) { "indexedOnly", "capability", "extension", "alias" }, - "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, + "validate" => new HashSet(StringComparer.Ordinal) { "kind", "severity", "limit", "cursor", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution" }, "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "visibility", "excludeVisibility", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "byBucket", "project", "solution" }, "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "visibility", "excludeVisibility", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "dryRun", "dry_run", "maxFileBytes", "maxSymbolsPerFile", "maxReferencesPerFile", "followSymlinks", "includeSymbolKind", "excludeSymbolKind", "memoryTrace", "parallelism", "commits", "changedBetween", "files", "watch", "debounce" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 1a9087d65..8c2c2e3a0 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -170,7 +170,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "symbols", - "Use this when discovering candidate symbols before `definition`, `references`, `callers`, or `callees`. Prefer `exactName:true` when the name must match exactly. Search for code symbols (functions, classes, interfaces, imports) by name pattern. `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Examples: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`. / `definition` / `references` / `callers` / `callees` の前に候補シンボルを探すときに使う。名前を厳密一致させるなら `exactName:true` を優先する。シンボル(関数、クラス、インターフェース、import)を名前パターンで検索。例: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`。", + "Use this when discovering candidate symbols before `definition`, `references`, `callers`, or `callees`. Prefer `exactName:true` when the name must match exactly. Search for code symbols (functions, classes, interfaces, imports) by name pattern. Page metadata includes authoritative totals, `result_stable_at`, and an opaque generation-bound `next_cursor`; pass it back unchanged with the same filters, format, and limit. `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Examples: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`. / `definition` / `references` / `callers` / `callees` の前に候補シンボルを探すときに使う。名前を厳密一致させるなら `exactName:true` を優先する。シンボル(関数、クラス、インターフェース、import)を名前パターンで検索。ページ metadata は authoritative total、`result_stable_at`、generation-bound な opaque `next_cursor` を含む。同じ filter / format / limit で cursor を変更せず渡す。例: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`。", new JsonObject { ["type"] = "object", @@ -183,6 +183,7 @@ private static JsonArray CreateToolCatalog() ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["cursor"] = new JsonObject { ["type"] = "string", ["maxLength"] = MaxMcpQueryCursorCharacters, ["description"] = "Opaque generation-bound next_cursor returned by a previous symbols page. Keep filters, format, and limit unchanged." }, ["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"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, @@ -197,7 +198,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "files", - "Use this when you need to locate indexed files by path, language, or recent-change scope before reading content. Prefer `outline` or `excerpt` as the next step after choosing a file. List indexed files, optionally filtered by name pattern and language. / 内容を読む前に path、言語、最近の変更範囲でインデックス済みファイルを探すときに使う。ファイルを選んだ後は `outline` または `excerpt` を優先する。インデックス済みファイルを一覧(名前パターン・言語でフィルタ可能)。", + "Use this when you need to locate indexed files by path, language, or recent-change scope before reading content. Prefer `outline` or `excerpt` as the next step after choosing a file. List indexed files, optionally filtered by name pattern and language. Page metadata includes authoritative totals, `result_stable_at`, and an opaque generation-bound `next_cursor`; pass it back unchanged with the same filters and limit. / 内容を読む前に path、言語、最近の変更範囲でインデックス済みファイルを探すときに使う。ファイルを選んだ後は `outline` または `excerpt` を優先する。インデックス済みファイルを一覧(名前パターン・言語でフィルタ可能)。ページ metadata は authoritative total、`result_stable_at`、generation-bound な opaque `next_cursor` を含む。同じ filter / limit で cursor を変更せず渡す。", new JsonObject { ["type"] = "object", @@ -206,6 +207,7 @@ private static JsonArray CreateToolCatalog() ["query"] = new JsonObject { ["type"] = "string", ["description"] = "File path pattern to filter by" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["cursor"] = new JsonObject { ["type"] = "string", ["maxLength"] = MaxMcpQueryCursorCharacters, ["description"] = "Opaque generation-bound next_cursor returned by a previous files page. Keep filters and limit unchanged." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Additional path filter text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, @@ -419,7 +421,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "validate", - "Report encoding issues found during indexing: U+FFFD replacement chars, BOM markers, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings. replacement_char rows include origin/severity metadata so agents can separate source literals from decoder replacements. / インデックス時に検出したエンコーディング問題を報告。replacement_char 行は source literal と decoder replacement を分ける origin/severity metadata を含む。", + "Report encoding issues found during indexing: U+FFFD replacement chars, BOM markers, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings. replacement_char rows include origin/severity metadata so agents can separate source literals from decoder replacements. Page metadata includes authoritative totals, `result_stable_at`, and an opaque generation-bound `next_cursor`; pass it back unchanged with the same filters, format, and limit. / インデックス時に検出したエンコーディング問題を報告。replacement_char 行は source literal と decoder replacement を分ける origin/severity metadata を含む。ページ metadata は authoritative total、`result_stable_at`、generation-bound な opaque `next_cursor` を含む。同じ filter / format / limit で cursor を変更せず渡す。", new JsonObject { ["type"] = "object", @@ -428,6 +430,7 @@ private static JsonArray CreateToolCatalog() ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by issue kind (replacement_char, bom, null_byte, mixed_line_endings, mixed_line_endings_three_way, cr_only_line_endings, utf16_bom, non_utf8_likely, line_too_long)" }, ["severity"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "error", "warning", "info" }, ["description"] = "Filter by issue severity." }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max issues to return (default: 20).", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["cursor"] = new JsonObject { ["type"] = "string", ["maxLength"] = MaxMcpQueryCursorCharacters, ["description"] = "Opaque generation-bound next_cursor returned by a previous validate page. Keep filters, format, and limit unchanged." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = StringOrArraySchema("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.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs index 24d1270ac..a49a7293d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -391,10 +391,66 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, sinceError!); var orderBySize = args?["orderBySize"]?.GetValue() ?? false; var rawBytes = args?["rawBytes"]?.GetValue() ?? false; + var includeGenerated = args?["includeGenerated"]?.GetValue() ?? false; + var rawCursor = args?["cursor"]?.GetValue(); + McpQueryCursor? cursor = null; + if (rawCursor is not null && !TryParseMcpQueryCursor(rawCursor, out cursor)) + { + return CreateMcpCursorError( + id, + "files", + "cursor_malformed", + "cursor must be an opaque response:v2 next_cursor returned by files.", + stale: false); + } - return WithDbReader(id, args, reader => + return WithDbReader(id, args, reader => reader.RunInReadSnapshot(() => { - var results = reader.ListFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, since, orderBySize || rawBytes); + var queryFingerprint = BuildMcpQueryFingerprint( + "files", + limit, + "full", + new Dictionary + { + ["query"] = query, + ["lang"] = lang, + ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ["exclude-tests"] = excludeTests ? "true" : "false", + ["include-generated"] = includeGenerated ? "true" : "false", + ["order-by-size"] = orderBySize ? "true" : "false", + ["raw-bytes"] = rawBytes ? "true" : "false", + }, + ("path", pathPatterns, PreserveOrder: false), + ("exclude-path", excludePaths, PreserveOrder: false)); + var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + var total = reader.CountListFiles( + query, + lang, + pathPatterns, + excludePaths, + excludeTests, + since).Count; + if (ValidateMcpQueryCursor( + id, + "files", + cursor, + queryFingerprint, + generation.Fingerprint, + total) is JsonObject cursorError) + { + return cursorError; + } + var offset = cursor?.Offset ?? 0; + var results = reader.ListFiles( + query, + limit, + lang, + pathPatterns, + excludePaths, + excludeTests, + since, + orderBySize || rawBytes, + offset); if (results.Count == 0) { var payload = new JsonObject @@ -408,6 +464,14 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) ["count"] = 0, ["results"] = new JsonArray() }; + AddMcpPaginationEnvelope( + payload, + total, + returnedCount: 0, + offset, + limit, + queryFingerprint, + generation); if (rawBytes) { payload["raw_bytes_payload_supported"] = false; @@ -429,6 +493,14 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) ["count"] = results.Count, ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions) }; + AddMcpPaginationEnvelope( + structured, + total, + results.Count, + offset, + limit, + queryFingerprint, + generation); if (rawBytes) { structured["raw_bytes_payload_supported"] = false; @@ -436,7 +508,7 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) } adjustments.ApplyTo(structured); return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "file"), structured); - }); + })); } private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs new file mode 100644 index 000000000..705f52d46 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + internal const int MaxMcpQueryCursorCharacters = 16_384; + + private sealed record McpQueryCursor( + int Offset, + string QueryFingerprint, + string GenerationFingerprint); + + private static bool TryParseMcpQueryCursor(string cursor, out McpQueryCursor? parsed) + { + parsed = null; + if (cursor.Length > MaxMcpQueryCursorCharacters + || !JsonEnvelopeWrapper.TryParseResponseCursor( + cursor, + out var offset, + out var queryFingerprint, + out var generationFingerprint, + out var resumePath, + out var resumeLine) + || queryFingerprint is null + || generationFingerprint is null + || resumePath is not null + || resumeLine.HasValue) + { + return false; + } + + parsed = new McpQueryCursor(offset, queryFingerprint, generationFingerprint); + return true; + } + + private static string BuildMcpQueryFingerprint( + string toolName, + int pageLimit, + string format, + IEnumerable> scalarComponents, + params (string Name, IReadOnlyList? Values, bool PreserveOrder)[] listComponents) + { + var components = new List + { + "mcp-query:v1", + "tool:" + toolName, + "page-limit:" + pageLimit.ToString(CultureInfo.InvariantCulture), + "format:" + format, + }; + components.AddRange(scalarComponents + .OrderBy(component => component.Key, StringComparer.Ordinal) + .Select(component => component.Key + ":" + (component.Value ?? string.Empty))); + foreach (var (name, values, preserveOrder) in listComponents.OrderBy(component => component.Name, StringComparer.Ordinal)) + { + var indexedValues = (values ?? []).Select((value, index) => (value, index)); + if (!preserveOrder) + indexedValues = indexedValues.OrderBy(item => item.value, StringComparer.Ordinal); + components.AddRange(indexedValues.Select(item => + name + ":" + (preserveOrder + ? item.index.ToString(CultureInfo.InvariantCulture) + ":" + : string.Empty) + item.value)); + } + + return InspectGraphCursorCodec.BuildQueryFingerprint(components); + } + + private JsonObject CreateMcpCursorError( + JsonNode? id, + string toolName, + string errorCode, + string message, + bool stale) + => CreateToolErrorResponse( + id, + message, + category: stale ? McpErrorEnvelope.CategoryIndexStale : McpErrorEnvelope.CategoryInvalidArgument, + suggestion: stale + ? $"The index changed after this {toolName} cursor was issued. Restart pagination without cursor." + : $"Use the exact next_cursor returned by the previous {toolName} page with unchanged filters, format, and limit.", + retrySafe: stale, + extraData: new JsonObject + { + ["error_code"] = errorCode, + ["tool"] = toolName, + ["restart_required"] = true, + ["max_cursor_characters"] = MaxMcpQueryCursorCharacters, + }); + + private JsonObject? ValidateMcpQueryCursor( + JsonNode? id, + string toolName, + McpQueryCursor? cursor, + string queryFingerprint, + string generationFingerprint, + int totalCount) + { + if (cursor is null) + return null; + if (!string.Equals(cursor.QueryFingerprint, queryFingerprint, StringComparison.Ordinal)) + { + return CreateMcpCursorError( + id, + toolName, + "cursor_query_mismatch", + $"cursor does not match this {toolName} query, filters, format, or limit.", + stale: false); + } + if (!string.Equals(cursor.GenerationFingerprint, generationFingerprint, StringComparison.Ordinal)) + { + return CreateMcpCursorError( + id, + toolName, + "cursor_stale", + $"cursor is stale because the {toolName} index generation changed.", + stale: true); + } + if (cursor.Offset > totalCount) + { + return CreateMcpCursorError( + id, + toolName, + "cursor_offset_out_of_range", + $"cursor offset {cursor.Offset.ToString(CultureInfo.InvariantCulture)} exceeds the current {toolName} result count {totalCount.ToString(CultureInfo.InvariantCulture)}.", + stale: false); + } + + return null; + } + + private static void AddMcpPaginationEnvelope( + JsonObject payload, + int totalCount, + int returnedCount, + int offset, + int pageLimit, + string queryFingerprint, + (string Fingerprint, string? StableAt) generation) + { + var nextOffset = checked(offset + returnedCount); + var hasMore = nextOffset < totalCount; + payload["returned_count"] = returnedCount; + payload["total_count"] = totalCount; + payload["total_count_authoritative"] = true; + payload["omitted_count"] = Math.Max(0, totalCount - returnedCount); + payload["remaining_count"] = Math.Max(0, totalCount - nextOffset); + payload["cursor_offset"] = offset; + payload["page_limit"] = pageLimit; + payload["has_more"] = hasMore; + payload["truncated"] = hasMore; + payload["more_available"] = hasMore; + payload["result_stable_at"] = generation.StableAt; + payload["next_cursor"] = hasMore + ? JsonEnvelopeWrapper.FormatResponseCursor( + nextOffset, + queryFingerprint, + generation.Fingerprint) + : null; + } +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index 36a8047dd..a796fad55 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -141,6 +141,7 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) ["batch_query_max_response_bytes"] = MaxBatchQueryResponseByteLimit, ["batch_query_max_queries"] = MaxBatchQuerySize, ["max_pagination_offset"] = MaxMcpPaginationOffset, + ["max_query_cursor_characters"] = MaxMcpQueryCursorCharacters, ["max_json_depth"] = MaxJsonDepth, ["max_batch_requests"] = MaxBatchRequestCount, ["json_rpc_batch_max_requests"] = MaxBatchRequestCount, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs index fcc176f8c..ff01ae7e9 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs @@ -49,6 +49,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var includeGenerated = args?["includeGenerated"]?.GetValue() ?? false; if (!TryReadSinceArgument(args, out var since, out var sinceError)) return CreateToolErrorResponse(id, sinceError!); if (!TryResolveNameExactArgument(args, "symbols", out var exact, out var exactError)) @@ -59,6 +60,19 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) if (ValidateResponseFormat(format) is string formatError) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; + var rawCursor = args?["cursor"]?.GetValue(); + if (countOnly && rawCursor is not null) + return CreateToolErrorResponse(id, "cursor is not supported when symbols uses countOnly or format=count."); + McpQueryCursor? cursor = null; + if (rawCursor is not null && !TryParseMcpQueryCursor(rawCursor, out cursor)) + { + return CreateMcpCursorError( + id, + "symbols", + "cursor_malformed", + "cursor must be an opaque response:v2 next_cursor returned by symbols.", + stale: false); + } // Merge query + names into a de-duplicated OR list. `|` is treated as a literal name character // so operator symbols (e.g. `operator |`) stay searchable; multi-name must use repeated `names[]`. @@ -75,7 +89,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, $"Too many symbol names ({queriesForSearch.Count}); maximum is {QueryCommandRunner.MaxSymbolQueryNames}. Split the request into smaller batches."); IReadOnlyList? effectiveQueries = queriesForSearch.Count == 0 ? null : queriesForSearch; - return WithDbReader(id, args, reader => + return WithDbReader(id, args, reader => reader.RunInReadSnapshot(() => { JsonNode? namesEcho = effectiveQueries == null ? null : JsonSerializer.SerializeToNode(effectiveQueries, _jsonOptions); var hasExactPredicate = exact && effectiveQueries is { Count: > 0 }; @@ -99,7 +113,61 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countSummary.Count, "symbol")}.", payload); } - var results = reader.SearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + var queryFingerprint = BuildMcpQueryFingerprint( + "symbols", + limit, + format, + new Dictionary + { + ["query"] = query, + ["kind"] = kind, + ["lang"] = lang, + ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ["exact"] = exact ? "true" : "false", + ["exclude-tests"] = excludeTests ? "true" : "false", + ["include-generated"] = includeGenerated ? "true" : "false", + }, + ("names", effectiveQueries, PreserveOrder: true), + ("path", pathPatterns, PreserveOrder: false), + ("exclude-path", excludePaths, PreserveOrder: false), + ("visibility", visibilityFilters, PreserveOrder: false), + ("exclude-visibility", excludeVisibilityFilters, PreserveOrder: false)); + var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + var total = reader.CountSearchSymbolsTotal( + effectiveQueries, + kind, + lang, + pathPatterns, + excludePaths, + excludeTests, + since, + exact, + visibilityFilters, + excludeVisibilityFilters).Count; + if (ValidateMcpQueryCursor( + id, + "symbols", + cursor, + queryFingerprint, + generation.Fingerprint, + total) is JsonObject cursorError) + { + return cursorError; + } + var offset = cursor?.Offset ?? 0; + var results = reader.SearchSymbols( + effectiveQueries, + limit, + kind, + lang, + pathPatterns, + excludePaths, + excludeTests, + since, + exact, + visibilityFilters, + excludeVisibilityFilters, + offset: offset); var multiNameExactHint = effectiveQueries != null && effectiveQueries.Count > 1; var exactZeroHint = multiNameExactHint ? QueryCommandRunner.BuildExactZeroHint( @@ -126,11 +194,22 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) ["count"] = 0, ["results"] = new JsonArray() }; + AddMcpPaginationEnvelope( + payload, + total, + returnedCount: 0, + offset, + limit, + queryFingerprint, + generation); AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); if (hasExactPredicate) AddExactGraphSignal(payload, exactSignal); - AddExactZeroHint(payload, exactZeroHint); - AddFreshnessHint(payload, reader); + if (total == 0) + { + AddExactZeroHint(payload, exactZeroHint); + AddFreshnessHint(payload, reader); + } adjustments.ApplyTo(payload); return CreateToolResult(id, "No symbols found.", payload); } @@ -146,6 +225,14 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) ["count"] = results.Count, ["results"] = ToJsonArray(results) }; + AddMcpPaginationEnvelope( + structured, + total, + results.Count, + offset, + limit, + queryFingerprint, + generation); AddVisibilityFilterEcho(structured, visibilityFilters, excludeVisibilityFilters); if (format == "compact") { @@ -162,7 +249,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) "Use definition to confirm the declaration for the best symbol candidate; then use references, callers, or callees depending on the change."); adjustments.ApplyTo(structured); return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "symbol"), structured); - }); + })); } private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 60aca61bb..94b589d2c 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1183,15 +1183,62 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, formatError); var countOnly = ReadCountOnly(args) || format == "count"; var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var rawCursor = args?["cursor"]?.GetValue(); + if (countOnly && rawCursor is not null) + return CreateToolErrorResponse(id, "cursor is not supported when validate uses countOnly or format=count."); + McpQueryCursor? cursor = null; + if (rawCursor is not null && !TryParseMcpQueryCursor(rawCursor, out cursor)) + { + return CreateMcpCursorError( + id, + "validate", + "cursor_malformed", + "cursor must be an opaque response:v2 next_cursor returned by validate.", + stale: false); + } - return WithDbReader(id, args, reader => + return WithDbReader(id, args, reader => reader.RunInReadSnapshot(() => { + var queryFingerprint = BuildMcpQueryFingerprint( + "validate", + limit, + format, + new Dictionary + { + ["kind"] = kind, + ["severity"] = severity, + ["exclude-tests"] = excludeTests ? "true" : "false", + }, + ("path", pathPatterns, PreserveOrder: false), + ("exclude-path", excludePaths, PreserveOrder: false)); + var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + var total = reader.CountIssues( + kind, + pathPatterns, + excludePaths, + excludeTests, + severity); + if (ValidateMcpQueryCursor( + id, + "validate", + cursor, + queryFingerprint, + generation.Fingerprint, + total) is JsonObject cursorError) + { + return cursorError; + } + var offset = cursor?.Offset ?? 0; var issues = reader.GetIssues( kind, pathPatterns, - limit: countOnly ? null : FetchLimitForEnvelope(limit), - severity: severity); - var truncated = !countOnly && TrimToRequestedLimit(issues, limit); + excludePaths, + excludeTests, + limit: countOnly ? null : limit, + severity: severity, + offset: offset); QueryCommandRunner.AnnotateValidateIssues(issues); var pathFilterArray = new JsonArray(); if (pathPatterns is not null) @@ -1201,14 +1248,14 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) } var payload = new JsonObject { - ["count"] = issues.Count, - ["truncated"] = truncated, - ["more_available"] = truncated, + ["count"] = countOnly ? total : issues.Count, ["filters"] = new JsonObject { ["kind"] = kind, ["severity"] = severity, ["path"] = pathFilterArray, + ["exclude_paths"] = JsonSerializer.SerializeToNode(excludePaths), + ["exclude_tests"] = excludeTests, }, ["summary"] = QueryCommandRunner.BuildValidateIssueSummary(issues), ["top_files"] = BuildTopFileHistogram(issues, issue => issue.Path), @@ -1226,12 +1273,25 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) { payload["issues"] = JsonSerializer.SerializeToNode(issues, _jsonOptions); } + if (!countOnly) + { + AddMcpPaginationEnvelope( + payload, + total, + issues.Count, + offset, + limit, + queryFingerprint, + generation); + } var summary = issues.Count > 0 ? $"Found {issues.Count} encoding issue(s)." - : "No encoding issues found."; + : total > 0 + ? "No more encoding issues found." + : "No encoding issues found."; adjustments.ApplyTo(payload); return CreateToolResult(id, summary, payload); - }); + })); } private static JsonArray BuildCompactValidateIssues(IEnumerable issues) diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs new file mode 100644 index 000000000..09b1c5569 --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -0,0 +1,321 @@ +using System.Text; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Mcp; +using CodeIndex.Models; + +namespace CodeIndex.Tests; + +public partial class McpServerTests +{ + [Fact] + public void DiscoveryTools_PageEveryRowWithoutGapsOrDuplicates_Issue4853() + { + SeedIssue4853DiscoveryRows(5); + + AssertIssue4853Pages( + "symbols", + new JsonObject + { + ["names"] = new JsonArray("Issue4853", "Symbol"), + ["path"] = "src/pagination4853", + ["limit"] = 2, + ["format"] = "compact", + }, + "name"); + AssertIssue4853Pages( + "files", + new JsonObject + { + ["query"] = "pagination4853", + ["limit"] = 2, + }, + "path"); + AssertIssue4853Pages( + "validate", + new JsonObject + { + ["kind"] = "line_too_long", + ["path"] = "src/pagination4853", + ["limit"] = 2, + ["format"] = "compact", + }, + "path"); + + var empty = CallIssue4853Tool( + _server, + "files", + new JsonObject + { + ["query"] = "no-such-issue-4853-path", + ["limit"] = 2, + }, + id: 20); + Assert.Equal(0, empty["total_count"]!.GetValue()); + Assert.Equal(0, empty["returned_count"]!.GetValue()); + Assert.False(empty["has_more"]!.GetValue()); + Assert.Null(empty["next_cursor"]); + Assert.False(string.IsNullOrWhiteSpace(empty["result_stable_at"]!.GetValue())); + } + + [Fact] + public void DiscoveryTools_ReturnTypedErrorsForMalformedMismatchedAndStaleCursors_Issue4853() + { + SeedIssue4853DiscoveryRows(3); + var arguments = new JsonObject + { + ["query"] = "Issue4853Symbol", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + }; + var first = CallIssue4853Tool(_server, "symbols", arguments, id: 1); + var cursor = first["next_cursor"]!.GetValue(); + + var malformed = CallIssue4853ToolError( + _server, + "symbols", + new JsonObject + { + ["query"] = "Issue4853Symbol", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + ["cursor"] = "not-a-cursor", + }, + id: 2); + Assert.Equal("invalid_argument", malformed["category"]!.GetValue()); + Assert.Equal("cursor_malformed", malformed["error_code"]!.GetValue()); + Assert.True(malformed["restart_required"]!.GetValue()); + + var mismatch = CallIssue4853ToolError( + _server, + "symbols", + new JsonObject + { + ["query"] = "Issue4853Symbol", + ["path"] = "src/pagination4853", + ["kind"] = "class", + ["limit"] = 1, + ["format"] = "compact", + ["cursor"] = cursor, + }, + id: 3); + Assert.Equal("invalid_argument", mismatch["category"]!.GetValue()); + Assert.Equal("cursor_query_mismatch", mismatch["error_code"]!.GetValue()); + + var writer = new DbWriter(_db.Connection); + writer.SetMeta(DbContext.IndexedHeadTimestampMetaKey, "2026-07-28T02:00:00.0000000+00:00"); + using var refreshedServer = new McpServer(_dbPath, "1.0.0-test", dbPathExplicit: true); + var stale = CallIssue4853ToolError( + refreshedServer, + "symbols", + new JsonObject + { + ["query"] = "Issue4853Symbol", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + ["cursor"] = cursor, + }, + id: 4); + Assert.Equal("index_stale", stale["category"]!.GetValue()); + Assert.Equal("cursor_stale", stale["error_code"]!.GetValue()); + Assert.True(stale["retry_safe"]!.GetValue()); + } + + [Fact] + public async Task DiscoveryCursor_IsStatelessAcrossConcurrentClientsAndFitsBoundedResponse_Issue4853() + { + SeedIssue4853DiscoveryRows(4); + var first = CallIssue4853Tool( + _server, + "files", + new JsonObject + { + ["query"] = "pagination4853", + ["limit"] = 1, + }, + id: 1); + var cursor = first["next_cursor"]!.GetValue(); + Assert.StartsWith("response:v2:", cursor, StringComparison.Ordinal); + Assert.InRange(cursor.Length, 1, McpServer.MaxMcpQueryCursorCharacters); + var status = CallIssue4853Tool(_server, "status", new JsonObject(), id: 10); + Assert.Equal( + McpServer.MaxMcpQueryCursorCharacters, + status["mcp"]!["limits"]!["max_query_cursor_characters"]!.GetValue()); + + using var clientOne = new McpServer(_dbPath, "1.0.0-test", dbPathExplicit: true); + using var clientTwo = new McpServer(_dbPath, "1.0.0-test", dbPathExplicit: true); + JsonObject Arguments() => new() + { + ["query"] = "pagination4853", + ["limit"] = 1, + ["cursor"] = cursor, + }; + + var pages = await Task.WhenAll( + Task.Run(() => CallIssue4853Tool(clientOne, "files", Arguments(), id: 2)), + Task.Run(() => CallIssue4853Tool(clientTwo, "files", Arguments(), id: 3))); + + Assert.Equal( + pages[0]["results"]!.ToJsonString(), + pages[1]["results"]!.ToJsonString()); + Assert.Equal(1, pages[0]["cursor_offset"]!.GetValue()); + Assert.True( + clientOne.TrySerializeJsonNodeWithinByteLimitForTests( + CallIssue4853ToolResponse(clientOne, "files", Arguments(), id: 4), + maxBytes: 32 * 1024, + out _, + out var bytesWritten)); + Assert.InRange(bytesWritten, 1, 32 * 1024); + } + + private void AssertIssue4853Pages(string toolName, JsonObject arguments, string identityField) + { + var expectedTotal = 5; + var identities = new List(); + var pageSizes = new List(); + string? cursor = null; + for (var pageNumber = 0; pageNumber < 10; pageNumber++) + { + if (cursor is null) + arguments.Remove("cursor"); + else + arguments["cursor"] = cursor; + + var payload = CallIssue4853Tool(_server, toolName, arguments, id: 100 + pageNumber); + var collectionName = toolName == "validate" ? "issues" : "results"; + var results = payload[collectionName]!.AsArray(); + pageSizes.Add(results.Count); + identities.AddRange(results.Select(result => GetIssue4853Identity(result!, identityField))); + Assert.Equal(expectedTotal, payload["total_count"]!.GetValue()); + Assert.True(payload["total_count_authoritative"]!.GetValue()); + Assert.Equal(results.Count, payload["returned_count"]!.GetValue()); + Assert.Equal(identities.Count - results.Count, payload["cursor_offset"]!.GetValue()); + Assert.False(string.IsNullOrWhiteSpace(payload["result_stable_at"]!.GetValue())); + + if (!payload["has_more"]!.GetValue()) + { + Assert.Null(payload["next_cursor"]); + break; + } + + cursor = payload["next_cursor"]!.GetValue(); + Assert.StartsWith("response:v2:", cursor, StringComparison.Ordinal); + } + + Assert.Equal([2, 2, 1], pageSizes); + Assert.Equal(expectedTotal, identities.Count); + Assert.Equal(expectedTotal, identities.Distinct(StringComparer.Ordinal).Count()); + } + + private void SeedIssue4853DiscoveryRows(int count) + { + var writer = new DbWriter(_db.Connection); + for (var index = 0; index < count; index++) + { + var path = $"src/pagination4853/item-{index:D2}.cs"; + var content = $"public sealed class Issue4853Symbol{index:D2} {{ }}"; + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = Encoding.UTF8.GetByteCount(content), + Lines = 1, + Modified = ManualTimeProvider.FixtureUtcNow.AddMinutes(index).UtcDateTime, + Checksum = $"issue-4853-{index:D2}", + }); + writer.InsertChunks( + [ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 1, + Content = content, + }, + ]); + writer.InsertSymbols( + [ + new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = $"Issue4853Symbol{index:D2}", + Line = 1, + StartLine = 1, + EndLine = 1, + Signature = content, + }, + ]); + writer.InsertIssues( + fileId, + [ + new FileIssue + { + Path = path, + Line = 1, + Kind = "line_too_long", + Severity = FileIssue.SeverityWarning, + Origin = FileIssue.OriginSourceLiteral, + Message = $"Issue 4853 diagnostic {index:D2}", + }, + ]); + } + + writer.MarkIssuesReady(); + writer.SetMeta(DbContext.IndexedHeadTimestampMetaKey, "2026-07-28T01:00:00.0000000+00:00"); + } + + private static JsonObject CallIssue4853Tool( + McpServer server, + string toolName, + JsonObject arguments, + int id) + { + var response = CallIssue4853ToolResponse(server, toolName, arguments, id); + Assert.Null(response["error"]); + Assert.False(response["result"]?["isError"]?.GetValue() ?? false); + return response["result"]!["structuredContent"]!.AsObject(); + } + + private static JsonObject CallIssue4853ToolError( + McpServer server, + string toolName, + JsonObject arguments, + int id) + { + var response = CallIssue4853ToolResponse(server, toolName, arguments, id); + Assert.True(response["result"] is JsonObject, response.ToJsonString()); + var result = response["result"]!.AsObject(); + Assert.True(result["isError"]?.GetValue() ?? false, response.ToJsonString()); + return result["structuredContent"]!.AsObject(); + } + + private static string GetIssue4853Identity(JsonNode result, string identityField) + { + var value = result[identityField] + ?? result[char.ToUpperInvariant(identityField[0]) + identityField[1..]]; + Assert.NotNull(value); + return value!.GetValue(); + } + + private static JsonObject CallIssue4853ToolResponse( + McpServer server, + string toolName, + JsonObject arguments, + int id) + => server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = arguments.DeepClone(), + }, + })!.AsObject(); +} diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index 5d4374c3f..d762c668e 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -103,6 +103,23 @@ public void ToolsList_SearchCursorHasSharedArgumentContract_Issue3192() Assert.Equal("string", validatorType); } + [Fact] + public void ToolsList_HighVolumeDiscoveryCursorsHaveSharedArgumentContract_Issue4853() + { + var advertisedSchemas = GetAdvertisedToolSchemas(); + foreach (var toolName in new[] { "symbols", "files", "validate" }) + { + var properties = advertisedSchemas[toolName]; + Assert.True(properties.ContainsKey("cursor")); + Assert.Contains("cursor", GetAllowedToolArguments(toolName)); + Assert.Equal("string", ExpectedTypeFromSchema(properties["cursor"])); + Assert.Equal((true, "string"), TryGetExpectedJsonType(toolName, "cursor")); + Assert.Equal( + McpServer.MaxMcpQueryCursorCharacters, + properties["cursor"]["maxLength"]!.GetValue()); + } + } + [Fact] public void ToolsList_DepsArgumentsHaveSharedArgumentContract_Issue3196() { @@ -303,7 +320,7 @@ public void ToolsList_OutlineAndValidateDoNotExposeHiddenNoopArguments_Issue3198 var advertisedSchemas = GetAdvertisedToolSchemas(); AssertToolArgumentsExactly(advertisedSchemas, "outline", ["path"]); - AssertToolArgumentsExactly(advertisedSchemas, "validate", ["kind", "severity", "limit", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution"]); + AssertToolArgumentsExactly(advertisedSchemas, "validate", ["kind", "severity", "limit", "cursor", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution"]); AssertNoopArgumentsAbsent(advertisedSchemas, "outline", ["limit", "includeImports", "maxLineWidth", "lang"]); AssertNoopArgumentsAbsent(advertisedSchemas, "validate", ["includeImports", "maxLineWidth", "lang"]); From 7a6c4461a96a45e0b442012b4a9d3dbec169643c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 13:51:45 +0900 Subject: [PATCH 2/8] Address MCP cursor review findings (#4853) --- .../Database/DbReader.FilesStatus.cs | 11 ++++++++++ .../Database/DbWriter.FoldBackfill.cs | 2 +- .../Mcp/McpToolHandlers.Pagination.cs | 18 +++++++++++++++ .../Mcp/McpToolHandlers.Query.Symbols.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 ++ .../McpServerIssue4853Tests.cs | 22 ++++++++++++++++++- .../McpServerToolsCallTests.cs | 2 ++ 7 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 40ae80702..b8579d1f2 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -1914,6 +1914,17 @@ public FreshnessHintResult GetFreshnessHint() return (identity, stableAt); } + internal string GetFoldPaginationGenerationIdentity() + { + var userVersion = ExecuteScalar("PRAGMA user_version"); + return string.Create( + CultureInfo.InvariantCulture, + $"{userVersion & DbContext.FoldReadyFlag}\n" + + $"{TryGetMetaStringInternal("fold_key_version") ?? "no-fold-key-version"}\n" + + $"{TryGetMetaStringInternal("fold_key_fingerprint") ?? "no-fold-key-fingerprint"}\n" + + $"{TryGetMetaStringInternal(DbWriter.FoldBackfillGraphRefreshPendingMetaKey) ?? "no-fold-backfill-pending"}"); + } + private (DateTime? IndexedAt, DateTime? LatestModified) GetWorkspaceFreshness() { return ( diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 53ba324a5..db4e4e06f 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -8,7 +8,7 @@ public partial class DbWriter private const string FoldBackfillPhaseMetaKey = "fold_backfill_phase"; private const string FoldBackfillLastSymbolIdMetaKey = "fold_backfill_last_symbol_id"; private const string FoldBackfillLastReferenceIdMetaKey = "fold_backfill_last_reference_id"; - private const string FoldBackfillGraphRefreshPendingMetaKey = "fold_backfill_graph_refresh_pending"; + internal const string FoldBackfillGraphRefreshPendingMetaKey = "fold_backfill_graph_refresh_pending"; private static readonly AsyncLocal ScopedFoldBackfillRowUpdatedForTesting = new(); private static readonly AsyncLocal ScopedFoldBackfillVerificationForTesting = new(); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs index 705f52d46..e63d23b9c 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs @@ -68,6 +68,24 @@ private static string BuildMcpQueryFingerprint( return InspectGraphCursorCodec.BuildQueryFingerprint(components); } + private static (string Fingerprint, string? StableAt) BuildMcpGenerationFingerprint( + DbReader reader, + bool includeFoldState = false) + { + if (!includeFoldState) + return InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + + var generation = reader.GetPaginationGeneration(); + return ( + InspectGraphCursorCodec.BuildQueryFingerprint( + [ + "mcp-generation:v1", + generation.Identity, + reader.GetFoldPaginationGenerationIdentity(), + ]), + generation.StableAt); + } + private JsonObject CreateMcpCursorError( JsonNode? id, string toolName, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs index ff01ae7e9..58fd3f6e3 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs @@ -132,7 +132,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) ("exclude-path", excludePaths, PreserveOrder: false), ("visibility", visibilityFilters, PreserveOrder: false), ("exclude-visibility", excludeVisibilityFilters, PreserveOrder: false)); - var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + var generation = BuildMcpGenerationFingerprint(reader, includeFoldState: true); var total = reader.CountSearchSymbolsTotal( effectiveQueries, kind, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 94b589d2c..2ab81451b 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1263,6 +1263,8 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) if (countOnly) { payload["format"] = "count"; + payload["truncated"] = false; + payload["more_available"] = false; } else if (format == "compact") { diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs index 09b1c5569..f5f0fc536 100644 --- a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -105,6 +105,26 @@ public void DiscoveryTools_ReturnTypedErrorsForMalformedMismatchedAndStaleCursor Assert.Equal("cursor_query_mismatch", mismatch["error_code"]!.GetValue()); var writer = new DbWriter(_db.Connection); + writer.SetMeta(DbWriter.FoldBackfillGraphRefreshPendingMetaKey, "1"); + using (var foldRefreshedServer = new McpServer(_dbPath, "1.0.0-test", dbPathExplicit: true)) + { + var foldStale = CallIssue4853ToolError( + foldRefreshedServer, + "symbols", + new JsonObject + { + ["query"] = "Issue4853Symbol", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + ["cursor"] = cursor, + }, + id: 4); + Assert.Equal("index_stale", foldStale["category"]!.GetValue()); + Assert.Equal("cursor_stale", foldStale["error_code"]!.GetValue()); + } + + writer.SetMeta(DbWriter.FoldBackfillGraphRefreshPendingMetaKey, null); writer.SetMeta(DbContext.IndexedHeadTimestampMetaKey, "2026-07-28T02:00:00.0000000+00:00"); using var refreshedServer = new McpServer(_dbPath, "1.0.0-test", dbPathExplicit: true); var stale = CallIssue4853ToolError( @@ -118,7 +138,7 @@ public void DiscoveryTools_ReturnTypedErrorsForMalformedMismatchedAndStaleCursor ["format"] = "compact", ["cursor"] = cursor, }, - id: 4); + id: 5); Assert.Equal("index_stale", stale["category"]!.GetValue()); Assert.Equal("cursor_stale", stale["error_code"]!.GetValue()); Assert.True(stale["retry_safe"]!.GetValue()); diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 85a87cd0a..0762f7faa 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -5188,6 +5188,8 @@ public void ToolsCall_Validate_FiltersSeverityAndSupportsCompactCount_Issue3541( var count = countResponse["result"]!["structuredContent"]!; Assert.Equal("count", count["format"]!.GetValue()); Assert.Equal(1, count["count"]!.GetValue()); + Assert.False(count["truncated"]!.GetValue()); + Assert.False(count["more_available"]!.GetValue()); Assert.Null(count["issues"]); } From 75d3f5388bcab49d92800e79acba8f5bb5b24106 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 14:11:45 +0900 Subject: [PATCH 3/8] Complete changelog paths (#4853) --- changelog.d/unreleased/4853.added.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog.d/unreleased/4853.added.md b/changelog.d/unreleased/4853.added.md index e7f7d162b..920a3e280 100644 --- a/changelog.d/unreleased/4853.added.md +++ b/changelog.d/unreleased/4853.added.md @@ -4,8 +4,10 @@ issues: - 4853 affected: - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs - src/CodeIndex/Database/DbReader.cs - src/CodeIndex/Database/DbSymbolReader.Search.cs + - src/CodeIndex/Database/DbWriter.FoldBackfill.cs - src/CodeIndex/Mcp/McpToolArgumentContracts.cs - src/CodeIndex/Mcp/McpToolCatalog.cs - src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -14,6 +16,7 @@ affected: - src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs - src/CodeIndex/Mcp/McpToolHandlers.cs - tests/CodeIndex.Tests/McpServerIssue4853Tests.cs + - tests/CodeIndex.Tests/McpServerToolsCallTests.cs - tests/CodeIndex.Tests/McpToolContractTests.cs - DEVELOPER_GUIDE.md - TESTING_GUIDE.md From 81d94c1fe6199eb8faf7ab46424dafc6401311a8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 14:38:24 +0900 Subject: [PATCH 4/8] Bind MCP cursors to committed writes (#4853) --- DEVELOPER_GUIDE.md | 4 +- .../Database/DbReader.FilesStatus.cs | 14 ++- .../McpServerIssue4853Tests.cs | 116 ++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 4dd935e64..e74a608ec 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -901,7 +901,7 @@ MCP `search` responses include `result_stable_at`, copied from the index freshne Non-empty `search` responses also include `next_cursor`. Passing that value back as the `cursor` argument with the same query and filters continues after the last returned `(score, chunk rowid)` anchor. The cursor is an opaque response value; clients should not construct or edit it. -The high-volume discovery tools `symbols`, `files`, and `validate` also accept an opaque `cursor`. Every non-count response reports `returned_count`, authoritative `total_count`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `result_stable_at`, and `next_cursor`; the final or empty page returns `has_more: false` and `next_cursor: null`. Each page reads its generation, total, and rows in one SQLite snapshot, and deterministic ordering lets clients enumerate all rows without gaps or duplicates. +The high-volume discovery tools `symbols`, `files`, and `validate` also accept an opaque `cursor`. Every non-count response reports `returned_count`, authoritative `total_count`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `result_stable_at`, and `next_cursor`; the final or empty page returns `has_more: false` and `next_cursor: null`. Each page reads its generation, total, and rows in one SQLite snapshot, and deterministic ordering lets clients enumerate all rows without gaps or duplicates. The generation includes the persisted monotonic indexed-file write counter, so separately committed indexing batches invalidate older tokens even when they update existing files within the same timestamp second. Pass `next_cursor` back to the same tool with the exact same filters, `format`, and `limit`. These tokens are stateless and bound to both that normalized query and the index generation. Invalid tokens return `cursor_malformed`, changed arguments return `cursor_query_mismatch`, out-of-range offsets return `cursor_offset_out_of_range`, and an intervening index generation returns `cursor_stale` with the `index_stale` category. In all four cases, discard the token and restart without `cursor`. `countOnly: true` and `format: "count"` do not accept a cursor. The `status` tool publishes the token input bound as `mcp.limits.max_query_cursor_characters`. @@ -4122,7 +4122,7 @@ MCP `search` response には、その call が使った DB snapshot の index fr non-empty な `search` response には `next_cursor` も含める。同じ query と filter でその値を `cursor` argument として渡すと、最後に返した `(score, chunk rowid)` anchor の後から継続する。cursor は opaque な response value であり、client が構築・編集してはいけない。 -大量の discovery 結果を返す `symbols`、`files`、`validate` も opaque な `cursor` を受け付ける。count 以外の全 response は `returned_count`、authoritative な `total_count`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`result_stable_at`、`next_cursor` を報告し、最終 page または空 page は `has_more: false` と `next_cursor: null` を返す。各 page は generation、total、row を単一 SQLite snapshot で読み、決定的な順序により gap や duplicate なしで全 row を列挙できる。 +大量の discovery 結果を返す `symbols`、`files`、`validate` も opaque な `cursor` を受け付ける。count 以外の全 response は `returned_count`、authoritative な `total_count`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`result_stable_at`、`next_cursor` を報告し、最終 page または空 page は `has_more: false` と `next_cursor: null` を返す。各 page は generation、total、row を単一 SQLite snapshot で読み、決定的な順序により gap や duplicate なしで全 row を列挙できる。generation には永続的で単調増加する indexed-file write counter を含めるため、同じ timestamp 秒内に既存 file を更新した別 commit の indexing batch でも古い token は invalid になる。 `next_cursor` は、filter、`format`、`limit` を一切変えずに同じ tool へ渡す。token は stateless で、正規化済み query と index generation の両方に束縛される。不正 token は `cursor_malformed`、argument 変更は `cursor_query_mismatch`、範囲外 offset は `cursor_offset_out_of_range`、途中の index generation 変更は `index_stale` category の `cursor_stale` を返す。いずれも token を破棄し、`cursor` なしで最初からやり直す。`countOnly: true` と `format: "count"` は cursor を受け付けない。`status` tool は token 入力上限を `mcp.limits.max_query_cursor_characters` として公開する。 diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index b8579d1f2..c1c390149 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -1906,11 +1906,23 @@ public FreshnessHintResult GetFreshnessHint() var freshness = GetFreshnessHint(); var indexedHeadSha = TryGetMetaStringInternal(DbContext.IndexedHeadShaMetaKey); var indexedHeadTimestamp = TryGetMetaStringInternal(DbContext.IndexedHeadTimestampMetaKey); + // files_resource_generation_* triggers advance this persisted counter inside the + // same transaction as every indexed-file insert/update/delete. Unlike indexed_at, + // it cannot collapse multiple committed indexing batches into the same second. + // files_resource_generation_* trigger は indexed-file の insert/update/delete と + // 同じ transaction 内でこの永続 counter を進めるため、同一秒内の複数 commit + // batch が indexed_at 上で同一 generation に潰れることを防ぐ。 + var committedWriteGeneration = + TryGetMetaStringInternal(DbContext.ResourceListGenerationMetaKey); var indexedAt = freshness.IndexedAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); var stableAt = indexedHeadTimestamp ?? indexedAt; var identity = string.Create( CultureInfo.InvariantCulture, - $"{indexedHeadSha ?? "no-indexed-head"}\n{indexedHeadTimestamp ?? "no-indexed-head-timestamp"}\n{indexedAt ?? "no-indexed-at"}\n{freshness.FileCount}"); + $"{indexedHeadSha ?? "no-indexed-head"}\n" + + $"{indexedHeadTimestamp ?? "no-indexed-head-timestamp"}\n" + + $"{indexedAt ?? "no-indexed-at"}\n" + + $"{freshness.FileCount}\n" + + $"{committedWriteGeneration ?? "no-committed-write-generation"}"); return (identity, stableAt); } diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs index f5f0fc536..ab0c92f5b 100644 --- a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -191,6 +191,122 @@ public async Task DiscoveryCursor_IsStatelessAcrossConcurrentClientsAndFitsBound Assert.InRange(bytesWritten, 1, 32 * 1024); } + [Fact] + public void DiscoveryTools_RejectCursorsAfterSameSecondCommittedIndexBatch_Issue4853() + { + SeedIssue4853DiscoveryRows(3); + using (var fixTimestamp = _db.Connection.CreateCommand()) + { + fixTimestamp.CommandText = + "UPDATE files SET indexed_at = '2099-01-01T00:00:00.0000000Z'"; + fixTimestamp.ExecuteNonQuery(); + } + + var requests = new (string Tool, JsonObject Arguments)[] + { + ( + "symbols", + new JsonObject + { + ["query"] = "Issue4853Symbol", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + }), + ( + "files", + new JsonObject + { + ["query"] = "pagination4853", + ["limit"] = 1, + }), + ( + "validate", + new JsonObject + { + ["kind"] = "line_too_long", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + }), + }; + var cursors = requests.ToDictionary( + request => request.Tool, + request => CallIssue4853Tool( + _server, + request.Tool, + request.Arguments, + id: 30)["next_cursor"]!.GetValue(), + StringComparer.Ordinal); + + string[] beforeGeneration; + using (var reader = new DbReader(_db)) + beforeGeneration = reader.GetPaginationGeneration().Identity.Split('\n'); + + var writer = new DbWriter(_db.Connection); + using (var transaction = writer.BeginTransaction()) + { + const string path = "src/pagination4853/item-00.cs"; + const string replacement = "public sealed class Issue4853Symbol99 { }"; + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = Encoding.UTF8.GetByteCount(replacement), + Lines = 1, + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, + Checksum = "issue-4853-replacement", + }); + writer.InsertSymbols( + [ + new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = "Issue4853Symbol99", + Line = 1, + StartLine = 1, + EndLine = 1, + Signature = replacement, + }, + ]); + writer.InsertIssues( + fileId, + [ + new FileIssue + { + Path = path, + Line = 1, + Kind = "line_too_long", + Severity = FileIssue.SeverityWarning, + Origin = FileIssue.OriginSourceLiteral, + Message = "Issue 4853 replacement diagnostic", + }, + ]); + transaction.Commit(); + } + + string[] afterGeneration; + using (var reader = new DbReader(_db)) + afterGeneration = reader.GetPaginationGeneration().Identity.Split('\n'); + Assert.Equal(beforeGeneration[..4], afterGeneration[..4]); + Assert.NotEqual(beforeGeneration[4], afterGeneration[4]); + + using var refreshedServer = new McpServer(_dbPath, "1.0.0-test", dbPathExplicit: true); + for (var index = 0; index < requests.Length; index++) + { + var request = requests[index]; + request.Arguments["cursor"] = cursors[request.Tool]; + var stale = CallIssue4853ToolError( + refreshedServer, + request.Tool, + request.Arguments, + id: 40 + index); + Assert.Equal("index_stale", stale["category"]!.GetValue()); + Assert.Equal("cursor_stale", stale["error_code"]!.GetValue()); + } + } + private void AssertIssue4853Pages(string toolName, JsonObject arguments, string identityField) { var expectedTotal = 5; From e4f23001df8dd7888ebf88d693b30721d254b720 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 14:57:14 +0900 Subject: [PATCH 5/8] Cover MCP cursor readiness transitions (#4853) --- .../Database/DbReader.FilesStatus.cs | 20 ++++-- .../Mcp/McpToolHandlers.Pagination.cs | 21 ++++--- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 +- .../McpServerIssue4853Tests.cs | 62 +++++++++++++++++++ 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index c1c390149..104473bb9 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -1931,10 +1931,22 @@ internal string GetFoldPaginationGenerationIdentity() var userVersion = ExecuteScalar("PRAGMA user_version"); return string.Create( CultureInfo.InvariantCulture, - $"{userVersion & DbContext.FoldReadyFlag}\n" - + $"{TryGetMetaStringInternal("fold_key_version") ?? "no-fold-key-version"}\n" - + $"{TryGetMetaStringInternal("fold_key_fingerprint") ?? "no-fold-key-fingerprint"}\n" - + $"{TryGetMetaStringInternal(DbWriter.FoldBackfillGraphRefreshPendingMetaKey) ?? "no-fold-backfill-pending"}"); + $"stored-fold-ready-bit:{userVersion & DbContext.FoldReadyFlag}\n" + + $"stored-fold-version:{TryGetMetaStringInternal("fold_key_version") ?? "no-fold-key-version"}\n" + + $"stored-fold-fingerprint:{TryGetMetaStringInternal("fold_key_fingerprint") ?? "no-fold-key-fingerprint"}\n" + + $"effective-fold-ready:{(_foldReady ? "1" : "0")}\n" + + $"runtime-fold-version:{NameFold.Version}\n" + + $"runtime-fold-fingerprint:{NameFold.Fingerprint()}\n" + + $"fold-backfill-pending:{TryGetMetaStringInternal(DbWriter.FoldBackfillGraphRefreshPendingMetaKey) ?? "no-fold-backfill-pending"}"); + } + + internal string GetIssuePaginationGenerationIdentity() + { + var userVersion = ExecuteScalar("PRAGMA user_version"); + return string.Create( + CultureInfo.InvariantCulture, + $"stored-issues-ready-bit:{userVersion & DbContext.IssuesReadyFlag}\n" + + $"issues-table-available:{(_hasIssuesTable ? "1" : "0")}"); } private (DateTime? IndexedAt, DateTime? LatestModified) GetWorkspaceFreshness() diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs index e63d23b9c..97bb7f5ce 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs @@ -70,19 +70,24 @@ private static string BuildMcpQueryFingerprint( private static (string Fingerprint, string? StableAt) BuildMcpGenerationFingerprint( DbReader reader, - bool includeFoldState = false) + bool includeFoldState = false, + bool includeIssueState = false) { - if (!includeFoldState) + if (!includeFoldState && !includeIssueState) return InspectGraphCursorCodec.BuildGenerationFingerprint(reader); var generation = reader.GetPaginationGeneration(); + var components = new List + { + "mcp-generation:v1", + generation.Identity, + }; + if (includeFoldState) + components.Add(reader.GetFoldPaginationGenerationIdentity()); + if (includeIssueState) + components.Add(reader.GetIssuePaginationGenerationIdentity()); return ( - InspectGraphCursorCodec.BuildQueryFingerprint( - [ - "mcp-generation:v1", - generation.Identity, - reader.GetFoldPaginationGenerationIdentity(), - ]), + InspectGraphCursorCodec.BuildQueryFingerprint(components), generation.StableAt); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 2ab81451b..9b79ceb47 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1213,7 +1213,7 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) }, ("path", pathPatterns, PreserveOrder: false), ("exclude-path", excludePaths, PreserveOrder: false)); - var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + var generation = BuildMcpGenerationFingerprint(reader, includeIssueState: true); var total = reader.CountIssues( kind, pathPatterns, diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs index ab0c92f5b..97be4040c 100644 --- a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -144,6 +144,68 @@ public void DiscoveryTools_ReturnTypedErrorsForMalformedMismatchedAndStaleCursor Assert.True(stale["retry_safe"]!.GetValue()); } + [Fact] + public void DiscoveryTools_BindCursorsToEffectiveReadinessAndRuntimeFoldState_Issue4853() + { + SeedIssue4853DiscoveryRows(3); + var validateArguments = new JsonObject + { + ["kind"] = "line_too_long", + ["path"] = "src/pagination4853", + ["limit"] = 1, + ["format"] = "compact", + }; + var validateCursor = CallIssue4853Tool( + _server, + "validate", + validateArguments, + id: 20)["next_cursor"]!.GetValue(); + + string paginationGeneration; + using (var reader = new DbReader(_db)) + { + paginationGeneration = reader.GetPaginationGeneration().Identity; + var foldGeneration = reader.GetFoldPaginationGenerationIdentity(); + Assert.Contains( + $"effective-fold-ready:{(reader._foldReady ? "1" : "0")}", + foldGeneration, + StringComparison.Ordinal); + Assert.Contains( + $"runtime-fold-version:{NameFold.Version}", + foldGeneration, + StringComparison.Ordinal); + Assert.Contains( + $"runtime-fold-fingerprint:{NameFold.Fingerprint()}", + foldGeneration, + StringComparison.Ordinal); + } + + var writer = new DbWriter(_db.Connection); + writer.ClearReadyFlags(); + using (var reader = new DbReader(_db)) + { + Assert.Equal(paginationGeneration, reader.GetPaginationGeneration().Identity); + Assert.Contains( + "stored-issues-ready-bit:0", + reader.GetIssuePaginationGenerationIdentity(), + StringComparison.Ordinal); + } + + using var readinessDemotedServer = new McpServer( + _dbPath, + "1.0.0-test", + dbPathExplicit: true); + validateArguments["cursor"] = validateCursor; + var stale = CallIssue4853ToolError( + readinessDemotedServer, + "validate", + validateArguments, + id: 21); + Assert.Equal("index_stale", stale["category"]!.GetValue()); + Assert.Equal("cursor_stale", stale["error_code"]!.GetValue()); + Assert.True(stale["retry_safe"]!.GetValue()); + } + [Fact] public async Task DiscoveryCursor_IsStatelessAcrossConcurrentClientsAndFitsBoundedResponse_Issue4853() { From 0d974c6c9d0fd72ba26f01646a4f794737993c53 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 15:39:32 +0900 Subject: [PATCH 6/8] Address final MCP cursor review findings (#4853) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4853.added.md | 4 +- .../Database/DbSymbolReader.Search.cs | 10 +++ .../Mcp/McpToolHandlers.Pagination.cs | 5 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 7 +- .../McpServerIssue4853Tests.cs | 72 +++++++++++++++++++ 7 files changed, 97 insertions(+), 9 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e74a608ec..899429b6e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -901,7 +901,7 @@ MCP `search` responses include `result_stable_at`, copied from the index freshne Non-empty `search` responses also include `next_cursor`. Passing that value back as the `cursor` argument with the same query and filters continues after the last returned `(score, chunk rowid)` anchor. The cursor is an opaque response value; clients should not construct or edit it. -The high-volume discovery tools `symbols`, `files`, and `validate` also accept an opaque `cursor`. Every non-count response reports `returned_count`, authoritative `total_count`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `result_stable_at`, and `next_cursor`; the final or empty page returns `has_more: false` and `next_cursor: null`. Each page reads its generation, total, and rows in one SQLite snapshot, and deterministic ordering lets clients enumerate all rows without gaps or duplicates. The generation includes the persisted monotonic indexed-file write counter, so separately committed indexing batches invalidate older tokens even when they update existing files within the same timestamp second. +The high-volume discovery tools `symbols`, `files`, and `validate` also accept an opaque `cursor`. Every non-count response reports `returned_count`, `total_count`, `total_count_authoritative`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `result_stable_at`, and `next_cursor`; the final or empty page returns `has_more: false` and `next_cursor: null`. Totals are authoritative for `symbols` and `files`, and for `validate` while `file_issues_data_current` is true. If validation data is unavailable or not current, `validate` reports `total_count_authoritative: false` together with `issues_table_available` and `file_issues_data_current` instead of presenting its synthetic zero as a clean result. Each page reads its generation, total, and rows in one SQLite snapshot, and deterministic ordering lets clients enumerate all rows without gaps or duplicates. The generation includes the persisted monotonic indexed-file write counter, so separately committed indexing batches invalidate older tokens even when they update existing files within the same timestamp second. Pass `next_cursor` back to the same tool with the exact same filters, `format`, and `limit`. These tokens are stateless and bound to both that normalized query and the index generation. Invalid tokens return `cursor_malformed`, changed arguments return `cursor_query_mismatch`, out-of-range offsets return `cursor_offset_out_of_range`, and an intervening index generation returns `cursor_stale` with the `index_stale` category. In all four cases, discard the token and restart without `cursor`. `countOnly: true` and `format: "count"` do not accept a cursor. The `status` tool publishes the token input bound as `mcp.limits.max_query_cursor_characters`. @@ -4122,7 +4122,7 @@ MCP `search` response には、その call が使った DB snapshot の index fr non-empty な `search` response には `next_cursor` も含める。同じ query と filter でその値を `cursor` argument として渡すと、最後に返した `(score, chunk rowid)` anchor の後から継続する。cursor は opaque な response value であり、client が構築・編集してはいけない。 -大量の discovery 結果を返す `symbols`、`files`、`validate` も opaque な `cursor` を受け付ける。count 以外の全 response は `returned_count`、authoritative な `total_count`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`result_stable_at`、`next_cursor` を報告し、最終 page または空 page は `has_more: false` と `next_cursor: null` を返す。各 page は generation、total、row を単一 SQLite snapshot で読み、決定的な順序により gap や duplicate なしで全 row を列挙できる。generation には永続的で単調増加する indexed-file write counter を含めるため、同じ timestamp 秒内に既存 file を更新した別 commit の indexing batch でも古い token は invalid になる。 +大量の discovery 結果を返す `symbols`、`files`、`validate` も opaque な `cursor` を受け付ける。count 以外の全 response は `returned_count`、`total_count`、`total_count_authoritative`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`result_stable_at`、`next_cursor` を報告し、最終 page または空 page は `has_more: false` と `next_cursor: null` を返す。`symbols` と `files` の total、および `file_issues_data_current` が true のときの `validate` の total は authoritative である。validation data が利用不能または current でない場合、`validate` は合成した 0 件を clean result として見せず、`issues_table_available` と `file_issues_data_current` とともに `total_count_authoritative: false` を報告する。各 page は generation、total、row を単一 SQLite snapshot で読み、決定的な順序により gap や duplicate なしで全 row を列挙できる。generation には永続的で単調増加する indexed-file write counter を含めるため、同じ timestamp 秒内に既存 file を更新した別 commit の indexing batch でも古い token は invalid になる。 `next_cursor` は、filter、`format`、`limit` を一切変えずに同じ tool へ渡す。token は stateless で、正規化済み query と index generation の両方に束縛される。不正 token は `cursor_malformed`、argument 変更は `cursor_query_mismatch`、範囲外 offset は `cursor_offset_out_of_range`、途中の index generation 変更は `index_stale` category の `cursor_stale` を返す。いずれも token を破棄し、`cursor` なしで最初からやり直す。`countOnly: true` と `format: "count"` は cursor を受け付けない。`status` tool は token 入力上限を `mcp.limits.max_query_cursor_characters` として公開する。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index a51036ac0..4e33342f3 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -530,7 +530,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding MCP JSON-RPC behavior and tool outputs. Large server coverage is split into focused partial suites for tool calls, tool listing, protocol/session handling, and error handling while the root `McpServerTests` part keeps shared seeded fixture state. Request-timeout tests use signal-gated delay hooks instead of fixed sleeps: start the request, confirm the hook has begun, then await the timeout response with a bounded wait so they pay only the configured timeout while still proving in-flight actions drain after the timeout response. The single-request timeout-lease regression uses an ID-specific dispatch signal, a one-second execution timeout for scheduler headroom, typed response-node assertions, and `TestDeterminism.AssertTaskRemainsBlockedAsync` for the queued request. Keep those checks together so full-suite load produces an actionable assertion instead of a null dereference (#4807). The root seeded database/server fixture is initialized through a thread-safe `Lazy` only when a test accesses its default fixture path. Static helpers and tests that build their own server or transport must not pay schema creation and seed cost; concurrent fixture access must still publish exactly one database/server pair. - High-volume discovery cursor coverage must consume `symbols`, `files`, and `validate` through the final page and assert authoritative totals, deterministic no-gap/no-duplicate enumeration, empty/final-page metadata, bounded opaque tokens, stateless reuse by concurrent server instances, and typed malformed, query-mismatch, and stale-generation failures. Seed only the rows needed by that focused partial suite and change the persisted generation before opening a fresh server for stale-token assertions. + High-volume discovery cursor coverage must consume `symbols`, `files`, and `validate` through the final page and assert authoritative totals, deterministic no-gap/no-duplicate enumeration, empty/final-page metadata, bounded opaque tokens, stateless reuse by concurrent server instances, and typed malformed, query-mismatch, and stale-generation failures. Keep an exact qualified Rust symbol query on the total-count path, and prove demoted issue readiness makes `validate` report a non-authoritative zero with explicit table/currentness signals. Seed only the rows needed by that focused partial suite and change the persisted generation before opening a fresh server for stale-token assertions. Protocol negotiation coverage keeps `2025-06-18`, `2025-03-26`, and `2024-11-05` in one shared version-echo fixture and asserts the exact server-side capability keys for every version. The Codex compatibility regression separately uses the lifecycle-enforcing transport to send a `2025-06-18` initialize, `notifications/initialized`, and `tools/list`, because a direct handler assertion would not catch initialization-gate failures. Transport transcripts must also prove that a second initialize receives `duplicate_initialize` without mutating the session, and that `notifications/initialized` triggers `roots/list` only when the client advertised roots support. Signal-gated coverage must repeat initialized while the first roots response is blocked, prove only one client request starts and teardown drains it, and force both bounded drain deadlines to expire while a late roots write remains blocked to prove stdio resource disposal stays deferred. Release a timeout-delayed initialize worker after its frame cleanup to prove a corrected retry is accepted. Request-id telemetry coverage uses credential-shaped and high-cardinality ids to prove raw values never reach stderr prefixes/events, Activity tags, MCP metrics, audit records, or timeout logs/status. Assert the fixed opaque token plus consistent type and decoded string-value UTF-16 code-unit length (`null` = `0`), different injected process salts, JSON type domain separation for equal textual values, and collapse to one overflow token after the distinct-id budget (including concurrent creation). Keep a CLI metrics negative case that omits all request-id fields. Keep failed-then-success initialize isolation in the protocol/session suite: assert caller, client info, roots, capabilities, initialization lifecycle, and session ID after negotiation or success-response serialization failure and again after the corrected handshake, so failed metadata cannot poison the accepted session (#4540). Signal-gated duplicate-initialize coverage must hold a concurrent status reader on its captured snapshot and an in-flight `roots/list` response from the accepted handshake, proving readers see one complete state and a rejected duplicate cannot replace caller metadata or roots. @@ -1426,7 +1426,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" MCP の JSON-RPC 挙動とツール出力のテスト。大きな server coverage は tool call、tool listing、protocol/session handling、error handling ごとの focused partial suite に分割し、共有の seed 済み fixture 状態は root 側の `McpServerTests` に残します。request-timeout test は固定 sleep ではなく signal-gated delay hook を使います。request を開始し、hook が始まったことを確認してから timeout response を bounded wait で待つことで、timeout response 後に in-flight action が drain されることは保ったまま、設定した timeout 分だけを待つようにします。 single-request の timeout-lease 回帰テストでは、ID 別の dispatch signal、scheduler の余裕を確保する 1 秒の execution timeout、型付き response-node assertion、queue 待ち request に対する `TestDeterminism.AssertTaskRemainsBlockedAsync` を使います。full-suite 負荷でも null 参照ではなく対応可能な assertion を返すよう、これらの検証をまとめて維持してください(#4807)。 root の seed 済み database/server fixture は、test が既定 fixture path へアクセスした場合だけ thread-safe な `Lazy` で初期化します。static helper や独自 server / transport を構築する test は未使用 schema の作成・seed cost を支払わず、並行 fixture access でも database/server pair を必ず1組だけ公開してください。 - 大量 discovery 用 cursor の coverage では、`symbols`、`files`、`validate` を最終 page まで消費し、authoritative な total、gap・duplicate のない決定的列挙、空・最終 page metadata、上限内の opaque token、並行 server instance による stateless reuse、不正・query mismatch・stale generation の型付き failure を検証してください。focused partial suite に必要な row だけを seed し、stale token の assertion では永続化 generation を変更してから新しい server を開いてください。 + 大量 discovery 用 cursor の coverage では、`symbols`、`files`、`validate` を最終 page まで消費し、authoritative な total、gap・duplicate のない決定的列挙、空・最終 page metadata、上限内の opaque token、並行 server instance による stateless reuse、不正・query mismatch・stale generation の型付き failure を検証してください。total-count 経路には Rust の exact な完全修飾 symbol query も保持し、issue readiness を demote したときは `validate` が table/currentness signal とともに non-authoritative な 0 件を報告することも検証してください。focused partial suite に必要な row だけを seed し、stale token の assertion では永続化 generation を変更してから新しい server を開いてください。 protocol negotiation coverage は `2025-06-18`、`2025-03-26`、`2024-11-05` を共通の version-echo fixture にまとめ、全 version で server-side capability の正確な key を検証します。Codex 互換性の回帰テストでは別途 lifecycle を強制する transport を使い、`2025-06-18` の initialize、`notifications/initialized`、`tools/list` までを送ります。direct handler の assertion だけでは initialization gate の失敗を検出できないためです。transport transcript では、2回目の initialize が session を変更せず `duplicate_initialize` を返すことと、client が roots support を提示した場合だけ `notifications/initialized` の後に `roots/list` を送ることも検証してください。signal-gated coverage では最初の roots response を block したまま initialized を再送し、client request が1件だけ開始され teardown で drain されることを確認します。さらに、遅い roots write を block したまま両 bounded drain deadline を期限切れにし、stdio resource の dispose が引き続き defer されることを証明してください。また、frame cleanup 後に timeout で遅れた initialize worker を解放し、修正済み retry が受理されることを証明してください。 request-id telemetry coverage では credential 風および high-cardinality な id を使い、生値が stderr の prefix / event、Activity tag、MCP metrics、audit record、timeout log / status のどこにも出ないことを検証します。固定長 opaque token と、型および decode 後の string 値の UTF-16 code unit 数(`null` は `0`)が全 surface で一致することに加え、注入した process salt ごとの差、text が同じ JSON type 間の domain separation、concurrent creation を含む distinct-id budget 超過後の単一 overflow token への集約を確認してください。CLI metrics では request-id field をすべて省略する negative case を維持してください。 failed-then-success initialize の分離は protocol/session suite に維持し、交渉失敗または success response の serialization 失敗直後と、修正した handshake 後の caller、client info、roots、capabilities、initialization lifecycle、session ID を検証して、失敗 metadata が受理済み session を汚染できないようにします(#4540)。signal-gated な duplicate-initialize coverage では、並行 status reader を取得済み snapshot で保持し、受理済み handshake の進行中 `roots/list` response を保持することで、reader が完全な 1 state だけを見ることと、拒否された重複 initialize が caller metadata や roots を置き換えられないことも検証します。 diff --git a/changelog.d/unreleased/4853.added.md b/changelog.d/unreleased/4853.added.md index 920a3e280..83a010e4f 100644 --- a/changelog.d/unreleased/4853.added.md +++ b/changelog.d/unreleased/4853.added.md @@ -24,8 +24,8 @@ affected: ## English -- **High-volume MCP discovery tools now provide stable continuation tokens (#4853)** — `symbols`, `files`, and `validate` return authoritative pagination metadata and stateless, generation-bound `next_cursor` values, with deterministic full enumeration and typed errors for malformed, mismatched, or stale tokens. +- **High-volume MCP discovery tools now provide stable continuation tokens (#4853)** — `symbols`, `files`, and current `validate` data return authoritative pagination metadata and stateless, generation-bound `next_cursor` values, with deterministic full enumeration and typed errors for malformed, mismatched, or stale tokens. Degraded validation data is explicitly non-authoritative instead of presenting a synthetic zero as clean. ## 日本語 -- **大量の結果を返す MCP discovery tool に安定した continuation token を追加しました (#4853)** — `symbols`、`files`、`validate` は authoritative な pagination metadata と generation に束縛された stateless な `next_cursor` を返し、決定的な全件列挙と、不正・不一致・stale token に対する型付き error を提供します。 +- **大量の結果を返す MCP discovery tool に安定した continuation token を追加しました (#4853)** — `symbols`、`files`、current な `validate` data は authoritative な pagination metadata と generation に束縛された stateless な `next_cursor` を返し、決定的な全件列挙と、不正・不一致・stale token に対する型付き error を提供します。degraded な validation data は合成した 0 件を clean と見せず、明示的に non-authoritative と報告します。 diff --git a/src/CodeIndex/Database/DbSymbolReader.Search.cs b/src/CodeIndex/Database/DbSymbolReader.Search.cs index 95f0ceb23..5139a3eaa 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Search.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Search.cs @@ -395,6 +395,16 @@ FROM symbols s } if (SqlNameResolver.HasQualifier(value)) AddQualifiedSymbolQueryParameters(cmd, $"query{i}", value); + var rustQualifiedExact = ShouldPreserveRustQualifiedExactQuery(value, lang, exact); + var rustQualifiedParts = rustQualifiedExact + ? NormalizeRustQualifiedExactQueryParts(value) + : default; + if (rustQualifiedParts.QualifiedPath != null) + { + SqliteCommandPolicy.Add(cmd, $"@query{i}RustContainer", rustQualifiedParts.ContainerPath ?? string.Empty); + SqliteCommandPolicy.Add(cmd, $"@query{i}RustLeaf", rustQualifiedParts.LeafName ?? string.Empty); + SqliteCommandPolicy.Add(cmd, $"@query{i}RustLeafFolded", NameFold.Fold(rustQualifiedParts.LeafName ?? string.Empty) ?? rustQualifiedParts.LeafName ?? string.Empty); + } var swiftBacktickAlias = ComputeSwiftBacktickAlias(value, lang); if (swiftBacktickAlias != null) { diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs index 97bb7f5ce..bb49c14a2 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs @@ -161,13 +161,14 @@ private static void AddMcpPaginationEnvelope( int offset, int pageLimit, string queryFingerprint, - (string Fingerprint, string? StableAt) generation) + (string Fingerprint, string? StableAt) generation, + bool totalCountAuthoritative = true) { var nextOffset = checked(offset + returnedCount); var hasMore = nextOffset < totalCount; payload["returned_count"] = returnedCount; payload["total_count"] = totalCount; - payload["total_count_authoritative"] = true; + payload["total_count_authoritative"] = totalCountAuthoritative; payload["omitted_count"] = Math.Max(0, totalCount - returnedCount); payload["remaining_count"] = Math.Max(0, totalCount - nextOffset); payload["cursor_offset"] = offset; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 9b79ceb47..afbd917ee 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1214,6 +1214,8 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) ("path", pathPatterns, PreserveOrder: false), ("exclude-path", excludePaths, PreserveOrder: false)); var generation = BuildMcpGenerationFingerprint(reader, includeIssueState: true); + var issuesTableAvailable = reader._hasIssuesPhysicalTable; + var issuesDataCurrent = reader._hasIssuesTable; var total = reader.CountIssues( kind, pathPatterns, @@ -1259,6 +1261,8 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) }, ["summary"] = QueryCommandRunner.BuildValidateIssueSummary(issues), ["top_files"] = BuildTopFileHistogram(issues, issue => issue.Path), + ["issues_table_available"] = issuesTableAvailable, + ["file_issues_data_current"] = issuesDataCurrent, }; if (countOnly) { @@ -1284,7 +1288,8 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) offset, limit, queryFingerprint, - generation); + generation, + totalCountAuthoritative: issuesDataCurrent); } var summary = issues.Count > 0 ? $"Found {issues.Count} encoding issue(s)." diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs index 97be4040c..39e828091 100644 --- a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -144,6 +144,67 @@ public void DiscoveryTools_ReturnTypedErrorsForMalformedMismatchedAndStaleCursor Assert.True(stale["retry_safe"]!.GetValue()); } + [Fact] + public void Symbols_ExactQualifiedRustQueryBindsPaginationTotalParameters_Issue4853() + { + const string path = "src/pagination4853/lib.rs"; + var writer = new DbWriter(_db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "rust", + Size = 80, + Lines = 4, + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, + Checksum = "issue-4853-rust-qualified", + }); + writer.InsertSymbols( + [ + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "build", + Line = 2, + StartLine = 2, + EndLine = 2, + ContainerKind = "module", + ContainerName = "macros", + ContainerQualifiedName = "crate::macros", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "build", + Line = 4, + StartLine = 4, + EndLine = 4, + ContainerKind = "module", + ContainerName = "other", + ContainerQualifiedName = "crate::other", + }, + ]); + + var payload = CallIssue4853Tool( + _server, + "symbols", + new JsonObject + { + ["query"] = "crate::macros::build", + ["lang"] = "rust", + ["exactName"] = true, + ["limit"] = 1, + ["format"] = "compact", + }, + id: 23); + + Assert.Equal(1, payload["total_count"]!.GetValue()); + Assert.True(payload["total_count_authoritative"]!.GetValue()); + var result = Assert.Single(payload["results"]!.AsArray()); + Assert.Equal("build", result!["name"]!.GetValue()); + } + [Fact] public void DiscoveryTools_BindCursorsToEffectiveReadinessAndRuntimeFoldState_Issue4853() { @@ -204,6 +265,17 @@ public void DiscoveryTools_BindCursorsToEffectiveReadinessAndRuntimeFoldState_Is Assert.Equal("index_stale", stale["category"]!.GetValue()); Assert.Equal("cursor_stale", stale["error_code"]!.GetValue()); Assert.True(stale["retry_safe"]!.GetValue()); + + validateArguments.Remove("cursor"); + var degraded = CallIssue4853Tool( + readinessDemotedServer, + "validate", + validateArguments, + id: 22); + Assert.Equal(0, degraded["total_count"]!.GetValue()); + Assert.False(degraded["total_count_authoritative"]!.GetValue()); + Assert.True(degraded["issues_table_available"]!.GetValue()); + Assert.False(degraded["file_issues_data_current"]!.GetValue()); } [Fact] From 8a07855efe651a4fe31022f069d846d0b02aa084 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 17:07:33 +0900 Subject: [PATCH 7/8] Clarify degraded MCP validation results (#4853) --- src/CodeIndex/Mcp/McpToolCatalog.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 14 +++++++++----- tests/CodeIndex.Tests/McpServerIssue4853Tests.cs | 8 +++++++- tests/CodeIndex.Tests/McpToolContractTests.cs | 10 ++++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 8c2c2e3a0..ed3ddf177 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -421,7 +421,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "validate", - "Report encoding issues found during indexing: U+FFFD replacement chars, BOM markers, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings. replacement_char rows include origin/severity metadata so agents can separate source literals from decoder replacements. Page metadata includes authoritative totals, `result_stable_at`, and an opaque generation-bound `next_cursor`; pass it back unchanged with the same filters, format, and limit. / インデックス時に検出したエンコーディング問題を報告。replacement_char 行は source literal と decoder replacement を分ける origin/severity metadata を含む。ページ metadata は authoritative total、`result_stable_at`、generation-bound な opaque `next_cursor` を含む。同じ filter / format / limit で cursor を変更せず渡す。", + "Report encoding issues found during indexing: U+FFFD replacement chars, BOM markers, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings. replacement_char rows include origin/severity metadata so agents can separate source literals from decoder replacements. Page metadata includes totals that are authoritative only while `file_issues_data_current` is true, plus `result_stable_at` and an opaque generation-bound `next_cursor`; pass it back unchanged with the same filters, format, and limit. / インデックス時に検出したエンコーディング問題を報告。replacement_char 行は source literal と decoder replacement を分ける origin/severity metadata を含む。ページ metadata は `file_issues_data_current` が true の間だけ authoritative な total、`result_stable_at`、generation-bound な opaque `next_cursor` を含む。同じ filter / format / limit で cursor を変更せず渡す。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index afbd917ee..cca5a9d59 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1291,11 +1291,15 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) generation, totalCountAuthoritative: issuesDataCurrent); } - var summary = issues.Count > 0 - ? $"Found {issues.Count} encoding issue(s)." - : total > 0 - ? "No more encoding issues found." - : "No encoding issues found."; + var summary = !issuesDataCurrent + ? issuesTableAvailable + ? "Validation issue data is not current; results are non-authoritative." + : "Validation issue data is unavailable; results are non-authoritative." + : issues.Count > 0 + ? $"Found {issues.Count} encoding issue(s)." + : total > 0 + ? "No more encoding issues found." + : "No encoding issues found."; adjustments.ApplyTo(payload); return CreateToolResult(id, summary, payload); })); diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs index 39e828091..03d601d79 100644 --- a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -267,11 +267,17 @@ public void DiscoveryTools_BindCursorsToEffectiveReadinessAndRuntimeFoldState_Is Assert.True(stale["retry_safe"]!.GetValue()); validateArguments.Remove("cursor"); - var degraded = CallIssue4853Tool( + var degradedResponse = CallIssue4853ToolResponse( readinessDemotedServer, "validate", validateArguments, id: 22); + Assert.Null(degradedResponse["error"]); + Assert.False(degradedResponse["result"]?["isError"]?.GetValue() ?? false); + Assert.Equal( + "Validation issue data is not current; results are non-authoritative.", + degradedResponse["result"]!["content"]![0]!["text"]!.GetValue()); + var degraded = degradedResponse["result"]!["structuredContent"]!.AsObject(); Assert.Equal(0, degraded["total_count"]!.GetValue()); Assert.False(degraded["total_count_authoritative"]!.GetValue()); Assert.True(degraded["issues_table_available"]!.GetValue()); diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index d762c668e..e76d29ebe 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -118,6 +118,16 @@ public void ToolsList_HighVolumeDiscoveryCursorsHaveSharedArgumentContract_Issue McpServer.MaxMcpQueryCursorCharacters, properties["cursor"]["maxLength"]!.GetValue()); } + + var validateDescription = GetAdvertisedTools()["validate"]["description"]!.GetValue(); + Assert.Contains( + "authoritative only while `file_issues_data_current` is true", + validateDescription, + StringComparison.Ordinal); + Assert.Contains( + "`file_issues_data_current` が true の間だけ authoritative", + validateDescription, + StringComparison.Ordinal); } [Fact] From f310d6ed5cab5b2e6dc39f0748d6d21c44e7d00d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 17:24:09 +0900 Subject: [PATCH 8/8] Mark degraded validation state unknown (#4853) --- .../Database/DbReader.FilesStatus.cs | 19 ++++++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 40 +++++++++++-------- .../McpServerIssue4853Tests.cs | 22 ++++++++++ 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 104473bb9..e3e092652 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -1943,10 +1943,27 @@ internal string GetFoldPaginationGenerationIdentity() internal string GetIssuePaginationGenerationIdentity() { var userVersion = ExecuteScalar("PRAGMA user_version"); + var issuesDataCurrent = _hasIssuesTable + && (userVersion & DbContext.IssuesReadyFlag) != 0; return string.Create( CultureInfo.InvariantCulture, $"stored-issues-ready-bit:{userVersion & DbContext.IssuesReadyFlag}\n" - + $"issues-table-available:{(_hasIssuesTable ? "1" : "0")}"); + + $"issues-table-available:{(_hasIssuesPhysicalTable ? "1" : "0")}\n" + + $"effective-issues-ready:{(issuesDataCurrent ? "1" : "0")}"); + } + + /// + /// Re-check issue readiness after the caller has established its read snapshot. + /// The constructor's cached readiness remains a conservative prerequisite so a reader + /// opened while issues were degraded cannot promote itself without being reopened. + /// 呼び出し側が read snapshot を確立した後で issue readiness を再確認する。 + /// degraded 時に開いた reader は再オープンなしに昇格させない。 + /// + internal bool IsIssueDataCurrentInSnapshot() + { + var userVersion = ExecuteScalar("PRAGMA user_version"); + return _hasIssuesTable + && (userVersion & DbContext.IssuesReadyFlag) != 0; } private (DateTime? IndexedAt, DateTime? LatestModified) GetWorkspaceFreshness() diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index cca5a9d59..b58ad7997 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1215,13 +1215,15 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) ("exclude-path", excludePaths, PreserveOrder: false)); var generation = BuildMcpGenerationFingerprint(reader, includeIssueState: true); var issuesTableAvailable = reader._hasIssuesPhysicalTable; - var issuesDataCurrent = reader._hasIssuesTable; - var total = reader.CountIssues( - kind, - pathPatterns, - excludePaths, - excludeTests, - severity); + var issuesDataCurrent = reader.IsIssueDataCurrentInSnapshot(); + var total = issuesDataCurrent + ? reader.CountIssues( + kind, + pathPatterns, + excludePaths, + excludeTests, + severity) + : 0; if (ValidateMcpQueryCursor( id, "validate", @@ -1233,14 +1235,16 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) return cursorError; } var offset = cursor?.Offset ?? 0; - var issues = reader.GetIssues( - kind, - pathPatterns, - excludePaths, - excludeTests, - limit: countOnly ? null : limit, - severity: severity, - offset: offset); + var issues = issuesDataCurrent + ? reader.GetIssues( + kind, + pathPatterns, + excludePaths, + excludeTests, + limit: countOnly ? null : limit, + severity: severity, + offset: offset) + : []; QueryCommandRunner.AnnotateValidateIssues(issues); var pathFilterArray = new JsonArray(); if (pathPatterns is not null) @@ -1248,6 +1252,10 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) foreach (var path in pathPatterns) pathFilterArray.Add(path); } + var issueSummary = QueryCommandRunner.BuildValidateIssueSummary(issues); + issueSummary["authoritative"] = issuesDataCurrent; + if (!issuesDataCurrent) + issueSummary["actionability"] = "unknown"; var payload = new JsonObject { ["count"] = countOnly ? total : issues.Count, @@ -1259,7 +1267,7 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) ["exclude_paths"] = JsonSerializer.SerializeToNode(excludePaths), ["exclude_tests"] = excludeTests, }, - ["summary"] = QueryCommandRunner.BuildValidateIssueSummary(issues), + ["summary"] = issueSummary, ["top_files"] = BuildTopFileHistogram(issues, issue => issue.Path), ["issues_table_available"] = issuesTableAvailable, ["file_issues_data_current"] = issuesDataCurrent, diff --git a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs index 03d601d79..4f978fb3d 100644 --- a/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue4853Tests.cs @@ -282,6 +282,28 @@ public void DiscoveryTools_BindCursorsToEffectiveReadinessAndRuntimeFoldState_Is Assert.False(degraded["total_count_authoritative"]!.GetValue()); Assert.True(degraded["issues_table_available"]!.GetValue()); Assert.False(degraded["file_issues_data_current"]!.GetValue()); + Assert.Equal("unknown", degraded["summary"]!["actionability"]!.GetValue()); + Assert.False(degraded["summary"]!["authoritative"]!.GetValue()); + } + + [Fact] + public void Validate_RechecksIssueReadinessInsidePaginationSnapshot_Issue4853() + { + SeedIssue4853DiscoveryRows(1); + using var readerOpenedWhileReady = new DbReader(_db); + Assert.True(readerOpenedWhileReady._hasIssuesTable); + + var writer = new DbWriter(_db.Connection); + writer.ClearReadyFlags(); + + var snapshotState = readerOpenedWhileReady.RunInReadSnapshot(() => ( + Generation: readerOpenedWhileReady.GetIssuePaginationGenerationIdentity(), + Current: readerOpenedWhileReady.IsIssueDataCurrentInSnapshot())); + + Assert.Contains("stored-issues-ready-bit:0", snapshotState.Generation, StringComparison.Ordinal); + Assert.Contains("issues-table-available:1", snapshotState.Generation, StringComparison.Ordinal); + Assert.Contains("effective-issues-ready:0", snapshotState.Generation, StringComparison.Ordinal); + Assert.False(snapshotState.Current); } [Fact]