From c5ac2d10524ed3be04d3fda11ad42bd03f9435ed Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 10 Aug 2026 23:38:47 +0900 Subject: [PATCH 1/3] Bound MCP tool discovery payload (#5059) --- DEVELOPER_GUIDE.md | 8 +- TESTING_GUIDE.md | 2 + changelog.d/unreleased/5059.fixed.md | 23 ++++ src/CodeIndex/Mcp/McpServer.cs | 2 + src/CodeIndex/Mcp/McpToolDefinitions.cs | 118 ++++++++++++++++- .../Mcp/McpToolHandlers.Instructions.cs | 125 ++---------------- .../McpServerOutputSchemaTests.cs | 2 +- .../CodeIndex.Tests/McpServerProtocolTests.cs | 45 +++---- .../McpServerToolsListTests.cs | 103 +++++++++++---- tests/CodeIndex.Tests/McpToolContractTests.cs | 16 ++- 10 files changed, 266 insertions(+), 178 deletions(-) create mode 100644 changelog.d/unreleased/5059.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 351f0fe26..794d1fe56 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2308,7 +2308,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **MCP bounded queues and concurrency gates** — HTTP request queue slots are acquired before `TryWrite`; once full, requests are rejected with HTTP 429, `Retry-After: 1`, `X-Cdidx-Mcp-Rejection: request_queue_limit`, and `http_request_queue_rejection_count` rather than blocking an HTTP handler. The request-log queue is best-effort and increments `http_request_log_queue_full_drop_count` / `http_request_log_dropped_count` on saturation. POST handlers and long-lived event streams use independent admission semaphores, report `concurrent_handler_limit` and `event_stream_limit` separately, and expose their effective capacities plus `http_separate_event_stream_handlers` through HTTP health. Limit environment variables use defaults only when absent; a present malformed or out-of-range value fails before listener startup. Transport-owned queue and handler gates are disposed only after bounded shutdown observes every acquired slot returned, because a late handler may still finish after listener teardown. Frame-loop gates are disposed only after EOF drain observes all request tasks, because bounded drain can intentionally leave late tasks running. - **MCP pagination offset cap** — `references`, `callers`, and `callees` clamp `offset` to 10,000 before executing SQL queries. `tools/list` advertises the maximum in each offset schema, and MCP `status` mirrors it under `mcp.limits.max_pagination_offset`. - **MCP language-catalog pagination** — MCP `languages` builds rows through the same canonical `LanguageCatalog` used by the CLI, then applies exact normalized language/extension/alias filters and ordinal language sorting. Pages default to 20 rows and use opaque response-v2 cursors bound to every filter, `limit`, `maxBytes`, the fixed sort contract, and a fingerprint of the emitted catalog generation (including indexed-language membership for `indexedOnly`). Query changes return `cursor_query_mismatch`; catalog changes return retry-safe `cursor_stale`. `maxBytes` accepts 4,096 through 1,000,000 bytes and measures the complete UTF-8 JSON-RPC envelope, shrinking the row page before emission while keeping filtered totals separate from catalog and capability counts. -- **MCP compact discovery and status projection** — `tools/list` keeps its complete response as both the default and explicit `format: "full"` contract. Opt-in `format: "compact"` replaces long descriptions, schemas, examples, and catalog metadata with bounded summaries and an on-demand full-definition recipe; `names` filters exact enabled tool names only and is capped at 24 names of 128 characters. Opaque continuation cursors preserve compact/name-filtered controls, and name-filtered full responses mark the returned list as scoped while deriving capability metadata from all enabled tools. The compact schema is deliberately non-authoritative and says so in `_meta`. MCP `status.fields` projects exact top-level fields after `format` and optional diagnostic attachments are built, while `api_version` remains part of every structured result. Projection inputs are capped at 32 names, 128 characters each, and 2,048 characters total; unknown names and nested paths fail as `invalid_argument`. Any new discovery or status mode must preserve the no-argument response byte-for-byte and add size/compatibility regression tests. +- **MCP bounded discovery and status projection** — A no-argument `tools/list` returns every enabled tool in deterministic order as the agent-safe `compact` catalog, whose complete JSON-RPC envelope is kept within a 64-KiB UTF-8 budget. Compact entries retain authoritative invocation schemas (types, required fields, constraints, and defaults) while removing documentation-only schema keywords; descriptions are shortened, and optional output schemas and examples are omitted. Request `format: "full"` with exact `names` only when complete descriptions, output schemas, examples, or workflow metadata are needed. Standard opaque cursors preserve compact and name-filtered controls; legacy numeric cursors continue the unfiltered full catalog. `_meta.size_telemetry` reports serialized UTF-8 bytes and approximate tokens for tools, descriptions, input schemas, output schemas, examples, annotations/stability, and catalog metadata without recording arguments. `names` accepts at most 24 exact enabled names of 128 characters each. MCP `status.fields` projects exact top-level fields after `format` and optional diagnostic attachments are built, while `api_version` remains part of every structured result. Projection inputs are capped at 32 names, 128 characters each, and 2,048 characters total; unknown names and nested paths fail as `invalid_argument`. New discovery modes must preserve tool reachability, invocation-schema authority, deterministic standard pagination, the default byte budget, and explicit-full compatibility. - **MCP resource-list cursor stability** — `resources/list` emits a fixed-size opaque keyset cursor that binds the last consumed file id to a persisted indexed-file generation and the canonical discovery filters. The reader resolves that id back to the existing source/test/docs bucket plus path ordering inside the same SQLite snapshot. Any file insertion, deletion, or update changes the generation; a later page then returns `-32011` / `index_stale` with `restart_required: true`. Changing `path`, `lang`, or `includeGenerated` between pages returns `-32602` / `resources_list_filters_changed` with the same restart requirement. In either case, the client must omit `params.cursor` to restart instead of continuing across mixed snapshots or filters. Writable legacy databases install the generation row and triggers through the normal read migration before a cursor is issued. A mutable read-only legacy database that cannot prove generation tracking returns `resources_list_generation_unavailable` with `migration_required: true`; a canonical, unambiguous `immutable=1` legacy URI (optionally paired with `mode=ro`) may safely use connection-local generation zero because it cannot change between pages. Encoded, case-variant, whitespace-padded, duplicated, conflicting, or extra query parameters are not trusted as that immutable guarantee. The legacy decimal zero remains a first-page upgrade input, but nonzero decimal offsets cannot prove their source generation and therefore return the same restart-required error; decimal cursors are never emitted. Version-1 opaque cursors remain valid only with the default unfiltered view. - **MCP file resource discovery** — `resources/templates/list` advertises `cdidx://file-path/{path}` so a client that already knows an exact repository-relative path can construct a `resources/read` URI without paging the repository inventory. Simple URI-template expansion percent-encodes separators and reserved filename characters such as `?` and `#`; the template-only resolver decodes the value once, rejects absolute paths, traversal, backslashes, empty segments, queries, and fragments, then returns the canonical `cdidx://file/` identity. Canonical resource URIs continue to reject encoded separators. `resources/list` accepts `path` as one string or at most 100 strings of at most 1024 characters and 128 wildcard operators each, using the same anchored directory/glob semantics as file queries, plus an exact normalized `lang` filter and `includeGenerated` (default `false`). Generated files also require `includeGenerated: true` for direct reads. - **MCP resource-list response budget** — `resources/list.params.maxBytes` accepts 4,096 through 1,000,000 bytes and defaults to 1,000,000, matching the default HTTP response-body cap. The effective budget is the minimum of that request, the server-wide MCP envelope cap, and the active HTTP transport response-body cap (when applicable), so a lower configured HTTP cap shapes a valid page instead of rejecting it with HTTP 500. The server measures the complete JSON-RPC envelope, keeps 200 as the candidate ceiling, and stops before the next resource would cross the effective byte budget. For HTTP JSON-RPC batches, the active transport budget covers the complete response array, including brackets and commas, and is divided fairly among response-bearing items; notifications consume no response slot. Each `resources/list` item honors its current share and preserves its request ID in a canonical budget error if even a bounded page cannot fit. State-changing and other non-resource outcomes are never relabeled as retry-safe; an aggregate overflow after execution reports an unknown completion state and forbids automatic retry. `_meta.response_controls` reports the requested/effective budgets, consumed and returned counts, `omitted_resource_count`, bounded reason counts (`resource_uri_too_long` / `resource_exceeds_max_bytes`), `byte_budget_reached`, and `continuation_reason` (`byte_budget`, `item_limit`, or `completed`). A continuation cursor anchors the last consumed database row; a valid resource that did not fit remains unconsumed for the next page, while a resource that cannot fit even on an empty page is consumed and counted so pagination cannot livelock. @@ -2318,7 +2318,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **MCP stability markers and naming** — Every tool advertises `x-stability` (`stable`, `experimental`, or `deprecated`). MCP structured payload keys use snake_case, matching the CLI JSON contract; do not add camelCase aliases for new fields. - **MCP language-support clauses** — Every advertised MCP tool description ends with a `Language support:` clause generated through `McpServer.CreateToolDefinition`. Graph tools enumerate `ReferenceExtractor.GetSupportedLanguages()`, symbol tools enumerate `SymbolExtractor.GetSupportedLanguages()`, and file/content tools point at the detected-language catalog used by `cdidx languages`, so `tools/list` stays aligned with the runtime registries instead of carrying hand-maintained prose. - **MCP tool annotations** — All tools emit `annotations` with `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` per the MCP spec, so AI clients can auto-approve safe read-only queries. -- **MCP server instructions** — The `initialize` response includes an `instructions` string with tool-selection guidance so AI clients can choose the right tool on first connection. +- **MCP server instructions** — The `initialize` response keeps first-contact guidance within a 2-KiB UTF-8 budget. It explains bounded `tools/list` discovery and on-demand full definitions, advertises only enabled tools, and directs extended workflows to `prompts/list` and `prompts/get` instead of duplicating the full catalog. - **Per-deployment MCP tool enablement** — `cdidx mcp` honors two environment variables so operators can narrow the exposed tool surface without a code change (#1561). `CDIDX_MCP_TOOLS_ALLOW=` is a strict allowlist; if set, only those tools appear in `tools/list` and are dispatched by `tools/call`. `CDIDX_MCP_TOOLS_DENY=` removes individual tools from the default-all-enabled set. Allow wins over deny when both are set. The single source of truth for known tool names is `McpToolFilter.KnownToolNames`, which is checked against by both the `tools/list` filter and the `tools/call` gate (and the per-slot guard inside `batch_query`). `BuildInstructions` is also gate-aware: scoped deployments never recommend a disabled tool in the `initialize` instructions, so the guidance stays in sync with the advertised surface. Top-level `tools/call` on a disabled known tool returns `-32601 Tool not enabled: `; `batch_query` envelopes succeed but each disabled-tool slot carries a `code: -32601` field alongside the `error` string so clients can branch on the code without parsing prose. Truly unknown names still fall through to the existing `-32602 Unknown tool` path so operator-disabled tools remain distinguishable from typos. Tool names compare case-insensitively, unknown env-var entries are filtered against the known set (an allowlist of only-unknown names intentionally exposes nothing rather than silently disabling the gate), and the default — no env vars set — keeps every tool enabled so existing deployments are unaffected. - **Backward-compatible symbol schema** — Opening an older DB with a newer binary auto-adds missing symbol columns when possible, including hotspot-family metadata such as `container_qualified_name` and `family_key`. If a read path cannot migrate the DB in place, symbol queries fall back to the legacy column set instead of crashing. - **Bounded hotspot aggregation** — `DbWriter` maintains `hotspot_reference_counts` as compact per-file logical-reference totals. Limited hotspot readers use its rank index to select a fixed bounded candidate frontier before the non-SQL, SQL exact, SQL leaf, ambiguity, and target-family joins; they do not rematerialize the complete `symbol_references` graph on every query. Logical-site identity excludes raw aliases, mutations refresh cross-file context dependents and demote reference-identity trust transactionally, and reader/writer aggregate SQL is cancellation-interruptible. Bulk-eligible refreshes with at least 64 dirty file IDs use primary-key seeks plus a bounded total-row probe to drop the four query indexes only when existing dirty aggregate rows cover at least three-fifths of the table; an empty pre-refresh aggregate also qualifies for fresh and rebuild runs, while skewed or small updates retain every index. Qualifying runs rebuild the indexes once after the set-based insert in the same transaction. Writable legacy databases create and backfill the table transactionally, while immutable legacy readers retain the raw-reference compatibility path. @@ -6005,7 +6005,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **MCP の有界 queue / concurrency gate** — HTTP request queue は `TryWrite` 前に slot を取り、満杯時は handler を block せず HTTP 429、`Retry-After: 1`、`X-Cdidx-Mcp-Rejection: request_queue_limit` で拒否する。POST handler と長寿命 event stream は独立した admission semaphore を使い、HTTP health は両方の有効 capacity と `http_separate_event_stream_handlers` を返す。limit 環境変数は未設定の場合だけ既定値を使い、設定済みの malformed 値または範囲外値は listener 起動前に失敗する。transport 所有 gate は bounded shutdown で全取得 slot の返却を確認できた場合だけ dispose する。 - **MCP pagination offset 上限** — `references`、`callers`、`callees` は SQL query 実行前に `offset` を 10,000 へクランプする。`tools/list` は各 offset schema に最大値を広告し、MCP `status` も `mcp.limits.max_pagination_offset` に同じ値を返す。 - **MCP 言語 catalog の pagination** — MCP `languages` は CLI と共通の canonical `LanguageCatalog` から row を構築し、正規化した言語・拡張子・alias の完全一致 filter と言語名の ordinal sort を適用する。page は既定 20 行で、opaque な response-v2 cursor を全 filter、`limit`、`maxBytes`、固定 sort contract、出力 catalog generation の fingerprint(`indexedOnly` では indexed-language membership も含む)へ拘束する。query 変更は `cursor_query_mismatch`、catalog 変更は retry-safe な `cursor_stale` を返す。`maxBytes` は 4,096〜1,000,000 bytes を受け付け、UTF-8 JSON-RPC envelope 全体を計測して送信前に row page を縮小し、filtered total を catalog 件数および capability 件数から分離したまま返す。 -- **MCP compact discovery と status projection** — `tools/list` は完全 response を既定値および明示的な `format: "full"` 契約として維持する。opt-in の `format: "compact"` は長い説明、schema、example、catalog metadata を上限付き要約と完全定義の on-demand 取得方法へ置き換える。`names` は有効化済み tool の exact name だけを filter し、128 文字の名前を最大 24 件まで受け付ける。opaque な continuation cursor は compact / name-filtered control を保持し、name-filtered な full response は返却 list を限定 scope と明示しつつ、有効な全 tool から capability metadata を構築する。compact schema は意図的に非 authoritative であり、その旨を `_meta` に明示する。MCP `status.fields` は `format` と任意 diagnostic attachment の構築後に exact な top-level field を project し、`api_version` はすべての structured result に残す。projection 入力は最大 32 件、各 128 文字、合計 2,048 文字で、未知名と nested path は `invalid_argument` とする。新しい discovery / status mode を追加する場合は、引数なし response を byte-for-byte で維持し、size と互換性の回帰テストを追加すること。 +- **MCP の bounded discovery と status projection** — 引数なしの `tools/list` は、有効な全 tool を決定的順序で agent-safe な `compact` catalog として返し、JSON-RPC envelope 全体を UTF-8 で 64 KiB 以内に保つ。compact entry は呼び出しに必要な authoritative schema(型、必須 field、制約、既定値)を維持し、schema の説明専用 keyword だけを除去する。description は短縮し、任意の output schema と example は省略する。完全な description、output schema、example、workflow metadata が必要な場合だけ、正確な `names` と `format: "full"` を指定する。標準の opaque cursor は compact / name-filtered control を維持し、legacy の数値 cursor は filter なし full catalog を継続する。`_meta.size_telemetry` は引数を記録せず、tools、description、input schema、output schema、example、annotation/stability、catalog metadata ごとの serialized UTF-8 bytes と概算 token 数を返す。`names` は 128 文字以内の有効 tool 完全名を最大 24 件受け付ける。MCP `status.fields` は `format` と任意 diagnostic attachment の構築後に exact な top-level field を project し、`api_version` はすべての structured result に残す。projection 入力は最大 32 件、各 128 文字、合計 2,048 文字で、未知名と nested path は `invalid_argument` とする。新しい discovery mode は tool 到達性、呼び出し schema の authority、決定的な標準 pagination、既定 byte budget、明示的 full の互換性を維持すること。 - **MCP ファイル resource discovery** — `resources/templates/list` は `cdidx://file-path/{path}` を公開し、正確なリポジトリ相対 path が既知の client は全 inventory をページングせず `resources/read` URI を構築できる。simple URI-template expansion は separator と `?` / `#` などの予約 filename 文字を percent encode する。template 専用 resolver は値を一度だけ decode し、absolute path、traversal、backslash、空 segment、query、fragment を拒否して canonical な `cdidx://file/` identity を返す。canonical resource URI は encoded separator を引き続き拒否する。`resources/list` は `path` に 1 文字列または各 1024 文字・wildcard operator 128 個以内かつ最大 100 件の文字列を受け付け、file query と同じ anchored directory / glob semantics を使うほか、正規化した完全一致の `lang` と `includeGenerated`(既定 `false`)も受け付ける。cursor は generation と canonical filter の両方に結び付き、ページ間の filter 変更は `-32602` / `resources_list_filters_changed` と `restart_required: true` を返す。generated file は discovery と direct read のどちらでも `includeGenerated: true` が必要である。 - **MCP resource list カーソルの安定性** — `resources/list` は、最後に消費した file id と永続化されたインデックス済みファイル世代を結び付ける固定長の不透明 keyset cursor を返す。reader は同じ SQLite snapshot 内でその id を既存の source/test/docs bucket と path の並び順へ解決する。ファイルの追加・削除・更新で世代が変わると、後続ページは `restart_required: true` 付きの `-32011` / `index_stale` を返し、混在 snapshot を続行せず `params.cursor` を省略して再開する必要がある。書き込み可能な legacy DB は cursor 発行前に通常の read migration で世代 row と trigger を導入する。世代追跡を証明できない変更可能な read-only legacy DB は `migration_required: true` 付きの `resources_list_generation_unavailable` を返すが、canonical かつ曖昧性のない `immutable=1` legacy URI(任意で `mode=ro` を併記)はページ間で変化しないため connection-local な世代 0 を安全に利用できる。encoded、case variant、空白付き、重複、競合、または余分な query parameter はこの immutable 保証として信頼しない。旧 decimal の 0 は先頭ページ用の移行入力として残すが、0 以外の decimal offset は発行時世代を検証できないため同じ再開必須 error を返し、decimal cursor は出力に使わない。 - **MCP resource list の response budget** — `resources/list.params.maxBytes` は 4,096〜1,000,000 bytes を受け付け、既定値は HTTP response body の既定上限と同じ 1,000,000。有効 budget はこの要求値、server-wide MCP envelope 上限、および該当時の active HTTP transport response-body 上限の最小値とし、HTTP の設定上限が低い場合も HTTP 500 で拒否せず有効なページへ整形する。server は JSON-RPC envelope 全体を計測し、candidate 上限を 200 件に保ったまま、次の resource で有効 byte budget を超える直前に停止する。HTTP JSON-RPC batch では active transport budget を bracket と comma を含む response 配列全体へ適用し、応答対象 item へ公平に分配する。notification は response slot を消費しない。各 `resources/list` item は現在の割当を守り、有界なページさえ収まらない場合も canonical な budget error に request ID を保持する。state-changing item とその他の non-resource outcome を retry-safe として付け替えることはなく、実行後の aggregate overflow は completion state が unknown で自動再試行不可であることを報告する。`_meta.response_controls` は要求/有効 budget、消費/返却件数、`omitted_resource_count`、有界な理由別件数(`resource_uri_too_long` / `resource_exceeds_max_bytes`)、`byte_budget_reached`、`continuation_reason`(`byte_budget`、`item_limit`、`completed`)を返す。継続 cursor は最後に消費した DB row を anchor とし、収まらなかった有効 resource は次ページ用に未消費のまま残すが、空ページにも収まらない resource は消費して件数へ計上し、pagination の livelock を防ぐ。 @@ -6015,7 +6015,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **MCP stability marker と命名** — すべての tool は `x-stability`(`stable`、`experimental`、`deprecated`)を公開する。MCP の構造化 payload key は CLI JSON 契約に合わせて snake_case を使う。新規 field に camelCase alias を追加しないこと。 - **MCP の言語サポート句** — 公開されるすべての MCP ツール説明は、`McpServer.CreateToolDefinition` で生成される `Language support:` 句で終わる。Graph 系ツールは `ReferenceExtractor.GetSupportedLanguages()`、symbol 系ツールは `SymbolExtractor.GetSupportedLanguages()`、file/content 系ツールは `cdidx languages` と同じ検出言語カタログを参照するため、`tools/list` は手書き説明ではなく実行時レジストリと同期する。 - **MCPツールアノテーション** — 全ツールが MCP 仕様に沿った `annotations`(`readOnlyHint`、`destructiveHint`、`idempotentHint`、`openWorldHint`)を返し、AIクライアントが安全な読み取り専用クエリを自動承認できるようにする。 -- **MCPサーバー instructions** — `initialize` レスポンスにツール選択ガイダンスの `instructions` 文字列を含め、AIクライアントが初回接続時に適切なツールを選べるようにする。 +- **MCP サーバー instructions** — `initialize` response の初回案内は UTF-8 で 2 KiB 以内に保つ。bounded な `tools/list` discovery と完全定義の on-demand 取得を説明し、有効な tool だけを案内し、完全 catalog の説明を重複させず詳細 workflow を `prompts/list` と `prompts/get` へ誘導する。 - **デプロイ単位での MCP ツール有効化** — `cdidx mcp` が 2 つの環境変数を尊重し、コード変更なしに公開ツールを絞れるようにする (#1561)。`CDIDX_MCP_TOOLS_ALLOW=` は厳格な allowlist で、指定された場合はそのツールだけが `tools/list` に現れ `tools/call` で dispatch される。`CDIDX_MCP_TOOLS_DENY=` は既定の全有効集合から個別ツールを除外する。両方指定された場合は allow を優先。既知ツール名の真実の源は `McpToolFilter.KnownToolNames` に集約し、`tools/list` 側の filter、`tools/call` 側のゲート、`batch_query` の slot ガードのいずれもここを参照する。`BuildInstructions` もゲート対応で、scoped デプロイの `initialize` instructions では無効化されたツールを推奨しなくなり、案内と公開面が一致する。トップレベル `tools/call` で無効化された既知ツールを呼ぶと `-32601 Tool not enabled: ` を返し、`batch_query` 自体はエンベロープとして成功するが各無効化スロットに `code: -32601` が `error` 文字列と並んで載るため、クライアントは prose を parsing せず code で分岐できる。サーバーに無い名前は既存の `-32602 Unknown tool` に流すことでオペレータ無効化と typo を区別できる。ツール名比較は大小文字無視、env var 内の未知名は既知集合で filter(typo で未知名のみの allowlist は意図的に何も公開しないため、ゲートが silent に外れない)。env var を一切設定しない既定挙動は全ツール有効なので、既存デプロイへの影響はない。 - **トリガー付きコンテンツ外部参照FTS5** — `chunks`テーブルを参照しコピーを保存しないことでストレージ倍増を回避。データベーストリガーでFTSインデックスを自動同期。 - **extractor regex の backtracking policy** — built-in symbol/reference extractor は repository-controlled な file content に対して unbounded regex match を使わない。backtracking regex は `BoundedRegex.DefaultMatchTimeout` を使い、`RegexOptions.NonBacktracking` は non-backtracking engine と互換な pattern で使ってよい。lookaround-heavy な extractor や balancing-group を使う extractor など、意図的に backtracking-only のまま残す pattern は、共有 timeout audit の対象になる場合だけ許容する。将来の extractor が `System.Text.RegularExpressions.Regex` を直接使う必要がある場合は、明示 timeout を渡し、`BoundedRegex` や `NonBacktracking` が適さない理由を文書化すること。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 72fa5b3c1..e2cd8f580 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -603,6 +603,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. + Bounded MCP discovery coverage must keep the default 25-tool `tools/list` envelope at or below 64 KiB of serialized UTF-8 while preserving authoritative invocation-schema structure, deterministic ordering and opaque pagination, explicit-full reachability for output schemas/examples, legacy numeric-cursor compatibility, and section-level UTF-8 byte telemetry (including non-ASCII descriptions). Initialize coverage must keep `instructions` at or below 2 KiB and verify that extended guidance is discoverable through prompts. 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. @@ -1601,6 +1602,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組だけ公開してください。 + bounded MCP discovery の coverage では、既定の 25-tool `tools/list` envelope を serialized UTF-8 で 64 KiB 以下に保ちつつ、authoritative な呼び出し schema 構造、決定的な順序と opaque pagination、output schema / example への明示的 full 経路、legacy 数値 cursor の互換性、non-ASCII description を含む section ごとの UTF-8 byte telemetry を検証してください。initialize coverage は `instructions` を 2 KiB 以下に保ち、詳細 guidance を prompt から発見できることも確認してください。 大量 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 が受理されることを証明してください。 diff --git a/changelog.d/unreleased/5059.fixed.md b/changelog.d/unreleased/5059.fixed.md new file mode 100644 index 000000000..d4cf98fc9 --- /dev/null +++ b/changelog.d/unreleased/5059.fixed.md @@ -0,0 +1,23 @@ +--- +category: fixed +issues: + - 5059 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs + - tests/CodeIndex.Tests/McpServerToolsListTests.cs + - tests/CodeIndex.Tests/McpServerProtocolTests.cs + - tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs + - tests/CodeIndex.Tests/McpToolContractTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **MCP discovery now fits an agent-safe bounded payload (#5059; follow-up to #4336)** — the default 25-tool `tools/list` response now stays within 64 KiB while retaining authoritative invocation schemas, deterministic standard pagination, on-demand full definitions, and section-level UTF-8 size telemetry. Initialize guidance is also concise and points clients to prompts for extended workflows. + +## 日本語 + +- **MCP discovery を agent-safe な上限付き payload に収めました (#5059、#4336 の follow-up)** — 既定の 25-tool `tools/list` response を 64 KiB 以内に保ちながら、authoritative な呼び出し schema、決定的な標準 pagination、完全定義の on-demand 取得、section ごとの UTF-8 size telemetry を維持します。initialize guidance も短縮し、詳細 workflow は prompt へ誘導します。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index ca6305020..b26da29dd 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -246,6 +246,8 @@ public partial class McpServer : IDisposable internal const int MaxResourceReadCursorCharacters = 128; internal const int DefaultToolsListPageSize = 25; internal const int MaxToolsListPageSize = 25; + internal const int DefaultToolsListResponseByteBudget = 64 * 1024; + internal const int DefaultInitializeInstructionsByteBudget = 2 * 1024; internal const int MaxToolsListNameFilters = 24; internal const int MaxToolsListNameCharacters = 128; internal const int MaxToolsListCursorCharacters = 8_192; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index f86ee5760..0ba2ad700 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -42,7 +42,7 @@ private JsonNode CreateToolsListResponse(JsonNode? id, JsonNode? listParams, Jso return CreateToolsListParamsError(id); var paramsObject = listParams as JsonObject; - var catalogFormat = "full"; + var catalogFormat = "compact"; if (paramsObject?.ContainsKey("format") == true && (paramsObject["format"] is not JsonValue formatValue || !formatValue.TryGetValue(out catalogFormat) @@ -85,6 +85,23 @@ private JsonNode CreateToolsListResponse(JsonNode? id, JsonNode? listParams, Jso catalogFormat = cursorFormat; requestedNames = cursorNames; } + else + { + // Numeric cursors were emitted by the legacy unfiltered full catalog. + // Keep accepting them as full-catalog continuations so clients that persisted + // a cursor across the default-format change do not silently switch schemas. + // 数値 cursor は従来の filter なし full catalog が返していた値。既定 format + // 変更をまたいで cursor を保持した client が schema を暗黙変更しないよう、 + // full catalog の継続として受理する。 + if ((paramsObject.ContainsKey("format") && catalogFormat != "full") + || paramsObject.ContainsKey("names")) + { + return CreateToolsListCursorError(id); + } + + catalogFormat = "full"; + requestedNames = null; + } } var selected = filtered; @@ -122,6 +139,7 @@ private JsonNode CreateToolsListResponse(JsonNode? id, JsonNode? listParams, Jso var nextOffset = offset + pageSize; if (nextOffset <= MaxMcpPaginationOffset && nextOffset < selected.Count) result["nextCursor"] = CreateToolsListCursor(nextOffset, catalogFormat, requestedNames); + AddToolsListSizeTelemetry(page, catalogMeta, catalogFormat); return CreateSuccessResponse(id, result); } @@ -309,7 +327,7 @@ private static JsonObject BuildCompactToolCatalogEntry(JsonObject tool) { ["name"] = tool["name"]?.DeepClone(), ["description"] = BuildCompactToolDescription(tool["description"]?.GetValue()), - ["inputSchema"] = new JsonObject { ["type"] = "object" }, + ["inputSchema"] = BuildInvocationSchema(tool["inputSchema"]), }; if (tool["annotations"] is not null) compact["annotations"] = tool["annotations"]!.DeepClone(); @@ -318,6 +336,38 @@ private static JsonObject BuildCompactToolCatalogEntry(JsonObject tool) return compact; } + private static JsonNode? BuildInvocationSchema(JsonNode? schema) => schema switch + { + JsonObject schemaObject => BuildInvocationSchemaObject(schemaObject), + JsonArray schemaArray => new JsonArray(schemaArray.Select(BuildInvocationSchema).ToArray()), + null => null, + _ => schema.DeepClone(), + }; + + private static JsonObject BuildInvocationSchemaObject(JsonObject schema) + { + var invocationSchema = new JsonObject(); + foreach (var (name, value) in schema) + { + if (name is "description" or "title" or "examples" or "$comment") + continue; + invocationSchema[name] = name is "properties" or "patternProperties" or "$defs" or "definitions" + or "dependentSchemas" or "dependentRequired" or "dependencies" + && value is JsonObject schemaMap + ? BuildInvocationSchemaMap(schemaMap) + : BuildInvocationSchema(value); + } + return invocationSchema; + } + + private static JsonObject BuildInvocationSchemaMap(JsonObject schemaMap) + { + var invocationSchemaMap = new JsonObject(); + foreach (var (name, value) in schemaMap) + invocationSchemaMap[name] = BuildInvocationSchema(value); + return invocationSchemaMap; + } + private static string BuildCompactToolDescription(string? description) { if (string.IsNullOrWhiteSpace(description)) @@ -355,11 +405,18 @@ private static JsonObject BuildCompactToolsListCatalogMeta( }, ["discovery_contract"] = new JsonObject { + ["tools_list_is_authoritative"] = !namesFiltered, ["disabled_tools_are_omitted"] = true, - ["input_schemas_are_authoritative"] = false, + ["input_schemas_are_authoritative"] = true, + ["descriptions_are_complete"] = false, + ["output_schemas_are_included"] = false, + ["examples_are_included"] = false, ["full_definitions_available_on_demand"] = true, ["name_filter_param"] = "params.names", ["pagination_supported"] = true, + ["cursor_param"] = "params.cursor", + ["limit_param"] = "params.limit", + ["next_cursor_field"] = "result.nextCursor", }, ["response_controls"] = new JsonObject { @@ -369,11 +426,66 @@ private static JsonObject BuildCompactToolsListCatalogMeta( ["tools_offset"] = offset, ["tools_page_size"] = pageSize, ["names_filtered"] = namesFiltered, + ["default_tools_list_page_size"] = DefaultToolsListPageSize, + ["max_tools_list_page_size"] = MaxToolsListPageSize, + ["default_response_budget_utf8_bytes"] = DefaultToolsListResponseByteBudget, ["max_tool_name_filters"] = MaxToolsListNameFilters, ["max_pagination_offset"] = MaxMcpPaginationOffset, }, }; + private static void AddToolsListSizeTelemetry(JsonArray tools, JsonObject catalogMeta, string format) + { + var descriptions = 0; + var inputSchemas = 0; + var outputSchemas = 0; + var examples = 0; + var annotationsAndStability = 0; + foreach (var tool in tools.OfType()) + { + descriptions += GetJsonUtf8ByteCount(tool["description"]); + inputSchemas += GetJsonUtf8ByteCount(tool["inputSchema"]); + outputSchemas += GetJsonUtf8ByteCount(tool["outputSchema"]); + examples += GetJsonUtf8ByteCount(tool["examples"]); + annotationsAndStability += GetJsonUtf8ByteCount(tool["annotations"]); + annotationsAndStability += GetJsonUtf8ByteCount(tool["x-stability"]); + } + + var capabilityMetadata = GetJsonUtf8ByteCount(catalogMeta["purpose"]) + + GetJsonUtf8ByteCount(catalogMeta["first_time_ai_guide"]) + + GetJsonUtf8ByteCount(catalogMeta["capability_groups"]) + + GetJsonUtf8ByteCount(catalogMeta["recommended_workflows"]); + var metadataBytes = GetJsonUtf8ByteCount(catalogMeta); + catalogMeta["size_telemetry"] = new JsonObject + { + ["measurement"] = "serialized JSON value UTF-8 bytes", + ["approximate_tokens"] = "ceil(utf8_bytes / 4)", + ["contains_tool_arguments"] = false, + ["format"] = format, + ["sections"] = new JsonObject + { + ["tools"] = CreateToolsListSizeMetric(GetJsonUtf8ByteCount(tools)), + ["descriptions"] = CreateToolsListSizeMetric(descriptions), + ["input_schemas"] = CreateToolsListSizeMetric(inputSchemas), + ["output_schemas"] = CreateToolsListSizeMetric(outputSchemas), + ["examples"] = CreateToolsListSizeMetric(examples), + ["annotations_and_stability"] = CreateToolsListSizeMetric(annotationsAndStability), + ["capability_metadata"] = CreateToolsListSizeMetric(capabilityMetadata), + ["catalog_metadata_before_size_telemetry"] = CreateToolsListSizeMetric(metadataBytes), + }, + }; + } + + private static JsonObject CreateToolsListSizeMetric(int utf8Bytes) => new() + { + ["utf8_bytes"] = utf8Bytes, + ["approximate_tokens"] = (utf8Bytes + 3) / 4, + }; + + private static int GetJsonUtf8ByteCount(JsonNode? node) => node is null + ? 0 + : Encoding.UTF8.GetByteCount(node.ToJsonString()); + private static JsonObject BuildToolsListCatalogMeta(JsonArray tools, int returnedToolCount, int offset, int pageSize) { var enabledToolNames = GetAdvertisedToolNames(tools); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs index 849976292..2a7af7042 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs @@ -3,7 +3,6 @@ using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; -using CodeIndex.Indexer; using CodeIndex.Models; namespace CodeIndex.Mcp; @@ -12,135 +11,39 @@ public partial class McpServer { /// /// Build the server instructions string for the initialize response. - /// Uses the actual supported-language list from ReferenceExtractor and skips guidance - /// for any tool the operator disabled through the #1561 enablement gate so scoped - /// deployments do not advertise tools that the gate would reject. + /// Keeps first-contact guidance bounded and skips guidance for any tool the operator + /// disabled through the #1561 enablement gate so scoped deployments do not advertise + /// tools that the gate would reject. /// initializeレスポンス用のサーバー指示文字列を構築。 - /// ReferenceExtractorの実際の対応言語リストを使用し、#1561 の有効化ゲートで無効化された - /// ツールについての案内は除外する(scoped デプロイで無効ツールが advertise されないように)。 + /// 初回案内を bounded に保ち、#1561 の有効化ゲートで無効化されたツールについての案内は + /// 除外する(scoped デプロイで無効ツールが advertise されないように)。 /// private string BuildInstructions() { bool On(string name) => _toolFilter.IsEnabled(name); - bool All(params string[] names) - { - foreach (var n in names) - if (!On(n)) return false; - return true; - } - var parts = new List { - "cdidx is a local-first code-index server. Prefer CodeIndex MCP tools before shell grep/find/cat when investigating indexed repositories; use whole-file reads only after narrowing the target. cdidx は local-first なコード検索・取得サーバーです。インデックス済みリポジトリの調査では shell の grep/find/cat を乱発する前に CodeIndex MCP tools を優先し、ファイル全体の読み取りは対象を絞ってから使ってください。", + "cdidx is a local-first code-index server. Prefer its focused MCP tools before shell grep/find/cat or whole-file reads. cdidx は local-first なコード索引サーバーです。shell の grep/find/cat やファイル全体の読み取りより、絞り込んだ MCP tool を優先してください。", + "The default tools/list page is a bounded catalog with authoritative invocation schemas. Request format=full with exact names only when detailed descriptions, output schemas, or examples are needed, and continue pagination with nextCursor unchanged. 既定の tools/list は呼び出し用 schema を保持した bounded catalog です。詳細説明、output schema、example が必要な場合だけ正確な names と format=full を指定し、nextCursor は変更せず継続してください。", + "Use prompts/list and prompts/get for extended workflows such as investigate_before_edit. 詳細な workflow は prompts/list と prompts/get(例: investigate_before_edit)から取得してください。", }; if (On("index")) - parts.Add("If queries fail because no index exists, run 'index' first to build it."); - - if (All("map", "search", "definition")) - parts.Add("Start with 'map' for repo orientation, then use 'search' for text queries or 'definition' for symbol lookup."); - else if (All("search", "definition")) - parts.Add("Use 'search' for text queries or 'definition' for symbol lookup."); - else if (On("search")) - parts.Add("Use 'search' for text queries."); - else if (On("definition")) - parts.Add("Use 'definition' for symbol lookup."); + parts.Add("If no index exists, call 'index' first. index が無い場合は最初に 'index' を呼んでください。"); var guidedFlowTools = new List(); - foreach (var name in new[] { "search", "definition", "references", "callers", "callees", "outline", "map", "excerpt", "read_resource" }) + foreach (var name in new[] { "status", "map", "search", "definition", "references", "callers", "callees", "outline", "excerpt", "read_resource" }) if (On(name)) guidedFlowTools.Add(name); if (guidedFlowTools.Count > 0) { - parts.Add("Investigation flow: search broadly, use definition for declarations, references for usage sites, callers/callees for call graph impact, outline/map for structure, then excerpt or resources/read for focused line ranges. Prefer pagination, path/lang filters, exactName/exactSubstring, and prefix over dumping large files. 調査順序: まず広く search し、宣言は definition、利用箇所は references、呼び出し影響は callers/callees、構造把握は outline/map、その後に excerpt または resources/read で必要な行範囲だけを読んでください。大きなファイルを丸ごと読む前に pagination、path/lang filter、exactName/exactSubstring、prefix で絞り込んでください。"); + var advertisedNames = string.Join(", ", guidedFlowTools.Select(name => $"'{name}'")); + parts.Add($"Enabled investigation tools: {advertisedNames}. Narrow with pagination and path/language filters before reading source. 有効な調査 tool: {advertisedNames}。source を読む前に pagination と path/language filter で絞り込んでください。"); } if (On("read_resource")) - parts.Add("For an exact known repository-relative path, expand the cdidx://file-path/{path} template from resources/templates/list and pass that URI to the typed `read_resource` tool. The legacy resources/read method remains available. 正確なリポジトリ相対 path が分かっている場合は、resources/templates/list の cdidx://file-path/{path} template を展開し、その URI を型付き `read_resource` tool に渡してください。従来の resources/read method も引き続き利用できます。"); + parts.Add("For a known path, expand cdidx://file-path/{path} from resources/templates/list and call 'read_resource'; resources/read remains compatible. 既知の path は resources/templates/list の template を展開して 'read_resource' を呼び、従来の resources/read も利用できます。"); else - parts.Add("For an exact known repository-relative path, expand the cdidx://file-path/{path} template from resources/templates/list and pass that URI to resources/read. 正確なリポジトリ相対 path が分かっている場合は、resources/templates/list の cdidx://file-path/{path} template を展開し、その URI を resources/read に渡してください。"); - parts.Add("For browsing, call resources/list with optional path, lang, includeGenerated, and maxBytes; continue with result.nextCursor and unchanged filters. Generated files are excluded by default. browse する場合は resources/list に任意の path、lang、includeGenerated、maxBytes を渡し、同じ filter のまま result.nextCursor で継続してください。generated file は既定で除外されます。"); - if (On("read_resource")) - parts.Add("For typed `read_resource`, use 1-based inclusive startLine/endLine and a UTF-8 maxBytes budget, then continue truncated reads with structuredContent._meta.nextCursor. Legacy resources/read exposes the same continuation as result._meta.nextCursor. 型付き `read_resource` では 1-based inclusive の startLine/endLine と UTF-8 maxBytes budget を使い、切り詰められた場合は structuredContent._meta.nextCursor で継続してください。従来の resources/read は同じ継続情報を result._meta.nextCursor に返します。"); - else - parts.Add("For resources/read, use startLine/endLine and maxBytes, then continue truncated reads with result._meta.nextCursor. resources/read では startLine/endLine と maxBytes を使い、切り詰められた場合は result._meta.nextCursor で継続してください。"); - - if (On("analyze_symbol")) - parts.Add("Use 'analyze_symbol' to get definition, callers, callees, and references in one call instead of chaining separate tools."); - - var graphEnabled = new List(); - foreach (var name in new[] { "references", "callers", "callees" }) - if (On(name)) graphEnabled.Add(name); - if (graphEnabled.Count > 0) - { - var languageCatalog = LanguageCapabilityCatalog.Build( - workspaceRoot: null, - QueryCommandRunner.GetLanguageAliases); - var referenceLanguageNames = ReferenceExtractor.GetSupportedLanguages(); - var langs = string.Join( - ", ", - languageCatalog.Languages - .Where(language => language.Value.Graph) - .SelectMany(language => new[] { language.Key } - .Concat(language.Value.Aliases.Where(referenceLanguageNames.Contains))) - .Distinct(StringComparer.Ordinal)); - var names = string.Join(", ", graphEnabled); - var sentence = $"Graph tools ({names}) only work for supported languages ({langs});"; - sentence += On("search") - ? " for other languages, use 'search' instead." - : " for other languages, these tools have no answers."; - parts.Add(sentence); - } - - if (On("outline")) - parts.Add("Use 'outline' to see the full symbol structure of a single file (functions, classes, properties, interfaces, enums with line numbers) without reading the file content."); - - if (On("symbols")) - parts.Add("Filter symbols by kind using the 'kind' parameter: function, class, struct, interface, enum, property, event, delegate, namespace, import."); - - if (On("find_in_file")) - parts.Add("Use 'find_in_file' for literal substring navigation when the target file is already known."); - - if (On("excerpt")) - parts.Add("Use 'excerpt' to read specific line ranges from indexed files."); - - if (On("status")) - parts.Add("Check 'status' to verify index freshness before trusting results."); - - if (On("languages")) - parts.Add("Use 'languages' to discover all supported languages, file extensions, and which languages support call-graph queries."); - - if (On("search")) - parts.Add("Use 'search' with 'exactSubstring: true' for case-sensitive substring matching when FTS5 returns too many results."); - - var exactNameTools = new List(); - foreach (var name in new[] { "symbols", "definition", "references", "callers", "callees", "analyze_symbol" }) - if (On(name)) exactNameTools.Add(name); - if (exactNameTools.Count > 0) - parts.Add($"Use 'exactName: true' on {string.Join("/", exactNameTools)} for exact symbol-name equality."); - - if (All("status", "backfill_fold")) - parts.Add("If 'status' reports fold_ready=false and Unicode exact-name matching matters, use 'backfill_fold' to upgrade folded keys without reparsing files."); - - if (On("files")) - parts.Add("Use 'files' with 'since' to find recently modified files without scanning all results."); - - if (On("batch_query")) - parts.Add("Use 'batch_query' to execute multiple read-only queries in a single call (max 10), dramatically reducing round-trips."); - - if (On("deps")) - parts.Add("Use 'deps' to see file-level dependency edges — which files reference symbols from which other files."); - - if (On("unused_symbols")) - parts.Add("Use 'unused_symbols' to find dead code — symbols defined but never referenced (only meaningful for graph-supported languages)."); - - if (On("symbol_hotspots")) - parts.Add("Use 'symbol_hotspots' to find the most-referenced symbols — central, high-impact code that changes may affect widely."); - - if (On("impact_analysis")) - parts.Add("Use 'impact_analysis' to compute transitive callers of a symbol. Pass maxHops=0 when you only want symbol resolution without traversing callers. Caller rows are edge-kind aware: the same caller can appear once for 'call' and once for 'subscribe'. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, it may instead return heuristic file-level dependency hints; always inspect 'impact_mode', 'heuristic', and 'file_impacts'."); - - if (On("suggest_improvement")) - parts.Add("Use 'suggest_improvement' to report gaps or errors you notice (e.g. missing language support, poor ranking, crashes) — never include source code, only describe the issue in natural language."); + parts.Add("For a known path, expand cdidx://file-path/{path} from resources/templates/list and call resources/read. 既知の path は resources/templates/list の template を展開して resources/read を呼んでください。"); return string.Join(" ", parts); } diff --git a/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs b/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs index b76313f19..ac7dce563 100644 --- a/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs +++ b/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs @@ -26,7 +26,7 @@ public void ToolsList_OutputSchemasValidateActualSuccessEmptyPartialAndTypedErro "public class App { public void Run() { } }"); var listResponse = _server.HandleMessage(JsonNode.Parse( - """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!)!; + """{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!)!; var toolDefinitions = listResponse["result"]!["tools"]!.AsArray(); var schemas = toolDefinitions .ToDictionary( diff --git a/tests/CodeIndex.Tests/McpServerProtocolTests.cs b/tests/CodeIndex.Tests/McpServerProtocolTests.cs index 586f9f443..5f0672be9 100644 --- a/tests/CodeIndex.Tests/McpServerProtocolTests.cs +++ b/tests/CodeIndex.Tests/McpServerProtocolTests.cs @@ -1217,39 +1217,28 @@ public void Initialize_ReturnsInstructions() var instructions = response["result"]!["instructions"]?.GetValue(); Assert.NotNull(instructions); - Assert.Contains("map", instructions!); - Assert.Contains("analyze_symbol", instructions); - Assert.Contains("search", instructions); - Assert.Contains("CodeIndex MCP tools", instructions); + Assert.True(Encoding.UTF8.GetByteCount(instructions!) <= McpServer.DefaultInitializeInstructionsByteBudget); + Assert.Contains("'map'", instructions); + Assert.Contains("'search'", instructions); + Assert.Contains("MCP tools", instructions); Assert.Contains("grep/find/cat", instructions); + Assert.Contains("tools/list", instructions); + Assert.Contains("format=full", instructions); + Assert.Contains("authoritative invocation schemas", instructions); + Assert.Contains("nextCursor unchanged", instructions); + Assert.Contains("prompts/list", instructions); + Assert.Contains("prompts/get", instructions); + Assert.Contains("investigate_before_edit", instructions); Assert.Contains("resources/templates/list", instructions); Assert.Contains("cdidx://file-path/{path}", instructions); - Assert.Contains("resources/list", instructions); - Assert.Contains("optional path, lang, includeGenerated, and maxBytes", instructions); - Assert.Contains("result.nextCursor", instructions); - Assert.Contains("unchanged filters", instructions); - Assert.Contains("read_resource", instructions); + Assert.Contains("'read_resource'", instructions); Assert.Contains("resources/read", instructions); - Assert.Contains("startLine/endLine", instructions); - Assert.Contains("maxBytes", instructions); - Assert.Contains("structuredContent._meta.nextCursor", instructions); - Assert.Contains("result._meta.nextCursor", instructions); Assert.Contains("whole-file reads", instructions); - Assert.Contains("definition", instructions); - Assert.Contains("references", instructions); - Assert.Contains("callers/callees", instructions); - Assert.Contains("excerpt", instructions); - // Verify index-first bootstrap guidance / インデックス未作成時の案内を検証 - Assert.Contains("index", instructions); - Assert.Contains("backfill_fold", instructions); - Assert.Contains("impact_mode", instructions); - Assert.Contains("file_impacts", instructions); - Assert.Contains("heuristic file-level dependency hints", instructions); - // Verify language list comes from ReferenceExtractor / 言語リストがReferenceExtractorから来ることを検証 - foreach (var lang in ReferenceExtractor.GetSupportedLanguages()) - { - Assert.Contains(lang, instructions); - } + Assert.Contains("'definition'", instructions); + Assert.Contains("'references'", instructions); + Assert.Contains("'callers'", instructions); + Assert.Contains("'excerpt'", instructions); + Assert.Contains("'index'", instructions); } [Fact] diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index e7460af3d..fcf0e03a2 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -24,7 +24,7 @@ public partial class McpServerTests [Fact] public void ToolsList_OutlinePublishesPaginationProjectionAndByteControls_Issue4897() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4897,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4897,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var outline = response["result"]!["tools"]!.AsArray() @@ -48,7 +48,7 @@ public void ToolsList_OutlinePublishesPaginationProjectionAndByteControls_Issue4 [Fact] public void ToolsList_IndexPathSchemaReflectsProjectPathContract_Issue3186() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -99,7 +99,7 @@ public void ToolsList_MemberReadCompatibilityOption_IsScopedToTraversalTools_Iss [Fact] public void ToolsList_EachToolPublishesSchemaAndExampleContract() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -130,17 +130,24 @@ public void ToolsList_EachToolPublishesSchemaAndExampleContract() } [Fact] - public void ToolsList_CompactCatalogIsLightweightAndPointsToFullDefinitions_Issue4724() + public void ToolsList_DefaultCatalogIsAgentSafeAndPointsToFullDefinitions_Issues4724_5059() { - var fullRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; - var compactRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"format":"compact"}}""")!; + var fullRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; + var compactRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}""")!; var fullResponse = _server.HandleMessage(fullRequest)!; var compactResponse = _server.HandleMessage(compactRequest)!; var compactResult = compactResponse["result"]!; var compactTools = compactResult["tools"]!.AsArray(); + var fullTools = fullResponse["result"]!["tools"]!.AsArray(); Assert.Equal(McpToolFilter.KnownToolNames.Count, compactTools.Count); + Assert.Equal( + fullTools.Select(tool => tool!["name"]!.GetValue()), + compactTools.Select(tool => tool!["name"]!.GetValue())); + Assert.True( + Encoding.UTF8.GetByteCount(compactResponse.ToJsonString()) + <= McpServer.DefaultToolsListResponseByteBudget); Assert.True( Encoding.UTF8.GetByteCount(compactResponse.ToJsonString()) < Encoding.UTF8.GetByteCount(fullResponse.ToJsonString()) / 3); @@ -149,20 +156,46 @@ public void ToolsList_CompactCatalogIsLightweightAndPointsToFullDefinitions_Issu Assert.False(string.IsNullOrWhiteSpace(tool!["name"]!.GetValue())); Assert.False(string.IsNullOrWhiteSpace(tool["description"]!.GetValue())); Assert.Equal("object", tool["inputSchema"]!["type"]!.GetValue()); - Assert.Single(tool["inputSchema"]!.AsObject()); + Assert.True(tool["inputSchema"]!.AsObject().Count > 1); Assert.Null(tool["outputSchema"]); Assert.Null(tool["examples"]); Assert.NotNull(tool["annotations"]); Assert.NotNull(tool["x-stability"]); } + var compactSearch = compactTools.Single(tool => tool!["name"]!.GetValue() == "search")!; + Assert.Null(compactSearch["inputSchema"]!["properties"]!["query"]!["description"]); + Assert.Equal(1, compactSearch["inputSchema"]!["properties"]!["query"]!["minLength"]!.GetValue()); + Assert.Contains( + compactSearch["inputSchema"]!["anyOf"]!.AsArray(), + mode => mode!["required"]!.AsArray().Any(required => required!.GetValue() == "query")); + var meta = compactResult["_meta"]!; Assert.Equal("compact", meta["format"]!.GetValue()); Assert.False(meta["definitions_complete"]!.GetValue()); - Assert.False(meta["discovery_contract"]!["input_schemas_are_authoritative"]!.GetValue()); + Assert.True(meta["discovery_contract"]!["input_schemas_are_authoritative"]!.GetValue()); + Assert.False(meta["discovery_contract"]!["output_schemas_are_included"]!.GetValue()); + Assert.False(meta["discovery_contract"]!["examples_are_included"]!.GetValue()); Assert.True(meta["discovery_contract"]!["full_definitions_available_on_demand"]!.GetValue()); Assert.Equal("tools/list", meta["full_definition_request"]!["method"]!.GetValue()); Assert.Equal("full", meta["full_definition_request"]!["params"]!["format"]!.GetValue()); + + var sections = meta["size_telemetry"]!["sections"]!; + Assert.True(sections["input_schemas"]!["utf8_bytes"]!.GetValue() > 0); + Assert.Equal(0, sections["output_schemas"]!["utf8_bytes"]!.GetValue()); + Assert.Equal(0, sections["examples"]!["utf8_bytes"]!.GetValue()); + Assert.False(meta["size_telemetry"]!["contains_tool_arguments"]!.GetValue()); + + var fullSections = fullResponse["result"]!["_meta"]!["size_telemetry"]!["sections"]!; + var measuredFullDescriptionBytes = fullTools.Sum(tool => + Encoding.UTF8.GetByteCount(tool!["description"]!.ToJsonString())); + Assert.Equal(measuredFullDescriptionBytes, fullSections["descriptions"]!["utf8_bytes"]!.GetValue()); + Assert.Contains( + fullTools, + tool => tool!["description"]!.GetValue().Any(character => character > 127)); + Assert.True(fullSections["output_schemas"]!["utf8_bytes"]!.GetValue() > 0); + Assert.True(fullSections["examples"]!["utf8_bytes"]!.GetValue() > 0); + Assert.True(fullSections["capability_metadata"]!["utf8_bytes"]!.GetValue() > 0); } [Fact] @@ -207,7 +240,7 @@ public void ToolsList_ContinuationCursorPreservesCompactNameFilter_Issue4724() var tool = Assert.Single(secondResponse["result"]!["tools"]!.AsArray())!; Assert.Equal("status", tool["name"]!.GetValue()); Assert.Null(tool["examples"]); - Assert.Single(tool["inputSchema"]!.AsObject()); + Assert.True(tool["inputSchema"]!.AsObject().Count > 1); Assert.Equal("compact", secondResponse["result"]!["_meta"]!["format"]!.GetValue()); Assert.True(secondResponse["result"]!["_meta"]!["response_controls"]!["names_filtered"]!.GetValue()); @@ -227,10 +260,10 @@ public void ToolsList_ContinuationCursorPreservesCompactNameFilter_Issue4724() } [Fact] - public void ToolsList_ExplicitFullMatchesDefaultResponse_Issue4724() + public void ToolsList_ExplicitCompactMatchesDefaultResponse_Issues4724_5059() { var defaultRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; - var explicitRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"format":"full"}}""")!; + var explicitRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"format":"compact"}}""")!; var defaultResponse = _server.HandleMessage(defaultRequest)!; var explicitResponse = _server.HandleMessage(explicitRequest)!; @@ -315,7 +348,7 @@ public void ToolsList_SuggestionCategorySchemaMatchesValidCategories_Issue4423() [Fact] public void ToolsList_MetaAdvertisesFirstTimeAiDiscoveryCatalog() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var meta = response["result"]!["_meta"]!; @@ -363,7 +396,7 @@ public void ToolsList_LimitAndCursorPageDiscoveryCatalog_Issue4304() var firstResult = firstResponse["result"]!; var firstTools = firstResult["tools"]!.AsArray(); Assert.Equal(3, firstTools.Count); - Assert.Equal("3", firstResult["nextCursor"]!.GetValue()); + Assert.StartsWith("v1.", firstResult["nextCursor"]!.GetValue(), StringComparison.Ordinal); Assert.Equal(new[] { "search", "definition", "references" }, firstTools.Select(tool => tool!["name"]!.GetValue()).ToArray()); var controls = firstResult["_meta"]!["response_controls"]!; @@ -396,10 +429,30 @@ public void ToolsList_LimitAndCursorPageDiscoveryCatalog_Issue4304() Assert.Equal(3, secondTools.Count); Assert.Equal(new[] { "callers", "callees", "symbols" }, secondTools.Select(tool => tool!["name"]!.GetValue()).ToArray()); - Assert.Equal("6", secondResponse["result"]!["nextCursor"]!.GetValue()); + Assert.StartsWith("v1.", secondResponse["result"]!["nextCursor"]!.GetValue(), StringComparison.Ordinal); Assert.Equal(3, secondResponse["result"]!["_meta"]!["response_controls"]!["tools_offset"]!.GetValue()); } + [Fact] + public void ToolsList_LegacyNumericCursorContinuesFullCatalog_Issue5059() + { + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":5059,"method":"tools/list","params":{"cursor":"3","limit":3}}""")!; + + var response = _server.HandleMessage(request)!; + var result = response["result"]!; + var tools = result["tools"]!.AsArray(); + + Assert.Equal(new[] { "callers", "callees", "symbols" }, tools.Select(tool => tool!["name"]!.GetValue()).ToArray()); + Assert.All(tools, tool => + { + Assert.NotNull(tool!["outputSchema"]); + Assert.NotNull(tool["examples"]); + }); + Assert.Equal("full", result["_meta"]!["size_telemetry"]!["format"]!.GetValue()); + Assert.Equal("6", result["nextCursor"]!.GetValue()); + } + [Fact] public void ToolsList_InvalidPaginationParamsReturnInvalidParams_Issue4304() { @@ -445,7 +498,7 @@ public void ToolsList_InvalidPaginationParamsReturnInvalidParams_Issue4304() [Fact] public void ToolsList_EveryDescriptionIncludesLanguageSupportClause() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -515,7 +568,7 @@ public void ToolsList_SearchIncludesPathFilterParams() [Fact] public void ToolsList_SearchDescriptionStaysCompact() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -569,7 +622,7 @@ public void ToolsList_CommonSchemasAdvertiseClientSideConstraints() [Fact] public void ToolsList_NavigationDescriptionsIncludeConcreteExamples() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -595,7 +648,7 @@ public void ToolsList_NavigationDescriptionsIncludeConcreteExamples() [Fact] public void ToolsList_NavigationDescriptionsExplainWhenAndNextStep() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -614,7 +667,7 @@ public void ToolsList_NavigationDescriptionsExplainWhenAndNextStep() [Fact] public void ToolsList_CommonSchemaDescriptionsGuideDisambiguation() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -657,7 +710,7 @@ public void ToolsList_CallersCalleesKindDescription_ExcludesMetadataKinds() // not advertise them as valid filter values. // callers/callees の handler は metadata kinds (`attribute` / `annotation`) を拒否するため、 // schema の `kind` description も有効値として列挙しないこと。 - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -677,7 +730,7 @@ public void ToolsList_CallersCalleesKindDescription_ExcludesMetadataKinds() public void ToolsList_CallersCalleesAnalyzeSymbolDescriptions_PinSnakeCaseMixedKindFields() { // MCP structured JSON follows the same snake_case convention as CLI JSON. - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -697,7 +750,7 @@ public void ToolsList_CallersCalleesAnalyzeSymbolDescriptions_PinSnakeCaseMixedK [Fact] public void ToolsList_ImpactAnalysisDescribesHeuristicFallback() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -715,7 +768,7 @@ public void ToolsList_ImpactAnalysisDescribesHeuristicFallback() [Fact] public void ToolsList_DepsExposesGeneratedCodeFilter_Issue3544() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); @@ -830,7 +883,7 @@ public void ToolsList_FilteredMetaDoesNotAdvertiseDeniedTools() var deny = McpToolFilter.Parse(null, "index,backfill_fold,suggest_improvement"); using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion(), false, deny); - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = server.HandleMessage(request)!; var groups = response["result"]!["_meta"]!["capability_groups"]!; @@ -850,7 +903,7 @@ public void ToolsList_FilteredMetaDoesNotAdvertiseDeniedTools() [Fact] public void ToolsList_ImpactAnalysisMaxHopsSchemaDocumentsCap() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full"}}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index c6fafb5b1..a362d9d4a 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -120,7 +120,7 @@ public void ToolsList_HighVolumeDiscoveryCursorsHaveSharedArgumentContract_Issue properties["cursor"]["maxLength"]!.GetValue()); } - var validateDescription = GetAdvertisedTools()["validate"]["description"]!.GetValue(); + var validateDescription = GetAdvertisedTools(full: true)["validate"]["description"]!.GetValue(); Assert.Contains( "authoritative only while `file_issues_data_current` is true", validateDescription, @@ -246,7 +246,7 @@ public void ToolsList_CommonListAndAliasMetadata_AreAdvertised_Issue3538() [Fact] public void ToolsList_CatalogAndSchemaMetadataContractsStaySynchronized_Issue4177() { - var result = GetToolsListResult(); + var result = GetToolsListResult(full: true); var tools = GetAdvertisedTools(result); var advertisedNames = tools.Keys.ToHashSet(StringComparer.Ordinal); var failures = new List(); @@ -325,7 +325,9 @@ public void ToolsList_CatalogAndSchemaMetadataContractsStaySynchronized_Issue417 [Fact] public void ToolsList_FilteredCatalogMetaReferencesOnlyAdvertisedTools_Issue4177() { - var result = GetToolsListResult(McpToolFilter.Parse(null, "index,backfill_fold,suggest_improvement")); + var result = GetToolsListResult( + McpToolFilter.Parse(null, "index,backfill_fold,suggest_improvement"), + full: true); var tools = GetAdvertisedTools(result); var advertisedNames = tools.Keys.ToHashSet(StringComparer.Ordinal); var failures = new List(); @@ -421,7 +423,7 @@ private static Dictionary> GetAdvertisedT return result; } - private static JsonObject GetToolsListResult(McpToolFilter? filter = null) + private static JsonObject GetToolsListResult(McpToolFilter? filter = null, bool full = false) { using var server = new McpServer("unused.db", "test", dbPathExplicit: false, filter ?? McpToolFilter.AllowAll()); var request = new JsonObject @@ -430,6 +432,8 @@ private static JsonObject GetToolsListResult(McpToolFilter? filter = null) ["id"] = 1, ["method"] = "tools/list", }; + if (full) + request["params"] = new JsonObject { ["format"] = "full" }; var response = server.HandleMessage(request) ?? throw new InvalidOperationException("tools/list returned no response."); @@ -437,8 +441,8 @@ private static JsonObject GetToolsListResult(McpToolFilter? filter = null) ?? throw new InvalidOperationException("tools/list response did not contain a result object."); } - private static Dictionary GetAdvertisedTools() - => GetAdvertisedTools(GetToolsListResult()); + private static Dictionary GetAdvertisedTools(bool full = false) + => GetAdvertisedTools(GetToolsListResult(full: full)); private static Dictionary GetAdvertisedTools(JsonObject resultObject) { From 18e500e79fbf3c9426c2ce27e6a8d78be3e7f5cb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 10 Aug 2026 23:54:25 +0900 Subject: [PATCH 2/3] Preserve safe filtered MCP discovery guidance (#5059) --- DEVELOPER_GUIDE.md | 8 ++++---- changelog.d/unreleased/5059.fixed.md | 4 ++-- src/CodeIndex/Mcp/McpToolDefinitions.cs | 8 ++++++-- src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs | 5 ++++- tests/CodeIndex.Tests/McpServerProtocolTests.cs | 1 + tests/CodeIndex.Tests/McpServerToolsListTests.cs | 2 ++ 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 794d1fe56..4d0491d20 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2308,7 +2308,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **MCP bounded queues and concurrency gates** — HTTP request queue slots are acquired before `TryWrite`; once full, requests are rejected with HTTP 429, `Retry-After: 1`, `X-Cdidx-Mcp-Rejection: request_queue_limit`, and `http_request_queue_rejection_count` rather than blocking an HTTP handler. The request-log queue is best-effort and increments `http_request_log_queue_full_drop_count` / `http_request_log_dropped_count` on saturation. POST handlers and long-lived event streams use independent admission semaphores, report `concurrent_handler_limit` and `event_stream_limit` separately, and expose their effective capacities plus `http_separate_event_stream_handlers` through HTTP health. Limit environment variables use defaults only when absent; a present malformed or out-of-range value fails before listener startup. Transport-owned queue and handler gates are disposed only after bounded shutdown observes every acquired slot returned, because a late handler may still finish after listener teardown. Frame-loop gates are disposed only after EOF drain observes all request tasks, because bounded drain can intentionally leave late tasks running. - **MCP pagination offset cap** — `references`, `callers`, and `callees` clamp `offset` to 10,000 before executing SQL queries. `tools/list` advertises the maximum in each offset schema, and MCP `status` mirrors it under `mcp.limits.max_pagination_offset`. - **MCP language-catalog pagination** — MCP `languages` builds rows through the same canonical `LanguageCatalog` used by the CLI, then applies exact normalized language/extension/alias filters and ordinal language sorting. Pages default to 20 rows and use opaque response-v2 cursors bound to every filter, `limit`, `maxBytes`, the fixed sort contract, and a fingerprint of the emitted catalog generation (including indexed-language membership for `indexedOnly`). Query changes return `cursor_query_mismatch`; catalog changes return retry-safe `cursor_stale`. `maxBytes` accepts 4,096 through 1,000,000 bytes and measures the complete UTF-8 JSON-RPC envelope, shrinking the row page before emission while keeping filtered totals separate from catalog and capability counts. -- **MCP bounded discovery and status projection** — A no-argument `tools/list` returns every enabled tool in deterministic order as the agent-safe `compact` catalog, whose complete JSON-RPC envelope is kept within a 64-KiB UTF-8 budget. Compact entries retain authoritative invocation schemas (types, required fields, constraints, and defaults) while removing documentation-only schema keywords; descriptions are shortened, and optional output schemas and examples are omitted. Request `format: "full"` with exact `names` only when complete descriptions, output schemas, examples, or workflow metadata are needed. Standard opaque cursors preserve compact and name-filtered controls; legacy numeric cursors continue the unfiltered full catalog. `_meta.size_telemetry` reports serialized UTF-8 bytes and approximate tokens for tools, descriptions, input schemas, output schemas, examples, annotations/stability, and catalog metadata without recording arguments. `names` accepts at most 24 exact enabled names of 128 characters each. MCP `status.fields` projects exact top-level fields after `format` and optional diagnostic attachments are built, while `api_version` remains part of every structured result. Projection inputs are capped at 32 names, 128 characters each, and 2,048 characters total; unknown names and nested paths fail as `invalid_argument`. New discovery modes must preserve tool reachability, invocation-schema authority, deterministic standard pagination, the default byte budget, and explicit-full compatibility. +- **MCP bounded discovery and status projection** — A no-argument `tools/list` returns every enabled tool in deterministic order as the agent-safe `compact` catalog, whose complete JSON-RPC envelope is kept within a 64-KiB UTF-8 budget. Compact entries retain authoritative invocation schemas (types, required fields, constraints, and defaults) while removing documentation-only schema keywords; descriptions are shortened without removing safety-critical guidance such as the prohibition on including source code in suggestions, and optional output schemas and examples are omitted. Request `format: "full"` with exact `names` only when complete descriptions, output schemas, examples, or workflow metadata are needed. Standard opaque cursors preserve compact and name-filtered controls; legacy numeric cursors continue the unfiltered full catalog. `_meta.size_telemetry` reports serialized UTF-8 bytes and approximate tokens for tools, descriptions, input schemas, output schemas, examples, annotations/stability, and catalog metadata without recording arguments. `names` accepts at most 24 exact enabled names of 128 characters each. MCP `status.fields` projects exact top-level fields after `format` and optional diagnostic attachments are built, while `api_version` remains part of every structured result. Projection inputs are capped at 32 names, 128 characters each, and 2,048 characters total; unknown names and nested paths fail as `invalid_argument`. New discovery modes must preserve tool reachability, invocation-schema authority, deterministic standard pagination, the default byte budget, and explicit-full compatibility. - **MCP resource-list cursor stability** — `resources/list` emits a fixed-size opaque keyset cursor that binds the last consumed file id to a persisted indexed-file generation and the canonical discovery filters. The reader resolves that id back to the existing source/test/docs bucket plus path ordering inside the same SQLite snapshot. Any file insertion, deletion, or update changes the generation; a later page then returns `-32011` / `index_stale` with `restart_required: true`. Changing `path`, `lang`, or `includeGenerated` between pages returns `-32602` / `resources_list_filters_changed` with the same restart requirement. In either case, the client must omit `params.cursor` to restart instead of continuing across mixed snapshots or filters. Writable legacy databases install the generation row and triggers through the normal read migration before a cursor is issued. A mutable read-only legacy database that cannot prove generation tracking returns `resources_list_generation_unavailable` with `migration_required: true`; a canonical, unambiguous `immutable=1` legacy URI (optionally paired with `mode=ro`) may safely use connection-local generation zero because it cannot change between pages. Encoded, case-variant, whitespace-padded, duplicated, conflicting, or extra query parameters are not trusted as that immutable guarantee. The legacy decimal zero remains a first-page upgrade input, but nonzero decimal offsets cannot prove their source generation and therefore return the same restart-required error; decimal cursors are never emitted. Version-1 opaque cursors remain valid only with the default unfiltered view. - **MCP file resource discovery** — `resources/templates/list` advertises `cdidx://file-path/{path}` so a client that already knows an exact repository-relative path can construct a `resources/read` URI without paging the repository inventory. Simple URI-template expansion percent-encodes separators and reserved filename characters such as `?` and `#`; the template-only resolver decodes the value once, rejects absolute paths, traversal, backslashes, empty segments, queries, and fragments, then returns the canonical `cdidx://file/` identity. Canonical resource URIs continue to reject encoded separators. `resources/list` accepts `path` as one string or at most 100 strings of at most 1024 characters and 128 wildcard operators each, using the same anchored directory/glob semantics as file queries, plus an exact normalized `lang` filter and `includeGenerated` (default `false`). Generated files also require `includeGenerated: true` for direct reads. - **MCP resource-list response budget** — `resources/list.params.maxBytes` accepts 4,096 through 1,000,000 bytes and defaults to 1,000,000, matching the default HTTP response-body cap. The effective budget is the minimum of that request, the server-wide MCP envelope cap, and the active HTTP transport response-body cap (when applicable), so a lower configured HTTP cap shapes a valid page instead of rejecting it with HTTP 500. The server measures the complete JSON-RPC envelope, keeps 200 as the candidate ceiling, and stops before the next resource would cross the effective byte budget. For HTTP JSON-RPC batches, the active transport budget covers the complete response array, including brackets and commas, and is divided fairly among response-bearing items; notifications consume no response slot. Each `resources/list` item honors its current share and preserves its request ID in a canonical budget error if even a bounded page cannot fit. State-changing and other non-resource outcomes are never relabeled as retry-safe; an aggregate overflow after execution reports an unknown completion state and forbids automatic retry. `_meta.response_controls` reports the requested/effective budgets, consumed and returned counts, `omitted_resource_count`, bounded reason counts (`resource_uri_too_long` / `resource_exceeds_max_bytes`), `byte_budget_reached`, and `continuation_reason` (`byte_budget`, `item_limit`, or `completed`). A continuation cursor anchors the last consumed database row; a valid resource that did not fit remains unconsumed for the next page, while a resource that cannot fit even on an empty page is consumed and counted so pagination cannot livelock. @@ -2318,7 +2318,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **MCP stability markers and naming** — Every tool advertises `x-stability` (`stable`, `experimental`, or `deprecated`). MCP structured payload keys use snake_case, matching the CLI JSON contract; do not add camelCase aliases for new fields. - **MCP language-support clauses** — Every advertised MCP tool description ends with a `Language support:` clause generated through `McpServer.CreateToolDefinition`. Graph tools enumerate `ReferenceExtractor.GetSupportedLanguages()`, symbol tools enumerate `SymbolExtractor.GetSupportedLanguages()`, and file/content tools point at the detected-language catalog used by `cdidx languages`, so `tools/list` stays aligned with the runtime registries instead of carrying hand-maintained prose. - **MCP tool annotations** — All tools emit `annotations` with `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` per the MCP spec, so AI clients can auto-approve safe read-only queries. -- **MCP server instructions** — The `initialize` response keeps first-contact guidance within a 2-KiB UTF-8 budget. It explains bounded `tools/list` discovery and on-demand full definitions, advertises only enabled tools, and directs extended workflows to `prompts/list` and `prompts/get` instead of duplicating the full catalog. +- **MCP server instructions** — The `initialize` response keeps first-contact guidance within a 2-KiB UTF-8 budget. It explains bounded `tools/list` discovery and on-demand full definitions, advertises only enabled tools, and directs extended workflows to `prompts/list` and `prompts/get` only when the workflow's required tools are enabled instead of duplicating the full catalog. - **Per-deployment MCP tool enablement** — `cdidx mcp` honors two environment variables so operators can narrow the exposed tool surface without a code change (#1561). `CDIDX_MCP_TOOLS_ALLOW=` is a strict allowlist; if set, only those tools appear in `tools/list` and are dispatched by `tools/call`. `CDIDX_MCP_TOOLS_DENY=` removes individual tools from the default-all-enabled set. Allow wins over deny when both are set. The single source of truth for known tool names is `McpToolFilter.KnownToolNames`, which is checked against by both the `tools/list` filter and the `tools/call` gate (and the per-slot guard inside `batch_query`). `BuildInstructions` is also gate-aware: scoped deployments never recommend a disabled tool in the `initialize` instructions, so the guidance stays in sync with the advertised surface. Top-level `tools/call` on a disabled known tool returns `-32601 Tool not enabled: `; `batch_query` envelopes succeed but each disabled-tool slot carries a `code: -32601` field alongside the `error` string so clients can branch on the code without parsing prose. Truly unknown names still fall through to the existing `-32602 Unknown tool` path so operator-disabled tools remain distinguishable from typos. Tool names compare case-insensitively, unknown env-var entries are filtered against the known set (an allowlist of only-unknown names intentionally exposes nothing rather than silently disabling the gate), and the default — no env vars set — keeps every tool enabled so existing deployments are unaffected. - **Backward-compatible symbol schema** — Opening an older DB with a newer binary auto-adds missing symbol columns when possible, including hotspot-family metadata such as `container_qualified_name` and `family_key`. If a read path cannot migrate the DB in place, symbol queries fall back to the legacy column set instead of crashing. - **Bounded hotspot aggregation** — `DbWriter` maintains `hotspot_reference_counts` as compact per-file logical-reference totals. Limited hotspot readers use its rank index to select a fixed bounded candidate frontier before the non-SQL, SQL exact, SQL leaf, ambiguity, and target-family joins; they do not rematerialize the complete `symbol_references` graph on every query. Logical-site identity excludes raw aliases, mutations refresh cross-file context dependents and demote reference-identity trust transactionally, and reader/writer aggregate SQL is cancellation-interruptible. Bulk-eligible refreshes with at least 64 dirty file IDs use primary-key seeks plus a bounded total-row probe to drop the four query indexes only when existing dirty aggregate rows cover at least three-fifths of the table; an empty pre-refresh aggregate also qualifies for fresh and rebuild runs, while skewed or small updates retain every index. Qualifying runs rebuild the indexes once after the set-based insert in the same transaction. Writable legacy databases create and backfill the table transactionally, while immutable legacy readers retain the raw-reference compatibility path. @@ -6005,7 +6005,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **MCP の有界 queue / concurrency gate** — HTTP request queue は `TryWrite` 前に slot を取り、満杯時は handler を block せず HTTP 429、`Retry-After: 1`、`X-Cdidx-Mcp-Rejection: request_queue_limit` で拒否する。POST handler と長寿命 event stream は独立した admission semaphore を使い、HTTP health は両方の有効 capacity と `http_separate_event_stream_handlers` を返す。limit 環境変数は未設定の場合だけ既定値を使い、設定済みの malformed 値または範囲外値は listener 起動前に失敗する。transport 所有 gate は bounded shutdown で全取得 slot の返却を確認できた場合だけ dispose する。 - **MCP pagination offset 上限** — `references`、`callers`、`callees` は SQL query 実行前に `offset` を 10,000 へクランプする。`tools/list` は各 offset schema に最大値を広告し、MCP `status` も `mcp.limits.max_pagination_offset` に同じ値を返す。 - **MCP 言語 catalog の pagination** — MCP `languages` は CLI と共通の canonical `LanguageCatalog` から row を構築し、正規化した言語・拡張子・alias の完全一致 filter と言語名の ordinal sort を適用する。page は既定 20 行で、opaque な response-v2 cursor を全 filter、`limit`、`maxBytes`、固定 sort contract、出力 catalog generation の fingerprint(`indexedOnly` では indexed-language membership も含む)へ拘束する。query 変更は `cursor_query_mismatch`、catalog 変更は retry-safe な `cursor_stale` を返す。`maxBytes` は 4,096〜1,000,000 bytes を受け付け、UTF-8 JSON-RPC envelope 全体を計測して送信前に row page を縮小し、filtered total を catalog 件数および capability 件数から分離したまま返す。 -- **MCP の bounded discovery と status projection** — 引数なしの `tools/list` は、有効な全 tool を決定的順序で agent-safe な `compact` catalog として返し、JSON-RPC envelope 全体を UTF-8 で 64 KiB 以内に保つ。compact entry は呼び出しに必要な authoritative schema(型、必須 field、制約、既定値)を維持し、schema の説明専用 keyword だけを除去する。description は短縮し、任意の output schema と example は省略する。完全な description、output schema、example、workflow metadata が必要な場合だけ、正確な `names` と `format: "full"` を指定する。標準の opaque cursor は compact / name-filtered control を維持し、legacy の数値 cursor は filter なし full catalog を継続する。`_meta.size_telemetry` は引数を記録せず、tools、description、input schema、output schema、example、annotation/stability、catalog metadata ごとの serialized UTF-8 bytes と概算 token 数を返す。`names` は 128 文字以内の有効 tool 完全名を最大 24 件受け付ける。MCP `status.fields` は `format` と任意 diagnostic attachment の構築後に exact な top-level field を project し、`api_version` はすべての structured result に残す。projection 入力は最大 32 件、各 128 文字、合計 2,048 文字で、未知名と nested path は `invalid_argument` とする。新しい discovery mode は tool 到達性、呼び出し schema の authority、決定的な標準 pagination、既定 byte budget、明示的 full の互換性を維持すること。 +- **MCP の bounded discovery と status projection** — 引数なしの `tools/list` は、有効な全 tool を決定的順序で agent-safe な `compact` catalog として返し、JSON-RPC envelope 全体を UTF-8 で 64 KiB 以内に保つ。compact entry は呼び出しに必要な authoritative schema(型、必須 field、制約、既定値)を維持し、schema の説明専用 keyword だけを除去する。description は、提案に source code を含めないという安全上重要な案内を残して短縮し、任意の output schema と example は省略する。完全な description、output schema、example、workflow metadata が必要な場合だけ、正確な `names` と `format: "full"` を指定する。標準の opaque cursor は compact / name-filtered control を維持し、legacy の数値 cursor は filter なし full catalog を継続する。`_meta.size_telemetry` は引数を記録せず、tools、description、input schema、output schema、example、annotation/stability、catalog metadata ごとの serialized UTF-8 bytes と概算 token 数を返す。`names` は 128 文字以内の有効 tool 完全名を最大 24 件受け付ける。MCP `status.fields` は `format` と任意 diagnostic attachment の構築後に exact な top-level field を project し、`api_version` はすべての structured result に残す。projection 入力は最大 32 件、各 128 文字、合計 2,048 文字で、未知名と nested path は `invalid_argument` とする。新しい discovery mode は tool 到達性、呼び出し schema の authority、決定的な標準 pagination、既定 byte budget、明示的 full の互換性を維持すること。 - **MCP ファイル resource discovery** — `resources/templates/list` は `cdidx://file-path/{path}` を公開し、正確なリポジトリ相対 path が既知の client は全 inventory をページングせず `resources/read` URI を構築できる。simple URI-template expansion は separator と `?` / `#` などの予約 filename 文字を percent encode する。template 専用 resolver は値を一度だけ decode し、absolute path、traversal、backslash、空 segment、query、fragment を拒否して canonical な `cdidx://file/` identity を返す。canonical resource URI は encoded separator を引き続き拒否する。`resources/list` は `path` に 1 文字列または各 1024 文字・wildcard operator 128 個以内かつ最大 100 件の文字列を受け付け、file query と同じ anchored directory / glob semantics を使うほか、正規化した完全一致の `lang` と `includeGenerated`(既定 `false`)も受け付ける。cursor は generation と canonical filter の両方に結び付き、ページ間の filter 変更は `-32602` / `resources_list_filters_changed` と `restart_required: true` を返す。generated file は discovery と direct read のどちらでも `includeGenerated: true` が必要である。 - **MCP resource list カーソルの安定性** — `resources/list` は、最後に消費した file id と永続化されたインデックス済みファイル世代を結び付ける固定長の不透明 keyset cursor を返す。reader は同じ SQLite snapshot 内でその id を既存の source/test/docs bucket と path の並び順へ解決する。ファイルの追加・削除・更新で世代が変わると、後続ページは `restart_required: true` 付きの `-32011` / `index_stale` を返し、混在 snapshot を続行せず `params.cursor` を省略して再開する必要がある。書き込み可能な legacy DB は cursor 発行前に通常の read migration で世代 row と trigger を導入する。世代追跡を証明できない変更可能な read-only legacy DB は `migration_required: true` 付きの `resources_list_generation_unavailable` を返すが、canonical かつ曖昧性のない `immutable=1` legacy URI(任意で `mode=ro` を併記)はページ間で変化しないため connection-local な世代 0 を安全に利用できる。encoded、case variant、空白付き、重複、競合、または余分な query parameter はこの immutable 保証として信頼しない。旧 decimal の 0 は先頭ページ用の移行入力として残すが、0 以外の decimal offset は発行時世代を検証できないため同じ再開必須 error を返し、decimal cursor は出力に使わない。 - **MCP resource list の response budget** — `resources/list.params.maxBytes` は 4,096〜1,000,000 bytes を受け付け、既定値は HTTP response body の既定上限と同じ 1,000,000。有効 budget はこの要求値、server-wide MCP envelope 上限、および該当時の active HTTP transport response-body 上限の最小値とし、HTTP の設定上限が低い場合も HTTP 500 で拒否せず有効なページへ整形する。server は JSON-RPC envelope 全体を計測し、candidate 上限を 200 件に保ったまま、次の resource で有効 byte budget を超える直前に停止する。HTTP JSON-RPC batch では active transport budget を bracket と comma を含む response 配列全体へ適用し、応答対象 item へ公平に分配する。notification は response slot を消費しない。各 `resources/list` item は現在の割当を守り、有界なページさえ収まらない場合も canonical な budget error に request ID を保持する。state-changing item とその他の non-resource outcome を retry-safe として付け替えることはなく、実行後の aggregate overflow は completion state が unknown で自動再試行不可であることを報告する。`_meta.response_controls` は要求/有効 budget、消費/返却件数、`omitted_resource_count`、有界な理由別件数(`resource_uri_too_long` / `resource_exceeds_max_bytes`)、`byte_budget_reached`、`continuation_reason`(`byte_budget`、`item_limit`、`completed`)を返す。継続 cursor は最後に消費した DB row を anchor とし、収まらなかった有効 resource は次ページ用に未消費のまま残すが、空ページにも収まらない resource は消費して件数へ計上し、pagination の livelock を防ぐ。 @@ -6015,7 +6015,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **MCP stability marker と命名** — すべての tool は `x-stability`(`stable`、`experimental`、`deprecated`)を公開する。MCP の構造化 payload key は CLI JSON 契約に合わせて snake_case を使う。新規 field に camelCase alias を追加しないこと。 - **MCP の言語サポート句** — 公開されるすべての MCP ツール説明は、`McpServer.CreateToolDefinition` で生成される `Language support:` 句で終わる。Graph 系ツールは `ReferenceExtractor.GetSupportedLanguages()`、symbol 系ツールは `SymbolExtractor.GetSupportedLanguages()`、file/content 系ツールは `cdidx languages` と同じ検出言語カタログを参照するため、`tools/list` は手書き説明ではなく実行時レジストリと同期する。 - **MCPツールアノテーション** — 全ツールが MCP 仕様に沿った `annotations`(`readOnlyHint`、`destructiveHint`、`idempotentHint`、`openWorldHint`)を返し、AIクライアントが安全な読み取り専用クエリを自動承認できるようにする。 -- **MCP サーバー instructions** — `initialize` response の初回案内は UTF-8 で 2 KiB 以内に保つ。bounded な `tools/list` discovery と完全定義の on-demand 取得を説明し、有効な tool だけを案内し、完全 catalog の説明を重複させず詳細 workflow を `prompts/list` と `prompts/get` へ誘導する。 +- **MCP サーバー instructions** — `initialize` response の初回案内は UTF-8 で 2 KiB 以内に保つ。bounded な `tools/list` discovery と完全定義の on-demand 取得を説明し、有効な tool だけを案内し、workflow に必要な tool が有効な場合だけ、完全 catalog の説明を重複させず詳細 workflow を `prompts/list` と `prompts/get` へ誘導する。 - **デプロイ単位での MCP ツール有効化** — `cdidx mcp` が 2 つの環境変数を尊重し、コード変更なしに公開ツールを絞れるようにする (#1561)。`CDIDX_MCP_TOOLS_ALLOW=` は厳格な allowlist で、指定された場合はそのツールだけが `tools/list` に現れ `tools/call` で dispatch される。`CDIDX_MCP_TOOLS_DENY=` は既定の全有効集合から個別ツールを除外する。両方指定された場合は allow を優先。既知ツール名の真実の源は `McpToolFilter.KnownToolNames` に集約し、`tools/list` 側の filter、`tools/call` 側のゲート、`batch_query` の slot ガードのいずれもここを参照する。`BuildInstructions` もゲート対応で、scoped デプロイの `initialize` instructions では無効化されたツールを推奨しなくなり、案内と公開面が一致する。トップレベル `tools/call` で無効化された既知ツールを呼ぶと `-32601 Tool not enabled: ` を返し、`batch_query` 自体はエンベロープとして成功するが各無効化スロットに `code: -32601` が `error` 文字列と並んで載るため、クライアントは prose を parsing せず code で分岐できる。サーバーに無い名前は既存の `-32602 Unknown tool` に流すことでオペレータ無効化と typo を区別できる。ツール名比較は大小文字無視、env var 内の未知名は既知集合で filter(typo で未知名のみの allowlist は意図的に何も公開しないため、ゲートが silent に外れない)。env var を一切設定しない既定挙動は全ツール有効なので、既存デプロイへの影響はない。 - **トリガー付きコンテンツ外部参照FTS5** — `chunks`テーブルを参照しコピーを保存しないことでストレージ倍増を回避。データベーストリガーでFTSインデックスを自動同期。 - **extractor regex の backtracking policy** — built-in symbol/reference extractor は repository-controlled な file content に対して unbounded regex match を使わない。backtracking regex は `BoundedRegex.DefaultMatchTimeout` を使い、`RegexOptions.NonBacktracking` は non-backtracking engine と互換な pattern で使ってよい。lookaround-heavy な extractor や balancing-group を使う extractor など、意図的に backtracking-only のまま残す pattern は、共有 timeout audit の対象になる場合だけ許容する。将来の extractor が `System.Text.RegularExpressions.Regex` を直接使う必要がある場合は、明示 timeout を渡し、`BoundedRegex` や `NonBacktracking` が適さない理由を文書化すること。 diff --git a/changelog.d/unreleased/5059.fixed.md b/changelog.d/unreleased/5059.fixed.md index d4cf98fc9..72189ee72 100644 --- a/changelog.d/unreleased/5059.fixed.md +++ b/changelog.d/unreleased/5059.fixed.md @@ -16,8 +16,8 @@ affected: ## English -- **MCP discovery now fits an agent-safe bounded payload (#5059; follow-up to #4336)** — the default 25-tool `tools/list` response now stays within 64 KiB while retaining authoritative invocation schemas, deterministic standard pagination, on-demand full definitions, and section-level UTF-8 size telemetry. Initialize guidance is also concise and points clients to prompts for extended workflows. +- **MCP discovery now fits an agent-safe bounded payload (#5059; follow-up to #4336)** — the default 25-tool `tools/list` response now stays within 64 KiB while retaining authoritative invocation schemas, safety-critical suggestion guidance, deterministic standard pagination, on-demand full definitions, and section-level UTF-8 size telemetry. Initialize guidance is also concise and points clients to compatible prompts for extended workflows. ## 日本語 -- **MCP discovery を agent-safe な上限付き payload に収めました (#5059、#4336 の follow-up)** — 既定の 25-tool `tools/list` response を 64 KiB 以内に保ちながら、authoritative な呼び出し schema、決定的な標準 pagination、完全定義の on-demand 取得、section ごとの UTF-8 size telemetry を維持します。initialize guidance も短縮し、詳細 workflow は prompt へ誘導します。 +- **MCP discovery を agent-safe な上限付き payload に収めました (#5059、#4336 の follow-up)** — 既定の 25-tool `tools/list` response を 64 KiB 以内に保ちながら、authoritative な呼び出し schema、安全上重要な提案 guidance、決定的な標準 pagination、完全定義の on-demand 取得、section ごとの UTF-8 size telemetry を維持します。initialize guidance も短縮し、詳細 workflow は互換性のある prompt へ誘導します。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 0ba2ad700..62493a4c1 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -326,7 +326,9 @@ private static JsonObject BuildCompactToolCatalogEntry(JsonObject tool) var compact = new JsonObject { ["name"] = tool["name"]?.DeepClone(), - ["description"] = BuildCompactToolDescription(tool["description"]?.GetValue()), + ["description"] = BuildCompactToolDescription( + tool["name"]?.GetValue(), + tool["description"]?.GetValue()), ["inputSchema"] = BuildInvocationSchema(tool["inputSchema"]), }; if (tool["annotations"] is not null) @@ -368,7 +370,7 @@ private static JsonObject BuildInvocationSchemaMap(JsonObject schemaMap) return invocationSchemaMap; } - private static string BuildCompactToolDescription(string? description) + private static string BuildCompactToolDescription(string? toolName, string? description) { if (string.IsNullOrWhiteSpace(description)) return string.Empty; @@ -377,6 +379,8 @@ private static string BuildCompactToolDescription(string? description) var sentenceEnd = english.IndexOf(". ", StringComparison.Ordinal); if (sentenceEnd >= 0) english = english[..(sentenceEnd + 1)]; + if (toolName == "suggest_improvement") + english += " Never include source code; describe the issue in natural language only."; const int maxDescriptionCharacters = 240; return english.Length <= maxDescriptionCharacters ? english diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs index 2a7af7042..fc280fec8 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs @@ -21,13 +21,16 @@ public partial class McpServer private string BuildInstructions() { bool On(string name) => _toolFilter.IsEnabled(name); + bool All(params string[] names) => names.All(On); var parts = new List { "cdidx is a local-first code-index server. Prefer its focused MCP tools before shell grep/find/cat or whole-file reads. cdidx は local-first なコード索引サーバーです。shell の grep/find/cat やファイル全体の読み取りより、絞り込んだ MCP tool を優先してください。", "The default tools/list page is a bounded catalog with authoritative invocation schemas. Request format=full with exact names only when detailed descriptions, output schemas, or examples are needed, and continue pagination with nextCursor unchanged. 既定の tools/list は呼び出し用 schema を保持した bounded catalog です。詳細説明、output schema、example が必要な場合だけ正確な names と format=full を指定し、nextCursor は変更せず継続してください。", - "Use prompts/list and prompts/get for extended workflows such as investigate_before_edit. 詳細な workflow は prompts/list と prompts/get(例: investigate_before_edit)から取得してください。", }; + if (All("map", "search", "symbols", "definition", "references", "excerpt")) + parts.Add("Use prompts/list and prompts/get for extended workflows such as investigate_before_edit. 詳細な workflow は prompts/list と prompts/get(例: investigate_before_edit)から取得してください。"); + if (On("index")) parts.Add("If no index exists, call 'index' first. index が無い場合は最初に 'index' を呼んでください。"); diff --git a/tests/CodeIndex.Tests/McpServerProtocolTests.cs b/tests/CodeIndex.Tests/McpServerProtocolTests.cs index 5f0672be9..485f96f9d 100644 --- a/tests/CodeIndex.Tests/McpServerProtocolTests.cs +++ b/tests/CodeIndex.Tests/McpServerProtocolTests.cs @@ -1280,6 +1280,7 @@ public void Initialize_InstructionsOmitsDisabledTools() Assert.DoesNotContain("'unused_symbols'", instructions); Assert.DoesNotContain("'symbol_hotspots'", instructions); Assert.DoesNotContain("'impact_analysis'", instructions); + Assert.DoesNotContain("investigate_before_edit", instructions); // The exactName-guidance sentence used to enumerate "symbols/definition/references/ // callers/callees/analyze_symbol" verbatim. With only 'search' and 'definition' // enabled, none of those disabled names should leak into the guidance. diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index fcf0e03a2..1f43d47b9 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -169,6 +169,8 @@ public void ToolsList_DefaultCatalogIsAgentSafeAndPointsToFullDefinitions_Issues Assert.Contains( compactSearch["inputSchema"]!["anyOf"]!.AsArray(), mode => mode!["required"]!.AsArray().Any(required => required!.GetValue() == "query")); + var compactSuggestion = compactTools.Single(tool => tool!["name"]!.GetValue() == "suggest_improvement")!; + Assert.Contains("Never include source code", compactSuggestion["description"]!.GetValue(), StringComparison.Ordinal); var meta = compactResult["_meta"]!; Assert.Equal("compact", meta["format"]!.GetValue()); From 89f6247ea09a4fe8591b6d9b42049eed10743c5e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 11 Aug 2026 00:12:46 +0900 Subject: [PATCH 3/3] Preserve compact MCP authority guidance (#5059) --- src/CodeIndex/Mcp/McpToolDefinitions.cs | 17 ++++++++++++----- .../CodeIndex.Tests/McpServerToolsListTests.cs | 9 +++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 62493a4c1..49069782f 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -379,12 +379,19 @@ private static string BuildCompactToolDescription(string? toolName, string? desc var sentenceEnd = english.IndexOf(". ", StringComparison.Ordinal); if (sentenceEnd >= 0) english = english[..(sentenceEnd + 1)]; - if (toolName == "suggest_improvement") - english += " Never include source code; describe the issue in natural language only."; + var safetyGuidance = toolName switch + { + "impact_analysis" => " File-level fallback may be heuristic; check `impact_mode`, `heuristic`, and `file_impacts`.", + "suggest_improvement" => " Never include source code; describe the issue in natural language only.", + "unused_symbols" => " Verify surprising hits before editing; meaningful only for languages with reference extraction.", + "validate" => " Totals are authoritative only while `file_issues_data_current` is true.", + _ => string.Empty, + }; const int maxDescriptionCharacters = 240; - return english.Length <= maxDescriptionCharacters - ? english - : $"{english[..(maxDescriptionCharacters - 3)].TrimEnd()}..."; + var baseDescriptionLimit = maxDescriptionCharacters - safetyGuidance.Length; + if (english.Length > baseDescriptionLimit) + english = $"{english[..(baseDescriptionLimit - 3)].TrimEnd()}..."; + return english + safetyGuidance; } private static JsonObject BuildCompactToolsListCatalogMeta( diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index 1f43d47b9..0d7ee52b8 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -171,6 +171,15 @@ public void ToolsList_DefaultCatalogIsAgentSafeAndPointsToFullDefinitions_Issues mode => mode!["required"]!.AsArray().Any(required => required!.GetValue() == "query")); var compactSuggestion = compactTools.Single(tool => tool!["name"]!.GetValue() == "suggest_improvement")!; Assert.Contains("Never include source code", compactSuggestion["description"]!.GetValue(), StringComparison.Ordinal); + var compactUnused = compactTools.Single(tool => tool!["name"]!.GetValue() == "unused_symbols")!; + Assert.Contains("meaningful only for languages with reference extraction", compactUnused["description"]!.GetValue(), StringComparison.Ordinal); + var compactValidate = compactTools.Single(tool => tool!["name"]!.GetValue() == "validate")!; + Assert.Contains("authoritative only while `file_issues_data_current` is true", compactValidate["description"]!.GetValue(), StringComparison.Ordinal); + var compactImpact = compactTools.Single(tool => tool!["name"]!.GetValue() == "impact_analysis")!; + Assert.Contains("File-level fallback may be heuristic", compactImpact["description"]!.GetValue(), StringComparison.Ordinal); + Assert.All( + compactTools, + tool => Assert.True(tool!["description"]!.GetValue().Length <= 240)); var meta = compactResult["_meta"]!; Assert.Equal("compact", meta["format"]!.GetValue());