From 356334bdcd7207a36e55ba3d4ae96dc509a19aa1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 04:18:43 +0900 Subject: [PATCH 1/3] Add MCP outline pagination controls (#4897) --- DEVELOPER_GUIDE.md | 2 + README.md | 12 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 25 ++ changelog.d/unreleased/4897.added.md | 26 +++ .../Cli/QueryCommandRunner.Outline.cs | 98 ++++++++ src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 2 +- src/CodeIndex/Mcp/McpToolCatalog.cs | 50 ++++ .../Mcp/McpToolHandlers.ArgumentValidation.cs | 2 +- .../Mcp/McpToolHandlers.Query.Source.cs | 155 ++++++++++++- ...cpServerOutlinePaginationIssue4897Tests.cs | 217 ++++++++++++++++++ .../McpServerToolsListTests.cs | 24 ++ tests/CodeIndex.Tests/McpToolContractTests.cs | 6 +- 13 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 changelog.d/unreleased/4897.added.md create mode 100644 tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 33da20289..de6cd8b42 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1720,6 +1720,7 @@ access. | Grouped search totals | `search --format grouped` derives `total_matches` / `matched_count`, `total_groups`, and `total_files` from the complete bounded query rather than the displayed page. `grouped_match_count` counts rows supplied to returned groups, `emitted_match_count` counts rows left after per-file grouping limits, and `omitted_match_count`, `truncated`, `has_more`, and `continuation_action` describe incomplete output. | | Bounded high-volume responses | `search`, `definition`, `find`, `status`, `hotspots`, `references`, `callers`, `callees`, `symbols`, `files`, `languages`, `impact`, and `map` accept shared bounded-response controls where their schema exposes them. Newly emitted opaque `--cursor ` values bind the offset to the command/query/filter selection and index generation; legacy `response:v1::` cursors remain accepted for transition. Reuse with changed selection or generation fails with restart-required guidance. `search --format compact`, `symbols --format compact`, and `files --format compact` auto-select the bounded contract, while `search --json=array --json-envelope` provides the opt-in array envelope and `languages --json` selects it when paging or `--max-json-bytes` is requested. Existing compact roots and location rows remain compatible while adding shared metadata. Metadata reports `returned_count`, authoritative `total_count` where available, `omitted_count`, `remaining_count`, `cursor_offset`, `page_limit`, `has_more`, `next_cursor`, `result_stable_at`, `pagination_window_limit`, and `pagination_window_exhausted`. The safety window is 10,000 rows; exhaustion suppresses `next_cursor` rather than returning a cursor that the next request would reject. Pageable commands pass the cursor offset into their database/scan layer instead of serializing an `offset + limit` prefix. `find --all` partial scans encode the next path/line in the opaque cursor so replay continues after the last scanned line. `hotspots` and `impact` page their active primary nested collection as `results`, identify it with `metadata.primary_collection`, and retain scalar/container evidence in `metadata.response_context`; dotted fields such as `callers.path,callers.depth` select that collection and project its rows. The final newline is included in `--max-json-bytes`, and trailing whole rows are removed until the complete envelope fits. `definition` remains metadata-only by default; explicit `--body` content is retained for `body`, `body_content`, or `all`, and suppressed when the projection excludes it. `map --sections` remains its section-level projection, while dotted bounded fields page a selected array section with section-specific totals and scalar projections skip unused ranked arrays. | | Bounded outline responses | `outline` opts into the shared bounded-response contract only when `--max-json-bytes` is present. The wrapper extracts complete projected symbol rows, preserves hierarchy and deterministic order, reports authoritative returned / total / omitted counts, includes the final newline in its UTF-8 measurement, and emits a bound `response:v2` continuation cursor. An undersized minimum envelope produces typed `E010_USAGE_ERROR` diagnostics with empty stdout. Uncapped outline JSON retains its existing root shape and outline cursor contract. | +| MCP outline pages | MCP `outline` routes `fields`, `sort`, `limit`, and `cursor` through `QueryCommandRunner.BuildOutlinePage`, so its projection aliases, derived sort fields, stable tie-breakers, `page:v1` query fingerprint, and generation validation remain the CLI outline contract rather than a second MCP-specific implementation. The default page is 100 rows and the MCP-wide maximum is 200. `maxBytes` measures the fully enriched serialized `structuredContent`; a binary search rebuilds the page with fewer complete rows and therefore regenerates `next_cursor` from the actual returned count. A budget that cannot hold metadata plus one row fails instead of returning a zero-progress cursor. Default MCP symbol serialization remains backward-compatible, while explicit projection fields use the CLI snake_case names. | | Bounded response edge cases | `impact` applies the cursor offset only to the selected nested collection so definition pages do not repeat or alter caller/fallback mode. Plain `map --compact` preserves its established section arrays and truncation payload; a collection projection is rejected when `--summary-only` or an excluding `--sections` filter would remove it. Explicit definition body fields override compact defaults. Profile and verbose records are moved into `metadata.stream_control_records`, and parser/capture failures emit an error envelope only when it fits the active hard byte cap. | | `--count --json` envelope | Count-only JSON for `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, and `unused` is a single automation-oriented object. It always includes `count`, applied `query_context`, freshness metadata (`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags `degraded` / `authoritative_count`; commands with matched-file totals also include `files` and the older `file_count` compatibility alias. `file_count` carries the same value as `files`, remains for compatibility, and is not scheduled for removal before the next major release. `unused --count --json` also includes `returned_bucket_counts`, `returned_contract_domain_counts`, and `summary.by_bucket` / `summary.by_confidence` / `summary.by_contract_domain`. `authoritative_count=false` means a readiness or graph/exact trust signal made the count non-authoritative, while the freshness fields describe the indexed snapshot used for the count. | | Search row selection | Row-producing plain-search and recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and fixed-seed deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate/compact query DTOs, plain compact roots, run summaries, issue-draft source DTOs, NDJSON terminals, and bounded array-envelope stream terminals expose `source_total`, `selected_total`, `returned`, `selector_omitted_count`, and `limit_omitted_count`; `source_total_authoritative` / `source_total_lower_bound` distinguish complete populations from bounded observations. Guard filters, origin/facet post-filters, exhausted candidate windows, and recipe file-reject post-filters force lower-bound authority. Their ordered `selectors` entries preserve each stage's input/output/omission counts plus sample size, mode, and seed, while nullable `selection_reason` / `selection_omitted_count` remain compatibility summaries. Bounded plain-search selection is computed once and its selected page is reused by compact/envelope serialization. Search `query_context.row_selectors` records the applied selector configuration. Selection-only omission updates matched/omitted lower bounds without setting `truncated`, `has_more`, or `next_cursor`. When a later limit truncates selected rows, limit truncation remains visible but `next_cursor` is suppressed because raw database cursors cannot preserve selector state; incoming `--cursor` values are rejected with either selector for the same reason. Generated compact and issue-draft replay commands retain the selector. Count, aggregation, named-query, recipe-list, results-only, metadata-free array, unsupported formatted, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | @@ -5153,6 +5154,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | grouped search の総数 | `search --format grouped` の `total_matches` / `matched_count`、`total_groups`、`total_files` は、表示 page ではなく上限適用前の query 全体から算出します。`grouped_match_count` は返却 group に渡した row 数、`emitted_match_count` は file ごとの上限適用後に残った row 数を表し、`omitted_match_count`、`truncated`、`has_more`、`continuation_action` が未完了出力を示します。 | | 高ボリューム応答の bounded 契約 | `search`、`definition`、`find`、`status`、`hotspots`、`references`、`callers`、`callees`、`symbols`、`files`、`languages`、`impact`、`map` は、それぞれの schema が公開する共通 bounded-response control に対応します。新しく発行する opaque な `--cursor ` は offset を command / query / filter と index generation に束縛し、移行用に legacy の `response:v1::` も受理します。選択条件または generation を変えて再利用すると restart-required の案内付きで失敗します。`search --format compact`、`symbols --format compact`、`files --format compact` は bounded 契約を自動選択し、`search --json=array --json-envelope` は opt-in の array envelope、`languages --json` は paging または `--max-json-bytes` 指定時に同じ契約を使います。既存 compact の root と location row は維持したまま共通 metadata を追加します。metadata は `returned_count`、取得可能な場合は authoritative な `total_count`、`omitted_count`、`remaining_count`、`cursor_offset`、`page_limit`、`has_more`、`next_cursor`、`result_stable_at`、`pagination_window_limit`、`pagination_window_exhausted` を返します。safety window は 10,000 row で、上限到達時は次の request が拒否する cursor を返さず `next_cursor` を抑止します。pageable command は `offset + limit` 件を serialize せず、cursor offset を database / scan layer へ渡します。`find --all` の partial scan cursor は次の path / line を保持し、再利用時は最後に scan した line の次から継続します。`hotspots` と `impact` は active な主要 nested collection を `results` としてページングし、`metadata.primary_collection` でその名前を示し、scalar / container evidence は `metadata.response_context` に保持します。`callers.path,callers.depth` のような dotted field で collection と row field を同時に選べます。`--max-json-bytes` は最後の改行を含み、完全な envelope が収まるまで末尾の完全な row を省略します。`definition` は既定で metadata-only のままで、明示的な `--body` は `body`、`body_content`、`all` で保持し、それ以外の projection では materialize 前に抑止します。`map --sections` は section-level projection として残り、dotted な bounded field は選択した array section を section 固有の総件数付きでページングし、scalar projection は不要な ranking array を構築しません。 | | bounded outline 応答 | `outline` は `--max-json-bytes` がある場合だけ共通 bounded-response 契約を選択します。wrapper は projection 済みの完全な symbol row を抽出し、階層と決定的な順序を維持し、authoritative な返却 / 総 / 省略件数を報告します。UTF-8 計測には最後の改行を含め、束縛済みの `response:v2` continuation cursor を発行します。最小 envelope が収まらない場合は stdout を空に保ち、型付きの `E010_USAGE_ERROR` diagnostic を返します。上限なしの outline JSON は既存の root shape と outline cursor 契約を維持します。 | +| MCP outline page | MCP `outline` は `fields`、`sort`、`limit`、`cursor` を `QueryCommandRunner.BuildOutlinePage` へ渡すため、projection alias、派生 sort field、安定した tie-breaker、`page:v1` query fingerprint、generation validation は第 2 の MCP 固有実装ではなく CLI outline 契約のままです。既定 page は 100 row、MCP 共通の上限は 200 row です。`maxBytes` は enrichment 済みの `structuredContent` 全体を serialize した byte 数で計測し、binary search で完全な row 数を減らして page を再構築するため、`next_cursor` も実際の返却件数から再生成されます。metadata と 1 row が収まらない budget は、進捗しない cursor を返さず失敗します。既定の MCP symbol serialization は後方互換を維持し、明示的な projection field は CLI の snake_case 名を使います。 | | bounded 応答の edge case | `impact` は選択された nested collection だけに cursor offset を適用するため、definition page の重複や caller / fallback mode の変化を防ぎます。通常の `map --compact` は既存の section array と truncation payload を維持し、collection projection が `--summary-only` または除外する `--sections` filter で失われる組み合わせは拒否します。明示的な definition body field は compact default より優先します。profile / verbose record は `metadata.stream_control_records` へ移し、parser / capture failure の error envelope は active な hard byte cap に収まる場合だけ出力します。 | | `--count --json` envelope | `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`impact`、`unused` の count-only JSON は単一の自動化向け object です。常に `count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、`indexed_at`、`freshness_available`)、trust flag の `degraded` / `authoritative_count` を含みます。matched-file total を持つ command は `files` と古い互換 alias の `file_count` も含みます。`file_count` は `files` と同じ値を持つ互換用 field として残り、少なくとも次の major release までは削除予定はありません。`unused --count --json` は `returned_bucket_counts`、`returned_contract_domain_counts`、`summary.by_bucket` / `summary.by_confidence` / `summary.by_contract_domain` も含みます。`authoritative_count=false` は readiness または graph/exact trust signal により count が authoritative ではないことを示し、freshness field は count に使った index snapshot を説明します。 | | search row selection | row を返す plain search / recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と固定 seed の決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO、plain compact root、run summary、issue-draft の source DTO、NDJSON terminal、bounded array envelope の stream terminal は `source_total`、`selected_total`、`returned`、`selector_omitted_count`、`limit_omitted_count` を公開し、`source_total_authoritative` / `source_total_lower_bound` で完全な population と bounded な観測を区別します。guard filter、origin / facet の後段 filter、candidate window の枯渇、recipe の file-reject 後段 filter は lower-bound authority にします。適用順の `selectors` entry は各段階の input / output / omission count と sample の size / mode / seed を保持し、nullable な `selection_reason` / `selection_omitted_count` は互換用 summary として維持します。bounded plain-search selection は一度だけ計算し、その selected page を compact / envelope serialize で再利用します。search の `query_context.row_selectors` は適用済み selector 設定を記録します。selection だけによる省略は matched / omitted の lower bound を更新しますが、`truncated`、`has_more`、`next_cursor` は設定しません。後続の limit が選択済み row を truncate する場合、limit truncation は表示しますが raw database cursor は selector state を保持できないため `next_cursor` を抑止し、同じ理由で selector と受け取った `--cursor` の併用も拒否します。compact / issue-draft の生成 replay command は selector を保持します。count、aggregation、named-query、recipe-list、results-only、metadata を持たない array、非対応 formatted、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | diff --git a/README.md b/README.md index 31fc86c91..092d70d24 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,12 @@ versioned surfaces are the `cdidx` CLI, CLI JSON output, and `cdidx mcp` JSON-RPC interface. There is no public library / SDK API. See [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#api-surface-and-library-use). +The MCP `outline` tool supports CLI-compatible `fields` and `sort` controls, +bounded `limit` / `cursor` pages, and a `maxBytes` UTF-8 budget. Its opaque +continuation is bound to the path, ordering, and index generation so large file +outlines can be enumerated without gaps or duplicates. See +[AI Integration](USER_GUIDE.md#ai-integration). + ## CLI JSON Error Contract Recoverable non-database failures from commands such as `outline`, `hooks`, @@ -698,6 +704,12 @@ commit し、構造化 `file_errors` を返して partial-result 終了コード JSON-RPC interface です。公開 library / SDK API は提供していません。詳細は [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#api-surface-and-library-use) を参照してください。 +MCP の `outline` tool は、CLI 互換の `fields` / `sort`、上限付きの +`limit` / `cursor` page、UTF-8 byte 上限の `maxBytes` に対応します。 +opaque な continuation は path、並び順、index generation に束縛されるため、 +大きな file outline も gap や duplicate なしで列挙できます。詳細は +[AIとの連携](USER_GUIDE.md#aiとの連携) を参照してください。 + ## CLI JSON エラー契約 `outline`、`hooks`、`doctor`、`validate-config` などの回復可能な diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 843586547..7f33aa29f 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -565,6 +565,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding 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. 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. + `McpServerOutlinePaginationIssue4897Tests.cs` owns the standalone large-file `outline` contract. Keep its 175-row deep tree, empty file, projected aliases, stable source/name ordering, exact UTF-8 byte boundary, no-progress budget error, and stale-generation replay checks together. The suite intentionally uses an isolated database and the `SQLite pool sensitive` collection because it closes one server, mutates the persisted generation, and opens another to prove cursor invalidation. 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. @@ -1504,6 +1505,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 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 を検証してください。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 を開いてください。 + `McpServerOutlinePaginationIssue4897Tests.cs` は独立した大規模 file の `outline` 契約を担当します。175 row の深い tree、空 file、projection alias、安定した source/name 順、正確な UTF-8 byte 境界、進捗不能 budget error、stale-generation replay の検証を同じ suite に維持してください。この suite は一方の server を閉じ、永続化 generation を変更して別の server で cursor invalidation を証明するため、独立 database と `SQLite pool sensitive` collection を意図的に使います。 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/USER_GUIDE.md b/USER_GUIDE.md index 8db38f3bc..5132963f5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2933,6 +2933,19 @@ Capped MCP result tools report `truncated` and `more_available` in `structuredCo Graph tools that can page through result sets (`references`, `callers`, and `callees`) also return `offset` and, when truncated, `next_offset`; pass that value back as `offset` to fetch the next page without re-reading earlier rows. +The MCP `outline` tool pages independently with the same canonical symbol rows, +stable ordering, projection names, and `page:v1` cursor fingerprint as CLI +`outline`. It returns at most 100 symbols by default (200 maximum) and publishes +`total_symbol_count`, `returned_symbol_count`, `cursor_offset`, `has_more`, and +`next_cursor`. Use `fields`, `sort`, `limit`, and `cursor` to enumerate large +files deterministically. `maxBytes` additionally bounds serialized +`structuredContent` in UTF-8 bytes by removing only complete trailing symbol +rows and advancing the cursor by the rows actually returned. Cursors are bound +to the path, ordering, and index generation; restart without the cursor after a +stale-generation error. Unprojected rows retain `depth`, `containerName`, and +symbol `path`; include the corresponding projection fields when a projected +page must carry hierarchy information. + ```mermaid flowchart LR tools["Claude Code
Cursor
Windsurf"] @@ -6157,6 +6170,18 @@ basename candidate だけを確認し、見つからなければ unresolved docu ページング可能な graph tool(`references`、`callers`、`callees`)は `offset` と、truncated 時には `next_offset` も返すため、その値を次の呼び出しの `offset` に渡すと、既に取得した行を読み直さずに次ページを取得できます。 +MCP の `outline` tool は、CLI `outline` と同じ canonical symbol row、安定した +並び順、projection 名、`page:v1` cursor fingerprint を使って独立にページングします。 +既定では最大 100 symbol、上限は 200 symbol で、`total_symbol_count`、 +`returned_symbol_count`、`cursor_offset`、`has_more`、`next_cursor` を返します。 +大きな file を決定的に列挙するには `fields`、`sort`、`limit`、`cursor` を使います。 +`maxBytes` を指定すると、serialize 済み `structuredContent` の UTF-8 byte 数を上限とし、 +末尾の完全な symbol row だけを除き、実際に返した row 数だけ cursor を進めます。 +cursor は path、並び順、index generation に束縛されるため、stale-generation error の後は +cursor なしで再開してください。projection なしの row は `depth`、`containerName`、 +symbol `path` を保持します。projection した page に階層情報が必要な場合は、対応する +projection field を含めてください。 + ```mermaid flowchart LR tools["Claude Code
Cursor
Windsurf"] diff --git a/changelog.d/unreleased/4897.added.md b/changelog.d/unreleased/4897.added.md new file mode 100644 index 000000000..b134ac4c5 --- /dev/null +++ b/changelog.d/unreleased/4897.added.md @@ -0,0 +1,26 @@ +--- +category: added +issues: + - 4897 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Outline.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs + - src/CodeIndex/Mcp/McpToolCatalog.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs + - tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs + - tests/CodeIndex.Tests/McpServerToolsListTests.cs + - tests/CodeIndex.Tests/McpToolContractTests.cs + - README.md + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **MCP `outline` can now page and project large file trees deterministically (#4897)** — The tool exposes CLI-compatible `fields` and `sort`, bounded `limit` / `cursor` pages, and a UTF-8 `maxBytes` budget. Opaque continuations reuse the outline query and index-generation fingerprint, preserve complete hierarchy-bearing symbol rows, and advance by the rows actually returned so large outlines enumerate without gaps or duplicates. + +## 日本語 + +- **MCP `outline` で大きな file tree を決定的に projection / pagination できるようになりました (#4897)** — CLI 互換の `fields` / `sort`、上限付きの `limit` / `cursor` page、UTF-8 の `maxBytes` budget を公開します。opaque な continuation は outline query と index generation の fingerprint を再利用し、階層情報を持つ完全な symbol row を維持しながら実際の返却件数だけ進むため、gap や duplicate なしで大きな outline を列挙できます。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs index 3d3768774..29fc57118 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs @@ -7,6 +7,12 @@ namespace CodeIndex.Cli; public static partial class QueryCommandRunner { + internal sealed record OutlinePageBuildResult( + JsonObject? Payload, + OutlineResult? Outline, + string? Error, + bool NotFound); + public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var wantsJson = cmdArgs.Any(static arg => @@ -201,6 +207,98 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions }); } + internal static bool TryNormalizeOutlineProjectionFields( + string rawValue, + out List? fields, + out string? error) + { + var errors = new List(); + fields = ParseOutlineProjectionFields(rawValue, errors.Add); + error = errors.Count == 0 + ? null + : errors[0] + .Replace("Error: ", string.Empty, StringComparison.Ordinal) + .Replace("--outline-fields", "fields", StringComparison.Ordinal); + return error == null; + } + + internal static OutlinePageBuildResult BuildOutlinePage( + DbReader reader, + string filePath, + IReadOnlyList? fields, + bool fieldsExplicit, + string? requestedSort, + int limit, + string? cursor, + JsonSerializerOptions jsonOptions) + { + var sortExplicit = !string.IsNullOrWhiteSpace(requestedSort); + var outlineSortMode = OutlineSortMode.Source; + if (sortExplicit && !TryParseOutlineSortMode(requestedSort!, out outlineSortMode)) + { + return new( + null, + null, + "sort must be one of source, name, kind, references, size, complexity, or path.", + NotFound: false); + } + + int? cursorOffset = null; + if (cursor != null) + { + if (!TryParseScopedOffsetCursor(cursor, out var parsedCursor) + || !string.Equals(parsedCursor.Scope, "outline", StringComparison.Ordinal)) + { + return new( + null, + null, + "cursor must be an outline pagination cursor; restart without cursor.", + NotFound: false); + } + cursorOffset = parsedCursor.Offset; + } + + var options = new QueryCommandOptions + { + Json = true, + Limit = limit, + LimitExplicit = true, + OutlineFields = fields?.ToList(), + OutlineFieldsExplicit = fieldsExplicit, + SortValue = requestedSort, + SortExplicit = sortExplicit, + CursorValue = cursor, + OutlineCursorOffset = cursorOffset, + }; + var includeReferenceCounts = OutlineNeedsReferenceCounts(options, outlineSortMode); + var includeDerivedMetadata = OutlineNeedsDerivedMetadata(options, outlineSortMode); + var cursorComponents = new List + { + filePath, + FormatOutlineSortMode(outlineSortMode), + }; + var cursorContext = BuildPaginationCursorContext(reader, "outline", cursorComponents); + var cursorValidationError = ValidateScopedOffsetCursor(options, "outline", cursorContext); + if (cursorValidationError != null) + return new(null, null, cursorValidationError, NotFound: false); + + var outline = reader.GetOutline(filePath, includeReferenceCounts: includeReferenceCounts); + if (outline == null) + return new(null, null, null, NotFound: true); + + var displaySymbols = ApplyOutlineSort(outline.Symbols, outlineSortMode, includeDerivedMetadata); + var payload = BuildOutlineJsonPayload( + outline, + displaySymbols, + [], + outlineSortMode, + options, + cursorContext, + jsonOptions, + compact: false); + return new(payload, outline, null, NotFound: false); + } + private static JsonObject ApplyOutlineCompactCaps(OutlineResult outline, int sectionLimit) => ApplyOutlineSymbolLimit(outline, sectionLimit); diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index b5ac9e585..fd1dda0d1 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -25,7 +25,7 @@ public partial class McpServer "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "minEntrypointConfidence", "project", "solution" }, "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "countOnly", "format", "project", "solution" }, "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format", "fields" }, - "outline" => new HashSet(StringComparer.Ordinal) { "path" }, + "outline" => new HashSet(StringComparer.Ordinal) { "path", "fields", "sort", "limit", "cursor", "maxBytes" }, "batch_query" => new HashSet(StringComparer.Ordinal) { "queries", "maxResponseBytes", "estimateOnly" }, "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" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 12d6b0a75..65058b404 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -389,6 +389,56 @@ private static JsonArray CreateToolCatalog() ["properties"] = new JsonObject { ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Indexed file path (e.g. src/app.cs)" }, + ["fields"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = 256 }, + new JsonObject + { + ["type"] = "array", + ["minItems"] = 1, + ["maxItems"] = 16, + ["items"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray + { + "all", "kind", "name", "display_name", "path", "line", "start_line", "end_line", + "depth", "body_start_line", "body_end_line", "signature", "signature_truncated", + "signature_original_length", "container_kind", "container_name", "visibility", + "return_type", "sort_mode", "reference_count", "size_lines", "complexity_score", + "range", "lines", "body", "body_range", "container", "refs", "references", + "size", "span", "complexity" + } + } + } + }, + ["description"] = "CLI-compatible outline projection fields. A string may be comma-separated; aliases expand exactly as `cdidx outline --outline-fields` does." + }, + ["sort"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray { "source", "name", "kind", "references", "size", "complexity", "path" }, + ["description"] = "Deterministic outline ordering shared with `cdidx outline --sort`.", + ["default"] = "source" + }, + ["limit"] = new JsonObject + { + ["type"] = "integer", + ["minimum"] = 1, + ["maximum"] = MaxLimit, + ["description"] = "Maximum complete symbol rows to return (default: 100, maximum: 200).", + ["default"] = 100 + }, + ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Opaque `page:v1` continuation returned as `next_cursor`; it is bound to path, ordering, and index generation." }, + ["maxBytes"] = new JsonObject + { + ["type"] = "integer", + ["minimum"] = 1, + ["maximum"] = MaxClientResponseJsonBytes, + ["description"] = "Maximum UTF-8 bytes for serialized structured content. Pages shrink only at complete symbol-row boundaries." + }, }, ["required"] = new JsonArray { "path" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs index df32649ca..e0558ed58 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs @@ -256,7 +256,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "project" or "capability" or "scopes" or "fields" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or "commits" or "changedBetween" or "files" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", - "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "guardScope" or + "query" or "lang" or "kind" or "format" or "rankBy" or "sort" or "since" or "cursor" or "guardScope" or "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "snippetFocus" or "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" or "followSymlinks" or "recipe" or "auditScope" => "string", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs index 75879606d..ce5821dfa 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -20,11 +20,55 @@ private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) { if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); + if (!TryReadOutlineProjectionFields(args, out var fields, out var fieldsExplicit, out var fieldsError)) + return CreateToolErrorResponse(id, fieldsError!); + + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, defaultLimit: 100, adjustments); + var sort = args?["sort"]?.GetValue(); + var cursor = args?["cursor"]?.GetValue(); + var maxBytes = ReadOptionalIntArgument(args, "maxBytes"); + if (maxBytes is <= 0 or > MaxClientResponseJsonBytes) + { + return CreateToolErrorResponse( + id, + $"maxBytes must be in [1, {MaxClientResponseJsonBytes}]."); + } return WithDbReader(id, args, reader => { - var outline = reader.GetOutline(path); - if (outline == null) + QueryCommandRunner.OutlinePageBuildResult BuildPage(int pageLimit) + => QueryCommandRunner.BuildOutlinePage( + reader, + path, + fields, + fieldsExplicit, + sort, + pageLimit, + cursor, + _jsonOptions); + + JsonObject PrepareStructuredContent(QueryCommandRunner.OutlinePageBuildResult page) + { + var structuredContent = page.Payload!; + adjustments.ApplyTo(structuredContent); + AddNextStepSuggestion( + structuredContent, + "excerpt", + new JsonObject + { + ["path"] = path, + ["startLine"] = 1, + ["endLine"] = Math.Min(page.Outline!.TotalLines, 80) + }, + "Use excerpt for only the relevant outline range instead of reading the whole file."); + return structuredContent; + } + + var page = BuildPage(limit); + if (page.Error != null) + return CreateToolErrorResponse(id, page.Error); + if (page.NotFound) { var emptyPayload = new JsonObject { @@ -35,16 +79,109 @@ private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) return CreateToolResult(id, "File not found in index.", emptyPayload); } - var structured = JsonSerializer.SerializeToNode(outline, _jsonOptions)!.AsObject(); - AddNextStepSuggestion( - structured, - "excerpt", - new JsonObject { ["path"] = path, ["startLine"] = 1, ["endLine"] = Math.Min(outline.TotalLines, 80) }, - "Use excerpt for only the relevant outline range instead of reading the whole file."); - return CreateToolResult(id, $"Outline: {ConsoleUi.Counted(outline.SymbolCount, "symbol")} in {ConsoleUi.Counted(outline.TotalLines, "line")}.", structured); + var structured = PrepareStructuredContent(page); + if (maxBytes.HasValue) + { + EnrichToolStructuredContent(structured); + if (!TryMeasureJsonUtf8BytesWithinLimit(structured, _jsonOptions, maxBytes.Value, out _)) + { + var requestedRows = structured["returned_symbol_count"]?.GetValue() ?? 0; + QueryCommandRunner.OutlinePageBuildResult? bestPage = null; + JsonObject? bestStructured = null; + var low = 1; + var high = requestedRows - 1; + while (low <= high) + { + var candidateLimit = low + ((high - low) / 2); + var candidatePage = BuildPage(candidateLimit); + if (candidatePage.Error != null) + return CreateToolErrorResponse(id, candidatePage.Error); + + var candidateStructured = PrepareStructuredContent(candidatePage); + EnrichToolStructuredContent(candidateStructured); + if (TryMeasureJsonUtf8BytesWithinLimit(candidateStructured, _jsonOptions, maxBytes.Value, out _)) + { + bestPage = candidatePage; + bestStructured = candidateStructured; + low = candidateLimit + 1; + } + else + { + high = candidateLimit - 1; + } + } + + if (bestPage == null || bestStructured == null) + { + return CreateToolErrorResponse( + id, + "maxBytes is too small for outline metadata and one complete symbol row; increase maxBytes or request fewer fields."); + } + page = bestPage; + structured = bestStructured; + } + + var boundedReturnedCount = structured["returned_symbol_count"]?.GetValue() ?? 0; + return CreateToolResult( + id, + $"Outline: {ConsoleUi.Counted(boundedReturnedCount, "symbol")} returned from {ConsoleUi.Counted(page.Outline!.SymbolCount, "symbol")} in {ConsoleUi.Counted(page.Outline.TotalLines, "line")}.", + structured, + enrichStructuredContent: false); + } + + var returnedCount = structured["returned_symbol_count"]?.GetValue() ?? 0; + return CreateToolResult( + id, + $"Outline: {ConsoleUi.Counted(returnedCount, "symbol")} returned from {ConsoleUi.Counted(page.Outline!.SymbolCount, "symbol")} in {ConsoleUi.Counted(page.Outline.TotalLines, "line")}.", + structured); }); } + private static bool TryReadOutlineProjectionFields( + JsonNode? args, + out List? fields, + out bool fieldsExplicit, + out string? error) + { + fields = null; + fieldsExplicit = args is JsonObject argsObject && argsObject.ContainsKey("fields"); + error = null; + if (!fieldsExplicit) + return true; + + var node = args!["fields"]; + if (node is null) + { + error = "fields must be a non-empty string or string array."; + return false; + } + + IEnumerable values = node is JsonArray array ? array : new JsonNode?[] { node }; + if (node is JsonArray fieldsArray && (fieldsArray.Count == 0 || fieldsArray.Count > 16)) + { + error = "fields must contain between 1 and 16 entries."; + return false; + } + + var rawFields = new List(); + foreach (var value in values) + { + if (value is not JsonValue jsonValue + || !jsonValue.TryGetValue(out var field) + || string.IsNullOrWhiteSpace(field)) + { + error = "fields entries must be non-empty strings."; + return false; + } + rawFields.Add(field.Trim()); + } + + return QueryCommandRunner.TryNormalizeOutlineProjectionFields( + string.Join(',', rawFields), + out fields, + out error); + } + private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) { if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) diff --git a/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs b/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs new file mode 100644 index 000000000..f65b8faec --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs @@ -0,0 +1,217 @@ +using System.Text; +using System.Text.Json.Nodes; +using CodeIndex.Mcp; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public sealed class McpServerOutlinePaginationIssue4897Tests +{ + private const string LargeOutlinePath = "src/LargeOutline.cs"; + + [Fact] + public void Outline_PagesLargeDeepTreesProjectsFieldsAndHonorsByteBoundaries_Issue4897() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_mcp_outline_paging_4897"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, LargeOutlinePath, "csharp", BuildLargeOutlineSource()); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Empty.cs", "csharp", Environment.NewLine); + using var server = new McpServer(dbPath, "1.0.0-test", dbPathExplicit: true); + + var identities = new List<(string Path, int Line)>(); + string? cursor = null; + var pageCount = 0; + do + { + var arguments = new JsonObject + { + ["path"] = LargeOutlinePath, + ["limit"] = 37, + }; + if (cursor != null) + arguments["cursor"] = cursor; + + var structured = CallOutline(server, arguments); + pageCount++; + Assert.Equal(175, structured["total_symbol_count"]!.GetValue()); + Assert.InRange(structured["returned_symbol_count"]!.GetValue(), 1, 37); + Assert.Equal(identities.Count, structured["cursor_offset"]!.GetValue()); + + foreach (var symbol in structured["symbols"]!.AsArray()) + { + identities.Add(( + symbol!["path"]!.GetValue(), + symbol["line"]!.GetValue())); + if (symbol["name"]!.GetValue().StartsWith("Method", StringComparison.Ordinal)) + { + Assert.True(symbol["depth"]!.GetValue() >= 3); + Assert.Equal("Branch", symbol["containerName"]!.GetValue()); + } + } + + cursor = structured["next_cursor"]?.GetValue(); + if (cursor != null) + Assert.StartsWith("page:v1:", cursor, StringComparison.Ordinal); + } + while (cursor != null); + + Assert.True(pageCount > 1); + Assert.Equal(175, identities.Count); + Assert.Equal(175, identities.Distinct().Count()); + Assert.True(identities.Select(identity => identity.Line).SequenceEqual( + identities.Select(identity => identity.Line).Order())); + Assert.Equal( + Enumerable.Range(0, 172).Select(index => $"Method{index:D3}"), + identities.Select(identity => identity.Path.Split('.').Last()) + .Where(name => name.StartsWith("Method", StringComparison.Ordinal))); + + var projected = CallOutline( + server, + new JsonObject + { + ["path"] = LargeOutlinePath, + ["fields"] = new JsonArray { "name", "depth", "container" }, + ["sort"] = "name", + ["limit"] = 5, + }); + Assert.Equal( + new[] { "name", "depth", "container_kind", "container_name" }, + projected["selected_fields"]!.AsArray().Select(field => field!.GetValue())); + foreach (var symbol in projected["symbols"]!.AsArray()) + { + Assert.Equal( + new[] { "name", "depth", "container_kind", "container_name" }, + symbol!.AsObject().Select(property => property.Key)); + } + + var empty = CallOutline( + server, + new JsonObject { ["path"] = "src/Empty.cs", ["limit"] = 10 }); + Assert.Equal(0, empty["total_symbol_count"]!.GetValue()); + Assert.Equal(0, empty["returned_symbol_count"]!.GetValue()); + Assert.Empty(empty["symbols"]!.AsArray()); + Assert.False(empty["has_more"]!.GetValue()); + Assert.Null(empty["next_cursor"]); + + var byteArguments = new JsonObject + { + ["path"] = LargeOutlinePath, + ["fields"] = new JsonArray { "name", "depth", "container" }, + ["limit"] = 200, + ["maxBytes"] = 2_500, + }; + var bounded = CallOutline(server, byteArguments); + var boundedBytes = Encoding.UTF8.GetByteCount(bounded.ToJsonString()); + var boundedCount = bounded["returned_symbol_count"]!.GetValue(); + Assert.InRange(boundedCount, 1, 174); + Assert.True(boundedBytes <= 2_500); + Assert.True(bounded["has_more"]!.GetValue()); + + byteArguments["maxBytes"] = boundedBytes; + var exact = CallOutline(server, byteArguments); + Assert.Equal(boundedCount, exact["returned_symbol_count"]!.GetValue()); + Assert.True(Encoding.UTF8.GetByteCount(exact.ToJsonString()) <= boundedBytes); + + byteArguments["maxBytes"] = boundedBytes - 1; + var belowExact = CallOutline(server, byteArguments); + Assert.True(belowExact["returned_symbol_count"]!.GetValue() < boundedCount); + Assert.True(Encoding.UTF8.GetByteCount(belowExact.ToJsonString()) <= boundedBytes - 1); + + var tooSmallResponse = CallOutlineResponse( + server, + new JsonObject + { + ["path"] = LargeOutlinePath, + ["fields"] = "name", + ["maxBytes"] = 1, + }); + Assert.True(tooSmallResponse["result"]!["isError"]!.GetValue()); + Assert.Contains( + "one complete symbol row", + tooSmallResponse["result"]!["content"]![0]!["text"]!.GetValue(), + StringComparison.Ordinal); + } + + [Fact] + public void Outline_RejectsCursorAfterIndexGenerationChanges_Issue4897() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_mcp_outline_stale_4897"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + "public sealed class App { public void A() { } public void B() { } }"); + + string cursor; + using (var firstServer = new McpServer(dbPath, "1.0.0-test", dbPathExplicit: true)) + { + var firstPage = CallOutline( + firstServer, + new JsonObject { ["path"] = "src/App.cs", ["limit"] = 1 }); + cursor = firstPage["next_cursor"]!.GetValue(); + } + + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/GenerationChange.cs", + "csharp", + "public sealed class GenerationChange { }"); + + using var secondServer = new McpServer(dbPath, "1.0.0-test", dbPathExplicit: true); + var response = CallOutlineResponse( + secondServer, + new JsonObject + { + ["path"] = "src/App.cs", + ["limit"] = 1, + ["cursor"] = cursor, + }); + + Assert.True(response["result"]!["isError"]!.GetValue()); + Assert.Contains( + "stale", + response["result"]!["content"]![0]!["text"]!.GetValue(), + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "restart", + response["result"]!["content"]![0]!["text"]!.GetValue(), + StringComparison.OrdinalIgnoreCase); + } + + private static JsonNode CallOutline(McpServer server, JsonObject arguments) + => CallOutlineResponse(server, arguments)["result"]!["structuredContent"]!; + + private static JsonNode CallOutlineResponse(McpServer server, JsonObject arguments) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4897, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "outline", + ["arguments"] = arguments.DeepClone(), + }, + }; + return server.HandleMessage(request)!; + } + + private static string BuildLargeOutlineSource() + { + var methods = string.Join( + Environment.NewLine, + Enumerable.Range(0, 172).Select(index => $" public void Method{index:D3}() {{ }}")); + return $$""" + namespace Demo; + public sealed class Root + { + public sealed class Branch + { + {{methods}} + } + } + """; + } +} diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index f1c650819..59ea14961 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -21,6 +21,30 @@ namespace CodeIndex.Tests; public partial class McpServerTests { + [Fact] + public void ToolsList_OutlinePublishesPaginationProjectionAndByteControls_Issue4897() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4897,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var outline = response["result"]!["tools"]!.AsArray() + .Single(tool => tool!["name"]!.GetValue() == "outline")!; + var properties = outline["inputSchema"]!["properties"]!; + + Assert.NotNull(properties["fields"]); + Assert.Contains( + properties["sort"]!["enum"]!.AsArray(), + value => value!.GetValue() == "source"); + Assert.Equal(100, properties["limit"]!["default"]!.GetValue()); + Assert.Equal(1, properties["limit"]!["minimum"]!.GetValue()); + Assert.Equal(200, properties["limit"]!["maximum"]!.GetValue()); + Assert.Contains("page:v1", properties["cursor"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal(1, properties["maxBytes"]!["minimum"]!.GetValue()); + Assert.Equal( + McpServer.MaxClientResponseJsonBytes, + properties["maxBytes"]!["maximum"]!.GetValue()); + } + [Fact] public void ToolsList_IndexPathSchemaReflectsProjectPathContract_Issue3186() { diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index d3abbd9a7..c6fafb5b1 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -360,14 +360,14 @@ public void ToolsList_FilteredCatalogMetaReferencesOnlyAdvertisedTools_Issue4177 } [Fact] - public void ToolsList_OutlineAndValidateDoNotExposeHiddenNoopArguments_Issue3198() + public void ToolsList_OutlineAndValidateExposeOnlyImplementedArguments_Issue3198_Issue4897() { var advertisedSchemas = GetAdvertisedToolSchemas(); - AssertToolArgumentsExactly(advertisedSchemas, "outline", ["path"]); + AssertToolArgumentsExactly(advertisedSchemas, "outline", ["path", "fields", "sort", "limit", "cursor", "maxBytes"]); AssertToolArgumentsExactly(advertisedSchemas, "validate", ["kind", "severity", "limit", "cursor", "path", "excludePaths", "excludeTests", "countOnly", "format", "project", "solution"]); - AssertNoopArgumentsAbsent(advertisedSchemas, "outline", ["limit", "includeImports", "maxLineWidth", "lang"]); + AssertNoopArgumentsAbsent(advertisedSchemas, "outline", ["includeImports", "maxLineWidth", "lang"]); AssertNoopArgumentsAbsent(advertisedSchemas, "validate", ["includeImports", "maxLineWidth", "lang"]); static void AssertToolArgumentsExactly( From 5b1d835e1bc90757364bec1ca0b4425a05774c3f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 04:59:43 +0900 Subject: [PATCH 2/3] Keep MCP outline pages in one read snapshot --- src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs index ce5821dfa..5d3916520 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -35,7 +35,7 @@ private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) $"maxBytes must be in [1, {MaxClientResponseJsonBytes}]."); } - return WithDbReader(id, args, reader => + return WithDbReader(id, args, reader => reader.RunInReadSnapshot(() => { QueryCommandRunner.OutlinePageBuildResult BuildPage(int pageLimit) => QueryCommandRunner.BuildOutlinePage( @@ -134,7 +134,7 @@ JsonObject PrepareStructuredContent(QueryCommandRunner.OutlinePageBuildResult pa id, $"Outline: {ConsoleUi.Counted(returnedCount, "symbol")} returned from {ConsoleUi.Counted(page.Outline!.SymbolCount, "symbol")} in {ConsoleUi.Counted(page.Outline.TotalLines, "line")}.", structured); - }); + })); } private static bool TryReadOutlineProjectionFields( From 4bdbb30d55e7e6497232200d257ab79e80180cfe Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 08:49:04 +0900 Subject: [PATCH 3/3] Align outline excerpt hints with returned pages --- .../Cli/QueryCommandRunner.Outline.cs | 7 +++-- .../Mcp/McpToolHandlers.Query.Source.cs | 27 ++++++++++++------- ...cpServerOutlinePaginationIssue4897Tests.cs | 5 ++++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs index 29fc57118..9d749bc06 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs @@ -11,7 +11,8 @@ internal sealed record OutlinePageBuildResult( JsonObject? Payload, OutlineResult? Outline, string? Error, - bool NotFound); + bool NotFound, + IReadOnlyList? PageSymbols = null); public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions) { @@ -287,6 +288,8 @@ internal static OutlinePageBuildResult BuildOutlinePage( return new(null, null, null, NotFound: true); var displaySymbols = ApplyOutlineSort(outline.Symbols, outlineSortMode, includeDerivedMetadata); + var pageOffset = Math.Min(cursorOffset ?? 0, displaySymbols.Count); + var pageSymbols = displaySymbols.Skip(pageOffset).Take(limit).ToList(); var payload = BuildOutlineJsonPayload( outline, displaySymbols, @@ -296,7 +299,7 @@ internal static OutlinePageBuildResult BuildOutlinePage( cursorContext, jsonOptions, compact: false); - return new(payload, outline, null, NotFound: false); + return new(payload, outline, null, NotFound: false, pageSymbols); } private static JsonObject ApplyOutlineCompactCaps(OutlineResult outline, int sectionLimit) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs index 5d3916520..4c5fee348 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -52,16 +52,23 @@ JsonObject PrepareStructuredContent(QueryCommandRunner.OutlinePageBuildResult pa { var structuredContent = page.Payload!; adjustments.ApplyTo(structuredContent); - AddNextStepSuggestion( - structuredContent, - "excerpt", - new JsonObject - { - ["path"] = path, - ["startLine"] = 1, - ["endLine"] = Math.Min(page.Outline!.TotalLines, 80) - }, - "Use excerpt for only the relevant outline range instead of reading the whole file."); + if (page.PageSymbols is { Count: > 0 }) + { + var firstSymbol = page.PageSymbols[0]; + var totalLines = Math.Max(1, page.Outline!.TotalLines); + var startLine = Math.Clamp( + firstSymbol.StartLine > 0 ? firstSymbol.StartLine : firstSymbol.Line, + 1, + totalLines); + var endLine = Math.Min( + totalLines, + Math.Min(Math.Max(startLine, firstSymbol.EndLine), startLine + 79)); + AddNextStepSuggestion( + structuredContent, + "excerpt", + BuildExcerptArgs(path, startLine, endLine), + "Use excerpt for the first symbol in this returned outline page instead of reading the whole file."); + } return structuredContent; } diff --git a/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs b/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs index f65b8faec..2f0cc237e 100644 --- a/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs +++ b/tests/CodeIndex.Tests/McpServerOutlinePaginationIssue4897Tests.cs @@ -36,6 +36,10 @@ public void Outline_PagesLargeDeepTreesProjectsFieldsAndHonorsByteBoundaries_Iss Assert.Equal(175, structured["total_symbol_count"]!.GetValue()); Assert.InRange(structured["returned_symbol_count"]!.GetValue(), 1, 37); Assert.Equal(identities.Count, structured["cursor_offset"]!.GetValue()); + var firstSymbol = structured["symbols"]![0]!; + var excerptArgs = structured["next_step_suggestion"]!["args"]!; + Assert.Equal(firstSymbol["startLine"]!.GetValue(), excerptArgs["startLine"]!.GetValue()); + Assert.Equal(firstSymbol["endLine"]!.GetValue(), excerptArgs["endLine"]!.GetValue()); foreach (var symbol in structured["symbols"]!.AsArray()) { @@ -92,6 +96,7 @@ public void Outline_PagesLargeDeepTreesProjectsFieldsAndHonorsByteBoundaries_Iss Assert.Empty(empty["symbols"]!.AsArray()); Assert.False(empty["has_more"]!.GetValue()); Assert.Null(empty["next_cursor"]); + Assert.Null(empty["next_step_suggestion"]); var byteArguments = new JsonObject {