From f90640d76f65211901e6ba6d270cd0ecf2e44641 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 13 Sep 2026 00:44:52 +0900 Subject: [PATCH 1/4] Expose semantic MCP search and bounded find continuation (#5349) --- TESTING_GUIDE.md | 14 + USER_GUIDE.md | 10 +- changelog.d/unreleased/5349.added.md | 16 + docs/find-scan-controls.md | 84 +++++- .../Cli/QueryCommandRunner.ArgParsing.cs | 4 +- src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 4 +- .../Cli/QueryCommandRunner.SearchResults.cs | 37 ++- src/CodeIndex/Mcp/McpServer.Responses.cs | 3 +- .../Mcp/McpServer.SynchronousToolDispatch.cs | 1 + src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 7 +- src/CodeIndex/Mcp/McpToolCatalog.Search.cs | 58 ++++ src/CodeIndex/Mcp/McpToolCatalog.cs | 1 + src/CodeIndex/Mcp/McpToolDefinitions.cs | 2 +- src/CodeIndex/Mcp/McpToolFilter.cs | 1 + .../Mcp/McpToolHandlers.ArgumentValidation.cs | 10 +- .../Mcp/McpToolHandlers.Pagination.cs | 4 +- .../Mcp/McpToolHandlers.Query.Find.cs | 173 +++++++++++ .../Mcp/McpToolHandlers.Query.Search.cs | 60 +++- .../Mcp/McpToolHandlers.Query.Source.cs | 92 ++---- .../Mcp/McpToolHandlers.SearchSemantics.cs | 95 ++++++ src/CodeIndex/Mcp/McpToolOutputSchemas.cs | 4 +- src/CodeIndex/Models/QueryResults.cs | 2 + .../CodeIndex.Tests/HttpMcpTransportTests.cs | 33 ++ .../McpServerIssue5349Tests.cs | 281 ++++++++++++++++++ .../McpServerToolsListTests.cs | 30 +- tests/CodeIndex.Tests/McpToolContractTests.cs | 13 +- 26 files changed, 941 insertions(+), 98 deletions(-) create mode 100644 changelog.d/unreleased/5349.added.md create mode 100644 src/CodeIndex/Mcp/McpToolCatalog.Search.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs create mode 100644 tests/CodeIndex.Tests/McpServerIssue5349Tests.cs diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6b5f25d96..4ef11fd28 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1,5 +1,12 @@ # Testing Guide +MCP search parity coverage (#5349) lives in `McpServerIssue5349Tests` and +`HttpMcpTransportTests`. Run `--filter FullyQualifiedName~Issue5349` on net8/net9 +with existing MCP schema/dispatch and CLI find/search-classification tests. +Keep CLI row/count comparisons, recipe/batch execution, path-required controls, +unknown origins, scan/byte caps, same-line/zero-width continuation, changed +filters/generation, timeout and request cancellation in shared small fixtures. + Dependency summary regressions in `QueryCommandRunnerGraphTests` (#5346) separate page counts, SQL/C# candidate boundaries, extraction completeness, and response budgets. Keep the three-edge limits 1/2/3/4, empty/filter/missing-graph controls, @@ -1434,6 +1441,13 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除 # テストガイド +MCP 検索の同等性 (#5349) は `McpServerIssue5349Tests` と `HttpMcpTransportTests` で +検証します。`--filter FullyQualifiedName~Issue5349` を net8/net9 で実行し、既存の +MCP スキーマ・dispatch と CLI find・検索分類のテストも併せて確認してください。 +小さな共通フィクスチャで CLI の行・件数比較、recipe・batch 実行、path 必須の契約、 +unknown、走査・サイズ上限、同一行・ゼロ幅一致の継続、条件・世代の変更、タイムアウトと +リクエストのキャンセルを維持します。 + `QueryCommandRunnerGraphTests` の依存関係 summary 回帰テスト(#5346)は、ページ件数、 SQL/C# の候補上限、抽出の完全性、応答サイズ上限を区別します。3 edge に対する limit 1/2/3/4、空結果・フィルター・グラフ欠落、batch の子メタデータ、201 symbol diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8f2e06780..587cf9d6b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -3874,14 +3874,15 @@ The MCP `tools/list` response includes an `examples` array for every registered | Tool | Description | |---|---| -| `search` | Full-text search across code chunks | +| `search` | Full-text search across code chunks with [origin/result-kind and fixture filters](docs/find-scan-controls.md#mcp-search-and-continuation-5349), also available for recipes | | `definition` | Reconstruct a symbol declaration and optional body | | `references` | Find indexed references for supported languages; identical constructor `call` + `instantiate` rows collapse by default | | `callers` | List callers for a named symbol in supported languages; `kind` filters by reference kind. The default keeps invocation-like kinds visible (`call`, `instantiate`, `subscribe`) while hiding metadata edges (`attribute`, `annotation`) and compile-time `type_reference` rows (e.g. `nameof(X)` / `typeof(T)`). Human-readable output prints the grouped reference-kind tag at the start of each row, joining multiple distinct kinds with `+` (for example `call+subscribe`) when one container mixes kinds, so terminals can distinguish `call` from `instantiate` / `subscribe` / mixed without `--json`. The reference-kind column widens dynamically to fit the longest label in the batch. The MCP response keeps the scalar `referenceKind` (back-compat with existing consumers; it reports the preferred summary kind `instantiate` > `subscribe` > `MIN(call)`) and adds a sorted `referenceKinds` array plus `hasMixedReferenceKinds` so consumers that need the full picture can avoid trusting a single collapsed label. `callers` is not a reliable path to metadata — an attribute / annotation row is attributed to the enclosing body-range symbol (the class for a member declaration) or drops entirely when the target is file-level (`[assembly: ...]`, where `containerName` is `null`). Use `references` with `kind: "attribute"` / `kind: "annotation"` for metadata enumeration. Identical constructor `call` + `instantiate` rows at one physical site collapse. | | `callees` | List callees for a named symbol in supported languages; the default keeps invocation-like kinds visible (`call`, `instantiate`, `subscribe`) while hiding metadata edges (`attribute`, `annotation`) and compile-time `type_reference` rows. MCP responses also include the sorted `referenceKinds` array and `hasMixedReferenceKinds` alongside the scalar `referenceKind`, since callee rows stay split per kind but still surface the mixed-kind contract for AI clients. Identical constructor `call` + `instantiate` rows at one physical site collapse. | | `symbols` | Find functions, classes, interfaces, imports, and namespaces by name | | `files` | List indexed files | -| `find_in_file` | Find literal substring matches inside known indexed files with line/column context | +| `find` | Bounded repository-wide literal/regex search with semantic filters, scan budgets, and continuation; see [MCP find controls](docs/find-scan-controls.md#mcp-search-and-continuation-5349) | +| `find_in_file` | Find literal/regex matches inside known indexed files with line/column context; `path` remains required and regex semantic filters share the [MCP find controls](docs/find-scan-controls.md#mcp-search-and-continuation-5349) | | `excerpt` | Reconstruct a specific line range from indexed chunks | | `map` | Summarize languages, modules, hotspots, and likely entrypoints | | `analyze_symbol` | Bundle definition, nearby symbols, references, callers, callees, file metadata, workspace trust metadata, and graph support metadata. Bundled `callers` / `callees` rows carry the same `referenceKind` (preferred summary, back-compat) plus `referenceKinds` (sorted distinct) and `hasMixedReferenceKinds` fields as the standalone tools, so mixed `call` + `subscribe` containers stay visible in the bundle. | @@ -7886,14 +7887,15 @@ cdidx は現行 Codex client 向けに MCP `2025-06-18` を交渉し、`2025-03- | ツール | 説明 | |---|---| -| `search` | コードチャンクの全文検索 | +| `search` | コードチャンクの全文検索。[origin・結果種別・fixture フィルター](docs/find-scan-controls.md#mcp-の検索と継続取得-5349)は recipe にも対応 | | `definition` | シンボルの宣言と必要なら本体を再構成して取得 | | `references` | 対応言語でインデックス済み参照を検索。constructor site の `call` + `instantiate` 重複は既定で集約 | | `callers` | 対応言語で指定シンボルの caller を列挙。`kind` は reference kind を指し、既定では invocation 系の kind(`call`、`instantiate`、`subscribe`)のみを表示して `attribute` / `annotation` のような metadata edge とコンパイル時の `type_reference`(`nameof(X)` / `typeof(T)` 等)は除外する。人間向け出力では各行の先頭に reference kind タグを表示し、1 つの container で複数 kind が混在する場合は `call+subscribe` のように `+` で連結して示すため、`--json` を付けなくても `call` / `instantiate` / `subscribe` / mixed を見分けられる。reference-kind 列の幅はバッチ内の最長ラベルに合わせて動的に広がる。MCP レスポンスは後方互換のため scalar な `referenceKind`(preferred 順 `instantiate` > `subscribe` > `MIN(call)` の要約 kind)を残しつつ、ソート済みの `referenceKinds` 配列と `hasMixedReferenceKinds` も追加したので、全 kind が必要な consumer は要約ラベルに騙されずに済む。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(メンバ宣言ならクラス)に設定され、`[assembly: ...]` のようなファイルレベル target では `containerName` が `null` になって `callers` 結果から脱落する。C# の `[...]` 属性や Java 系 `@Annotation(...)` を列挙したいときは `references --kind attribute|annotation` / MCP `references` を使う。同じ物理位置にある constructor の `call` + `instantiate` 重複は集約する。 | | `callees` | 対応言語で指定シンボルの callee を列挙。既定は invocation 系の kind(`call`、`instantiate`、`subscribe`)のみで、`attribute` / `annotation` のような metadata edge とコンパイル時の `type_reference` は除外する。MCP レスポンスには scalar な `referenceKind` に加えて、ソート済みの `referenceKinds` 配列と `hasMixedReferenceKinds` も含める。callee 側は kind 単位で行が分かれるが、AI クライアントが caller 側と同じ mixed-kind 契約を扱えるようにするため。同じ物理位置にある constructor の `call` + `instantiate` 重複は集約する。 | | `symbols` | 関数・クラス・インターフェース・import・namespace を名前で検索 | | `files` | インデックス済みファイル一覧 | -| `find_in_file` | 既知のインデックス済みファイル内でリテラル部分文字列一致を行・列付きで検索 | +| `find` | 意味フィルター・走査上限・継続取得に対応したリポジトリ横断のリテラル/正規表現検索。[MCP find の指定方法](docs/find-scan-controls.md#mcp-の検索と継続取得-5349)を参照 | +| `find_in_file` | 既知の索引済みファイル内を行・列付きでリテラル/正規表現検索。`path` は引き続き必須で、正規表現の意味フィルターは [MCP find の指定方法](docs/find-scan-controls.md#mcp-の検索と継続取得-5349)と共通 | | `excerpt` | インデックス済みチャンクから特定行範囲を再構成 | | `map` | 言語、モジュール、ホットスポット、推定エントリポイントを要約 | | `analyze_symbol` | 定義、近傍シンボル、参照、caller、callee、ファイル情報、ワークスペース信頼メタデータ、graph 対応メタデータをまとめて返す。バンドルされた `callers` / `callees` 行にも単独の `callers` / `callees` と同じ `referenceKind`(後方互換の優先サマリー種別)、`referenceKinds`(distinct kind の昇順配列)、`hasMixedReferenceKinds` が付くため、`call` + `subscribe` が混在する container も要約 1 ラベルに潰れず見える。 | diff --git a/changelog.d/unreleased/5349.added.md b/changelog.d/unreleased/5349.added.md new file mode 100644 index 000000000..ff4c40829 --- /dev/null +++ b/changelog.d/unreleased/5349.added.md @@ -0,0 +1,16 @@ +--- +category: added +issues: + - 5349 +affected: + - src/CodeIndex/Mcp + - docs/find-scan-controls.md +--- + +## English + +- **MCP semantic search and bounded repository find (#5349)** — `search`, recipes, and regex `find_in_file` now accept CLI origin/result-kind and fixture filters. The additive `find` tool supports repository-wide scanning, scan budgets, count pages, and generation-bound continuation. Structured results retain unknown-origin authority, partial results, and recovery guidance across STDIO, HTTP, and batches; byte-limited find pages preserve omitted matches for resumption. + +## 日本語 + +- **MCP の意味フィルターと上限付きリポジトリ横断検索 (#5349)** — `search`、recipe、正規表現の `find_in_file` で CLI と同じ origin・結果種別・fixture フィルターを使えるようになりました。新しい `find` は横断走査、走査上限、件数ページ、索引世代に紐づく継続取得に対応します。STDIO・HTTP・batch の構造化結果で unknown の確定性、部分結果、復旧案内を保持し、応答サイズで省略した一致も次ページから取得できます。 diff --git a/docs/find-scan-controls.md b/docs/find-scan-controls.md index a14197a47..3d6ddfda8 100644 --- a/docs/find-scan-controls.md +++ b/docs/find-scan-controls.md @@ -2,6 +2,48 @@ ## English +### MCP search and continuation (#5349) + +MCP `search` (including recipes), `find`, and `find_in_file` accept `origin`, +`excludeOrigin`, and `resultKind` as comma-separated strings or string arrays, +plus `excludeComments`, `excludeStrings`, and `excludeFixtures` booleans. +They share CLI validation and classification, applying filters before counts +and pagination. Find semantic filters require `regex:true` and support origin +names and `identifier` result kinds. Search also supports its declaration and +call-site classifications. Classifier coverage is unchanged. + +```json +{"name":"search","arguments":{"query":"File.Delete","origin":"code"}} +{"name":"find","arguments":{"query":"TODO|FIXME|HACK","regex":true,"all":true,"limit":30,"lineScanLimit":20000,"maxBytes":65536,"excludeTests":true}} +``` + +`find` requires either `all:true` or `path`; `find_in_file` continues to require +`path`. Both accept the existing context, language and exclusion options, plus +`cursor`, `countOnly`, and `maxBytes`. Only `find` with `all:true` accepts +`lineScanLimit`: default 250,000, maximum 10,000,000 lines per page, with a +4,096-file cap. Both tools retain the MCP 200-result maximum. Their default +`maxBytes` is 65,536 UTF-8 bytes in `structuredContent`; the enclosing response +must also fit the server budget. Byte fitting keeps whole rows and returns a +cursor before any omitted matches. An unfit minimum page returns +`E028_RESPONSE_BUDGET_TOO_SMALL` without consuming the input cursor. + +Pass `next_cursor` back as `cursor` until `has_more:false`. Keep the query, +scope, classification filters and `countOnly` mode unchanged; `limit`, +`maxBytes`, and `lineScanLimit` may change. Cursors bind indexed source identity +and generation, including raw same-line and zero-width match positions. Discard +them after indexing. Malformed, mismatched and stale cursors return structured +errors. Timeout or cancellation does not issue a new cursor. + +MCP find results preserve scan budgets, `scan_complete`, `partial_result`, +`authoritative_rows`/`authoritative_count`, `origin_classification_complete`, +`unknown_origin_matches`, and recovery guidance. Unknowns rejected by filters +still degrade authority. Resumed pages describe only their segment and remain +non-authoritative; sum unchanged-source count pages to obtain the full count. +Semantic search also exposes `candidate_scan_complete` and classification +completeness; bounded or unknown coverage cannot prove absence. Its cursor +binds filters and generation and supports changing the row limit. STDIO, HTTP, +and `batch_query` share these handlers and structured results. + ### Regex origin filters (#5324) Use `cdidx find 'XmlReader\.Create' --regex --path src/ --origin code --json`. @@ -20,7 +62,7 @@ Rows expose `match_facets` with original UTF-16 line/column/length, including zero length. `result_kinds` supports origin names and search's `identifier` projection for code; `declaration` and `call_site` are not supported here. Fixture classification uses recognized test-file paths and string-like origins; -`test_symbol` is not inferred from regex text. There is no new MCP find surface. +`test_symbol` is not inferred from regex text. Filtered row output supports text and JSON/NDJSON, including bounded `--fields`, `--cursor` and `--max-json-bytes`; formats without terminal authority metadata @@ -81,6 +123,44 @@ text or JSON output when context from `--before`, `--after`, or ## 日本語 +### MCP の検索と継続取得 (#5349) + +MCP の `search`(recipe を含む)、`find`、`find_in_file` は、カンマ区切り文字列または +文字列配列の `origin`、`excludeOrigin`、`resultKind` と、真偽値の `excludeComments`、 +`excludeStrings`、`excludeFixtures` に対応します。CLI と同じ検証・分類処理を使い、 +件数とページ分割の前にフィルターを適用します。find の意味フィルターには `regex:true` が +必要で、結果種別は origin 名と `identifier` に対応します。search は宣言・呼び出し位置の +分類にも対応します。分類器の対応範囲は従来どおりです。 + +```json +{"name":"search","arguments":{"query":"File.Delete","origin":"code"}} +{"name":"find","arguments":{"query":"TODO|FIXME|HACK","regex":true,"all":true,"limit":30,"lineScanLimit":20000,"maxBytes":65536,"excludeTests":true}} +``` + +`find` は `all:true` または `path` の一方が必要です。`find_in_file` は引き続き `path` を +必須とします。両方で従来の文脈・言語・除外指定に加え、`cursor`、`countOnly`、`maxBytes` を +使用できます。`lineScanLimit` は `find` の `all:true` 時のみ対応し、ページあたり既定 +250,000 行、最大 10,000,000 行、ファイル数上限は 4,096 件です。MCP の結果上限は両方とも +200 件を維持します。`maxBytes` の既定値は `structuredContent` の UTF-8 サイズで +65,536 バイトです。外側の応答もサーバーの上限内に収めます。サイズ調整では行を分断せず、 +省略した一致の直前を指すカーソルを返します。最小ページも入らない場合は入力カーソルを +進めず `E028_RESPONSE_BUDGET_TOO_SMALL` を返します。 + +`has_more:false` になるまで `next_cursor` を `cursor` として渡します。検索語・範囲・ +分類フィルター・`countOnly` は同じ値を維持し、`limit`、`maxBytes`、`lineScanLimit` は +変更できます。カーソルは索引の識別情報と世代、同じ行やゼロ幅の一致位置も保持します。 +再索引後は破棄してください。形式不正・条件不一致・古い世代は構造化エラーになり、 +タイムアウトやキャンセルでは新しいカーソルを発行しません。 + +MCP の find は走査上限、`scan_complete`、`partial_result`、`authoritative_rows` / +`authoritative_count`、`origin_classification_complete`、`unknown_origin_matches` と +復旧案内を保持します。フィルターで除外した unknown も確定性を低下させます。再開ページは +その区間だけを表すため、最終ページでも確定扱いにはしません。同じ索引の件数ページを +合算すると全件数が得られます。意味フィルター付き search も `candidate_scan_complete` と +分類完了状態を示し、上限到達や unknown が残る場合に不在を証明しません。カーソルは条件と +世代に紐づき、行数上限は変更できます。STDIO・HTTP・`batch_query` は共通ハンドラーと +構造化結果を使います。 + ### 正規表現の origin フィルター (#5324) `cdidx find 'XmlReader\.Create' --regex --path src/ --origin code --json` を使います。 @@ -96,7 +176,7 @@ v1 は search と共通の C# 索引済みプレフィックス分類器(4,096 `unknown` のままです。行の `match_facets` は元の UTF-16 行・列・長さを保持し、長さ 0 にも対応します。 `result_kinds` は origin 名と、code に対する search と同じ `identifier` 投影に対応します。 `declaration` と `call_site` には対応しません。fixture は認識済みテストファイルのパスと文字列系 origin から -判定し、正規表現の文字列から `test_symbol` を推測しません。MCP の find 機能は追加しません。 +判定し、正規表現の文字列から `test_symbol` を推測しません。 フィルター付きの行出力は text と JSON/NDJSON に対応し、`--fields`、`--cursor`、 `--max-json-bytes` も使用できます。終端の確定性情報を保持できない形式は拒否します。件数出力も利用できます。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs index f3bd8c346..753caedbf 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs @@ -441,7 +441,7 @@ void AddField(string field) return all ? null : fields; } - private static void AddSearchMatchOrigins(string optionName, string rawValue, List origins, Action addParseError) + internal static void AddSearchMatchOrigins(string optionName, string rawValue, List origins, Action addParseError) { if (!ValidateCsvBounds(optionName, rawValue, MaxSearchProjectionFieldsCsvLength, MaxSearchProjectionFieldsCsvEntries, addParseError)) return; @@ -466,7 +466,7 @@ private static void AddSourceOnlyDefaultExcludeOrigin(List excludeOrigin excludeOrigins.Add(origin); } - private static void AddSearchResultKinds(string rawValue, List resultKinds, Action addParseError) + internal static void AddSearchResultKinds(string rawValue, List resultKinds, Action addParseError) { if (!ValidateCsvBounds("--result-kind", rawValue, MaxSearchProjectionFieldsCsvLength, MaxSearchProjectionFieldsCsvEntries, addParseError)) return; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index 592b4675d..a3f9da8d5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -825,7 +825,7 @@ private static string BuildFindScanTerminalLine( return payload.ToJsonString(GetCompactJsonOptions(jsonOptions)); } - private static void AddFindTerminalScanFields( + internal static void AddFindTerminalScanFields( JsonObject payload, FindScanSummary scan, int returnedCount, @@ -925,7 +925,7 @@ private static void WriteFindScanSummary( CommandErrorWriter.WriteStderr($"Recovery: {recoveryGuidance}"); } - private static (string? Cursor, string? ResultStableAt) BuildFindResumeCursor( + internal static (string? Cursor, string? ResultStableAt) BuildFindResumeCursor( string[] commandArgs, DbReader reader, FindScanSummary scan) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index f21808142..f6ed05419 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -1380,11 +1380,31 @@ private static List ReadSearchDisplayRows( : rows.Skip(responseOffset).ToList(); } + internal static (List Rows, bool ScanComplete, bool ClassificationComplete) ReadSemanticSearchRows( + DbReader reader, + QueryCommandOptions options, + int requestedLimit, + SearchAuditRecipeQuery? recipeQuery = null, + IReadOnlyList? requiredPathPatterns = null) + { + var scanComplete = false; + var classificationComplete = true; + var rows = ReadOriginFilteredSearchDisplayRows(reader, options, + options.Exact || options.ExactSubstring || options.TokenBoundary, requestedLimit, + complete => classificationComplete &= complete, + complete => scanComplete = complete, recipeQuery, requiredPathPatterns); + return (rows.Select(row => row.Compact).ToList(), scanComplete, classificationComplete); + } + private static List ReadOriginFilteredSearchDisplayRows( DbReader reader, QueryCommandOptions options, bool exact, - int requestedLimit) + int requestedLimit, + Action? originCoverageObserver = null, + Action? candidateCoverageObserver = null, + SearchAuditRecipeQuery? recipeQuery = null, + IReadOnlyList? requiredPathPatterns = null) { requestedLimit = Math.Max(0, requestedLimit); if (requestedLimit == 0) @@ -1409,13 +1429,18 @@ private static List ReadOriginFilteredSearchDisplayRows( // The extra display candidate is only a pagination probe. Guard evaluation must // retain the user's requested budget or its bounded candidate scan can stop before // the first qualifying row. - var page = ReadSearchResults(reader, options, exact, pageLimit, cursor, options.Limit); + var page = ReadSearchResults(reader, options, exact, pageLimit, cursor, options.Limit, + recipeQuery, requiredPathPatterns); pagesRead++; if (page.Count == 0) + { + candidateCoverageObserver?.Invoke(true); break; + } candidates.AddRange(page); - displayRows = BuildSearchDisplayRows(candidates, options, exact); + displayRows = BuildSearchDisplayRows(candidates, options, exact, + recipeQuery: recipeQuery, originCoverageObserver: originCoverageObserver); var last = page[^1]; if (last.NextOffset <= currentOffset) @@ -1450,8 +1475,10 @@ private static int GetSearchDisplayCandidateLimit(QueryCommandOptions options) return SearchOriginFilterMaxCandidates; } - private static List ReadSearchResults(DbReader reader, QueryCommandOptions options, bool exact, int limit, SearchCursor? cursor = null, int? guardRequestedLimit = null) - => reader.Search(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary); + private static List ReadSearchResults(DbReader reader, QueryCommandOptions options, bool exact, int limit, SearchCursor? cursor = null, int? guardRequestedLimit = null, + SearchAuditRecipeQuery? recipeQuery = null, IReadOnlyList? requiredPathPatterns = null) + => reader.Search(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary, + requiredPathPatterns: requiredPathPatterns, resultRanking: recipeQuery?.ResultRanking ?? default); private static QueryCountResult CountFilteredSearchResults(DbReader reader, QueryCommandOptions options, bool exact) { diff --git a/src/CodeIndex/Mcp/McpServer.Responses.cs b/src/CodeIndex/Mcp/McpServer.Responses.cs index d1af4682a..e17a6fd8d 100644 --- a/src/CodeIndex/Mcp/McpServer.Responses.cs +++ b/src/CodeIndex/Mcp/McpServer.Responses.cs @@ -540,6 +540,7 @@ private static JsonArray BuildToolExamples(string name) "excerpt" => new JsonObject { ["path"] = "src/app.cs", ["startLine"] = 1, ["endLine"] = 5 }, "read_resource" => new JsonObject { ["uri"] = "cdidx://file/src/app.cs", ["startLine"] = 1, ["endLine"] = 5 }, "find_in_file" => new JsonObject { ["path"] = "src/app.cs", ["query"] = "Run", ["before"] = 1, ["after"] = 1 }, + "find" => new JsonObject { ["query"] = "TODO|FIXME", ["regex"] = true, ["all"] = true, ["origin"] = "comment", ["limit"] = 20, ["lineScanLimit"] = 20000 }, "map" => new JsonObject { ["limit"] = 5, ["excludeTests"] = true }, "analyze_symbol" => new JsonObject { ["query"] = "Run", ["includeBody"] = true }, "impact_analysis" => new JsonObject { ["query"] = "Run", ["maxHops"] = 2, ["withPaths"] = true }, @@ -598,7 +599,7 @@ private static string AppendLanguageSupportClause(string name, string descriptio => $"Language support: Supports symbol extraction for: {SymbolLanguageList()}. Search-only languages can still be indexed and filtered by file tools but may have no symbol rows.", "search" => "Language support: Supports indexed file/content filters for every detected language; call `languages` for the full catalog.", - "find_in_file" or "files" or "map" + "find" or "find_in_file" or "files" or "map" => $"Language support: Supports indexed file/content filters for every detected language listed by `languages`: {DetectedLanguageList()}. Symbol and graph fields are available only for the languages whose capabilities are advertised by `languages`.", "excerpt" or "read_resource" or "status" or "validate" => $"Language support: Language-agnostic over indexed files and diagnostics for every detected language listed by `languages`: {DetectedLanguageList()}. This tool does not interpret a `lang` filter.", diff --git a/src/CodeIndex/Mcp/McpServer.SynchronousToolDispatch.cs b/src/CodeIndex/Mcp/McpServer.SynchronousToolDispatch.cs index cd186c998..900049328 100644 --- a/src/CodeIndex/Mcp/McpServer.SynchronousToolDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.SynchronousToolDispatch.cs @@ -14,6 +14,7 @@ public partial class McpServer "callees" => ExecuteCallees(id, args), "symbols" => ExecuteSymbols(id, args), "files" => ExecuteFiles(id, args), + "find" => ExecuteFindInFile(id, args, allowAll: true), "find_in_file" => ExecuteFindInFile(id, args), "excerpt" => ExecuteExcerpt(id, args), "read_resource" => ExecuteReadResource(id, args), diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index 7809ee7f2..208830629 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -5,7 +5,7 @@ public partial class McpServer private static bool IsKnownToolName(string toolName) => toolName switch { "search" or "definition" or "references" or "callers" or "callees" or "symbols" or - "files" or "find_in_file" or "excerpt" or "read_resource" or "map" or "analyze_symbol" or "status" or + "files" or "find" or "find_in_file" or "excerpt" or "read_resource" or "map" or "analyze_symbol" or "status" or "outline" or "batch_query" or "deps" or "impact_analysis" or "languages" or "validate" or "unused_symbols" or "symbol_hotspots" or "ping" or "index" or "backfill_fold" or "suggest_improvement" => true, @@ -14,13 +14,14 @@ public partial class McpServer private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch { - "search" => new HashSet(StringComparer.Ordinal) { "query", "recipe", "listRecipes", "auditScope", "limit", "lang", "snippetLines", "snippetFocus", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "tokenBoundary", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "guardScope", "countOnly", "format", "project", "solution" }, + "search" => new HashSet(StringComparer.Ordinal) { "query", "recipe", "listRecipes", "auditScope", "limit", "lang", "snippetLines", "snippetFocus", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "tokenBoundary", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "guardScope", "countOnly", "format", "project", "solution", "origin", "excludeOrigin", "resultKind", "excludeComments", "excludeStrings", "excludeFixtures" }, "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "visibility", "excludeVisibility", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet(StringComparer.Ordinal) { "query", "selector", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "includeQualifiedCommonCalls", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "selector", "kind", "rawKinds", "includeQualifiedCommonCalls", "includeMemberReads", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "visibility", "excludeVisibility", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "countOnly", "format", "project", "solution" }, "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "orderBySize", "rawBytes", "project", "solution" }, - "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, + "find" => new HashSet(StringComparer.Ordinal) { "query", "path", "all", "lineScanLimit", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex", "origin", "excludeOrigin", "resultKind", "excludeComments", "excludeStrings", "excludeFixtures", "cursor", "countOnly", "maxBytes" }, + "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex", "origin", "excludeOrigin", "resultKind", "excludeComments", "excludeStrings", "excludeFixtures", "cursor", "countOnly", "maxBytes" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, "read_resource" => new HashSet(StringComparer.Ordinal) { "uri", "startLine", "endLine", "maxBytes", "cursor", "includeGenerated" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "minEntrypointConfidence", "project", "solution" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.Search.cs b/src/CodeIndex/Mcp/McpToolCatalog.Search.cs new file mode 100644 index 000000000..a9507d526 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolCatalog.Search.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private static void AddSemanticSearchTools(JsonArray tools) + { + var search = tools.OfType().Single(tool => tool["name"]!.GetValue() == "search"); + var scopedFind = tools.OfType().Single(tool => tool["name"]!.GetValue() == "find_in_file"); + foreach (var tool in new[] { search, scopedFind }) + { + var properties = tool["inputSchema"]!["properties"]!.AsObject(); + properties["origin"] = StringOrArraySchema( + "Include lexical match origins (OR within the list). Accepts comma-separated strings or arrays: " + + string.Join(", ", CliFlagSchema.GetCanonicalValuesForCommand("search", "--origin")) + "."); + properties["excludeOrigin"] = StringOrArraySchema("Exclude lexical match origins; exclusions win over inclusion. Accepts comma-separated strings or arrays."); + properties["resultKind"] = StringOrArraySchema("Include result kinds, using CLI search classification. Find supports origin names and identifier; declaration/call_site require search."); + properties["excludeComments"] = new JsonObject { ["type"] = "boolean", ["default"] = false }; + properties["excludeStrings"] = new JsonObject { ["type"] = "boolean", ["default"] = false }; + properties["excludeFixtures"] = new JsonObject { ["type"] = "boolean", ["default"] = false }; + } + + scopedFind["description"] = scopedFind["description"]!.GetValue() + + " Semantic filters require regex=true. / 意味フィルターには regex=true が必要。"; + var scopedProperties = scopedFind["inputSchema"]!["properties"]!.AsObject(); + scopedProperties["cursor"] = new JsonObject + { + ["type"] = "string", ["maxLength"] = MaxMcpQueryCursorCharacters, + ["description"] = "Resume next_cursor with the same query, filters, and count mode. Limit and maxBytes may change. Restart after indexing.", + }; + scopedProperties["countOnly"] = new JsonObject { ["type"] = "boolean", ["default"] = false }; + scopedProperties["maxBytes"] = new JsonObject + { + ["type"] = "integer", ["minimum"] = 1, ["maximum"] = MaxConfiguredResponseBytes, + ["default"] = DefaultFindMaxBytes, + ["description"] = "Maximum UTF-8 bytes in structuredContent (default 65536). Whole rows are paged without advancing past omitted matches. Server response limits also apply.", + }; + var schema = scopedFind["inputSchema"]!.DeepClone().AsObject(); + schema["required"] = new JsonArray { "query" }; + schema["properties"]!["all"] = new JsonObject + { + ["type"] = "boolean", ["default"] = false, + ["description"] = "Explicitly scan all indexed files with file/line safety caps. Specify either all=true or path, never both.", + }; + schema["properties"]!["path"]!["description"] = "Explicit file/path scope instead of all=true; accepts a string or array."; + schema["properties"]!["lineScanLimit"] = new JsonObject + { + ["type"] = "integer", ["minimum"] = 1, ["maximum"] = QueryCommandRunner.MaxFindLineScanLimit, + ["default"] = QueryCommandRunner.FindAllLineScanLimit, + ["description"] = "Maximum indexed lines per all=true scan page; may change when resuming a cursor. Requires all=true.", + }; + tools.Add(CreateToolDefinition("find", + "Bounded repository-wide literal or regex find over indexed files. Pass all=true or an explicit path. Resume next_cursor after row, file, line, or byte caps; inspect scan_complete, partial_result, authority, and recovery_guidance. Semantic filters require regex=true and reuse CLI classification. / 索引済みファイルを対象とする上限付きのリポジトリ横断検索。all=true または path を指定する。行数・ファイル数・走査行数・応答サイズの上限に達したら next_cursor で続行し、走査完了・部分結果・確定性・復旧案内を確認する。意味フィルターは regex=true が必要で CLI と同じ分類を使う。", + schema, ReadOnlyAnnotations())); + } +} diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 81d15e32f..d17468f78 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -25,6 +25,7 @@ private static JsonArray CreateToolCatalog() AddToolDefinitions(tools, CreateIndexMaintenanceTools()); AddToolDefinitions(tools, CreateAuditAndFeedbackTools()); + AddSemanticSearchTools(tools); AddProjectScopeProperties(tools); AddGraphSelectorSchemas(tools); AddCommonSchemaConstraints(tools); diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 403489ca0..3edd0410f 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -516,7 +516,7 @@ private static JsonObject BuildToolsListCatalogMeta(JsonArray tools, int returne ["capability_groups"] = new JsonObject { ["workspace_health"] = ToolNameArray(enabledToolNames, "status", "validate", "languages", "ping"), - ["discovery"] = ToolNameArray(enabledToolNames, "search", "map", "files", "symbols", "outline", "deps"), + ["discovery"] = ToolNameArray(enabledToolNames, "search", "find", "map", "files", "symbols", "outline", "deps"), ["symbol_navigation"] = ToolNameArray(enabledToolNames, "definition", "references", "callers", "callees", "analyze_symbol", "impact_analysis"), ["file_reading"] = ToolNameArray(enabledToolNames, "excerpt", "find_in_file", "read_resource"), ["batching"] = ToolNameArray(enabledToolNames, "batch_query"), diff --git a/src/CodeIndex/Mcp/McpToolFilter.cs b/src/CodeIndex/Mcp/McpToolFilter.cs index cc43bac1c..ec3b39918 100644 --- a/src/CodeIndex/Mcp/McpToolFilter.cs +++ b/src/CodeIndex/Mcp/McpToolFilter.cs @@ -48,6 +48,7 @@ private McpToolFilter(HashSet enabled) "callees", "symbols", "files", + "find", "find_in_file", "excerpt", "read_resource", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs index 10abf8dca..25c4417ac 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs @@ -10,7 +10,7 @@ public partial class McpServer { private static JsonObject? ValidateCommonListArguments(JsonNode? args) { - foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes", "visibility", "excludeVisibility", "includeSymbolKind", "excludeSymbolKind", "commits", "changedBetween", "files" }) + foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes", "visibility", "excludeVisibility", "includeSymbolKind", "excludeSymbolKind", "commits", "changedBetween", "files", "origin", "excludeOrigin", "resultKind" }) { if (ValidateStringListArgument(args, propertyName) is JsonObject error) return error; @@ -268,16 +268,16 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or "maxSymbolsPerFile" or "maxReferencesPerFile" or "debounce" or "staleAfterSeconds" or - "guardWindow" or "maxOutputBytes" or "maxResponseBytes" or "maxBytes" or "graphBudget" => "integer", + "guardWindow" or "maxOutputBytes" or "maxResponseBytes" or "maxBytes" or "graphBudget" or "lineScanLimit" => "integer", "check" or "excludeTests" or "includeGenerated" or "indexedOnly" or "rawQuery" or "noDedup" or "exactSubstring" or "tokenBoundary" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "lspCompatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" or "reverse" or "cycles" or "suppressNoise" or "summaryOnly" or "includeAllCycleNodes" or "groupPartialTypes" or "nodeMappings" or "config" or "logPath" or "updateCheck" or "rawKinds" or "includeQualifiedCommonCalls" or "includeMemberReads" or "orderBySize" or "rawBytes" or "byBucket" or "memoryTrace" or "watch" or - "estimateOnly" or "listRecipes" => "boolean", + "estimateOnly" or "listRecipes" or "all" or "excludeComments" or "excludeStrings" or "excludeFixtures" => "boolean", "project" or "capability" or "scopes" or "fields" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or - "commits" or "changedBetween" or "files" or + "commits" or "changedBetween" or "files" or "origin" or "excludeOrigin" or "resultKind" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "selector" or "uri" or "lang" or "kind" or "format" or "rankBy" or "sort" or "since" or "cursor" or "guardScope" or "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "snippetFocus" or @@ -297,7 +297,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, private static bool ToolAllowsStringOrArrayPath(string toolName) => toolName switch { "search" or "definition" or "references" or "callers" or "callees" or "symbols" or - "files" or "find_in_file" or "map" or "analyze_symbol" or "deps" or "impact_analysis" or + "files" or "find" or "find_in_file" or "map" or "analyze_symbol" or "deps" or "impact_analysis" or "validate" or "unused_symbols" or "symbol_hotspots" => true, _ => false, }; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs index 49acdfeab..961a121fe 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs @@ -109,7 +109,7 @@ private JsonObject CreateMcpCursorError( category: stale ? McpErrorEnvelope.CategoryIndexStale : McpErrorEnvelope.CategoryInvalidArgument, suggestion: stale ? $"The index changed after this {toolName} cursor was issued. Restart pagination without cursor." - : $"Use the exact next_cursor returned by the previous {toolName} page with unchanged filters, format, and limit.", + : $"Use the exact next_cursor returned by the previous {toolName} page with unchanged query, filters, and output mode. Only change page limits if the tool supports it.", retrySafe: stale, extraData: new JsonObject { @@ -135,7 +135,7 @@ private JsonObject CreateMcpCursorError( id, toolName, "cursor_query_mismatch", - $"cursor does not match this {toolName} query, filters, format, or limit.", + $"cursor does not match this {toolName} query or options.", stale: false); } if (!string.Equals(cursor.GenerationFingerprint, generationFingerprint, StringComparison.Ordinal)) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs new file mode 100644 index 000000000..1103d8add --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private const int DefaultFindMaxBytes = 65_536; + + private JsonNode ExecuteFindPage(JsonNode? id, DbReader reader, QueryCommandOptions options, + FindSemanticFilters? filters, string? cursor, int? lineScanLimit, int maxBytes, + bool contextTruncated, int? snippetLines, ArgumentAdjustmentCollector adjustments) + { + var cursorArgs = BuildMcpFindCursorArguments(options, filters, cursor); + try + { + var resume = JsonEnvelopeWrapper.GetStandaloneFindResume(cursorArgs, reader); + var effectiveLimit = options.Limit; + var byteLimited = false; + while (true) + { + reader.Cancellation.ThrowIfCancellationRequested(); + FindScanSummary scan; + List results; + int count; + int fileCount; + if (options.CountOnly) + { + var counted = reader.CountFindInFiles(options.Query!, options.Lang, + options.All ? null : options.PathPatterns, options.ExcludePaths, options.ExcludeTests, + options.Exact, options.FocusLine, options.FocusColumn, options.Regex, + options.All ? QueryCommandRunner.FindAllCandidateFileLimit : null, + options.All ? lineScanLimit ?? QueryCommandRunner.FindAllLineScanLimit : null, + useIndexedLiteralCandidates: options.All, + resumePath: resume.Path, resumeLine: resume.Line, resumeFileOrdinal: resume.FileOrdinal, + resumeMatchOrdinal: resume.MatchOrdinal, resumeByteOffset: resume.ByteOffset, + cancellationToken: reader.Cancellation, semanticFilters: filters); + scan = counted.Scan; + count = counted.Count; + fileCount = counted.FileCount; + results = []; + } + else + { + var found = reader.FindInFiles(options.Query!, effectiveLimit, options.Lang, + options.All ? null : options.PathPatterns, options.ExcludePaths, options.ExcludeTests, + options.ContextBefore, options.ContextAfter, options.Exact, options.MaxLineWidth, + options.FocusLine, options.FocusColumn, options.Regex, + options.All ? QueryCommandRunner.FindAllCandidateFileLimit : null, + options.All ? lineScanLimit ?? QueryCommandRunner.FindAllLineScanLimit : null, + useIndexedLiteralCandidates: options.All, + resumePath: resume.Path, resumeLine: resume.Line, resumeFileOrdinal: resume.FileOrdinal, + resumeMatchOrdinal: resume.MatchOrdinal, resumeByteOffset: resume.ByteOffset, + captureContinuation: true, cancellationToken: reader.Cancellation, semanticFilters: filters); + results = found.Results; + scan = found.Scan; + count = results.Count; + fileCount = results.Select(row => row.Path).Distinct(StringComparer.Ordinal).Count(); + } + + var next = QueryCommandRunner.BuildFindResumeCursor(cursorArgs, reader, scan); + var payload = new JsonObject + { + ["query"] = options.Query, ["path"] = PathEcho(options.PathPatterns), + ["excludeTests"] = options.ExcludeTests, ["before"] = options.ContextBefore, + ["after"] = options.ContextAfter, ["contextTruncated"] = contextTruncated, + ["maxLineWidth"] = options.MaxLineWidth, ["exact"] = options.Exact, ["regex"] = options.Regex, + ["count"] = count, ["fileCount"] = fileCount, + ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), + ["max_bytes"] = maxBytes, ["byte_limit_reached"] = byteLimited, + }; + if (snippetLines.HasValue) + payload["snippetLines"] = snippetLines.Value; + if (options.FocusLine.HasValue) + payload["focusLine"] = options.FocusLine.Value; + if (options.FocusColumn.HasValue) + payload["focusColumn"] = options.FocusColumn.Value; + QueryCommandRunner.AddFindTerminalScanFields(payload, scan, count, options.CountOnly, + options.CountOnly ? null : effectiveLimit, scan.ResultLimitReached, next.Cursor, next.ResultStableAt); + // A resumed page covers only its remaining scan segment, including the final page. + var authoritative = resume.Path is null && !scan.Truncated && !scan.ResultLimitReached && scan.UnknownOriginMatches == 0; + payload[options.CountOnly ? "authoritative_count" : "authoritative_rows"] = authoritative; + payload["total_count_authoritative"] = authoritative; + payload["truncated"] = scan.Truncated || scan.ResultLimitReached; + payload["more_available"] = scan.Truncated || scan.ResultLimitReached; + if (byteLimited) + { + payload["partial_result"] = true; + payload["truncation_reason"] = "max_bytes"; + } + if (next.Cursor is not null) + payload["recovery_guidance"] = "Pass next_cursor as cursor with the same query, scope, filters and countOnly mode; limit, maxBytes and lineScanLimit may change. Restart after indexing."; + if (count == 0) + AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); + var response = CreateToolResult(id, options.CountOnly ? $"Counted {count} match(es)." : $"Found {count} match(es) across {fileCount} file(s).", payload); + if (response["result"] is not null + && TryMeasureJsonUtf8BytesWithinLimit(payload, _jsonOptions, maxBytes, out _)) + return response; + + if (options.CountOnly || results.Count <= 1) + return CreateToolErrorResponse(id, "The find page and its continuation cannot fit the response byte budget.", + category: McpErrorEnvelope.CategoryInvalidArgument, retrySafe: true, + suggestion: "Increase maxBytes/server response budget or reduce before, after, snippetLines, or maxLineWidth. Retry the same cursor; no matches were consumed.", + extraData: new JsonObject { ["error_code"] = CommandErrorCodes.ResponseBudgetTooSmall, ["max_bytes"] = maxBytes, ["restart_required"] = false }); + + // Reuse the scanner from the original position with a smaller page. Its raw match + // ordinal and UTF-8 position remain correct even for zero-width/same-line matches. + effectiveLimit = Math.Max(1, results.Count / 2); + byteLimited = true; + } + } + catch (FindContinuationException ex) + { + return CreateMcpCursorError(id, "find", ex.Reason, ex.Message, stale: ex.Reason == "cursor_stale"); + } + catch (RegexMatchTimeoutException ex) when (options.Regex) + { + return CreateToolErrorResponse(id, RegexTimeoutPolicy.FormatFindTimeout(ex), + category: RegexTimeoutPolicy.RegexTimeoutCategory, suggestion: RegexTimeoutPolicy.McpFindTimeoutSuggestion, + retrySafe: true, extraData: new JsonObject + { + ["error_code"] = CommandErrorCodes.RegexMatchTimeout, + ["timeout_ms"] = ex.MatchTimeout.TotalMilliseconds, + }); + } + catch (ArgumentException) when (options.Regex) + { + return CreateToolErrorResponse(id, "invalid regular expression. Check regex syntax and retry."); + } + } + + private static string[] BuildMcpFindCursorArguments(QueryCommandOptions options, FindSemanticFilters? filters, string? cursor) + { + var args = new List { "--query=" + options.Query }; + void Value(string name, object? value) + { + if (value is not null) + args.Add(name + "=" + Convert.ToString(value, CultureInfo.InvariantCulture)); + } + void Flag(string name, bool enabled) { if (enabled) args.Add(name); } + Value("--lang", options.Lang); + foreach (var path in options.PathPatterns) Value("--path", path); + foreach (var path in options.ExcludePaths) Value("--exclude-path", path); + Flag("--all", options.All); + Flag("--regex", options.Regex); + Flag("--exact", options.Exact); + Flag("--count", options.CountOnly); + Flag("--exclude-tests", options.ExcludeTests); + Flag("--include-generated", options.IncludeGenerated); + Value("--before", options.ContextBefore); + Value("--after", options.ContextAfter); + Value("--max-line-width", options.MaxLineWidth); + Value("--focus-line", options.FocusLine); + Value("--focus-column", options.FocusColumn); + if (filters is not null) + { + foreach (var value in filters.Origins) Value("--origin", value); + foreach (var value in filters.ExcludedOrigins) Value("--exclude-origin", value); + foreach (var value in filters.ResultKinds) Value("--result-kind", value); + Flag("--exclude-comments", filters.ExcludeComments); + Flag("--exclude-strings", filters.ExcludeStrings); + Flag("--exclude-fixtures", filters.ExcludeFixtures); + } + Value("--cursor", cursor); + return [.. args]; + } +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs index e8df892d1..54239213d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs @@ -18,7 +18,11 @@ public partial class McpServer private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) { + if (ReadSemanticSearchFilters(id, args, out var semanticFilters) is { } semanticError) + return semanticError; var listRecipes = args?["listRecipes"]?.GetValue() ?? false; + if (listRecipes && semanticFilters is not null) + return CreateToolErrorResponse(id, "Semantic filters apply to recipe execution, not listRecipes."); if (listRecipes) return ExecuteSearchRecipeList(id); @@ -28,7 +32,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) var recipeName = recipeNode.GetValue(); if (string.IsNullOrWhiteSpace(recipeName)) return CreateToolErrorResponse(id, "'recipe' must be a non-empty search recipe name."); - return ExecuteSearchRecipe(id, args, recipeName.Trim()); + return ExecuteSearchRecipe(id, args, recipeName.Trim(), semanticFilters); } if (args?["auditScope"] is not null) @@ -51,7 +55,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) var rawQuery = args?["rawQuery"]?.GetValue() ?? false; SearchCursor? cursor = null; var cursorValue = args?["cursor"]?.GetValue(); - if (!string.IsNullOrWhiteSpace(cursorValue)) + if (semanticFilters is null && !string.IsNullOrWhiteSpace(cursorValue)) { if (!TryParseSearchCursor(cursorValue, out var parsedCursor)) return CreateToolErrorResponse(id, "'cursor' must be a search pagination cursor returned as `next_cursor` by a previous search response."); @@ -87,6 +91,19 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { + if (semanticFilters is not null) + return ExecuteSemanticSearchPage(id, args, reader, new QueryCommandOptions + { + Query = query, Limit = limit, Lang = lang, RawFts = rawQuery, + PathPatterns = pathPatterns ?? [], ExcludePaths = excludePaths, ExcludeTests = excludeTests, + NoDedup = !deduplicate, Since = since, Exact = exactSearch, TokenBoundary = tokenBoundary, + Prefix = prefix, GuardFilters = guardFilters, GuardWindow = guardWindow, GuardScope = guardScope, + SnippetLines = snippetLines, SnippetFocus = snippetFocus, MaxLineWidth = maxLineWidth, + CountOnly = countOnly, MatchOrigins = [.. semanticFilters.Origins], + ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], ResultKinds = [.. semanticFilters.ResultKinds], + ExcludeComments = semanticFilters.ExcludeComments, ExcludeStrings = semanticFilters.ExcludeStrings, + ExcludeFixtures = semanticFilters.ExcludeFixtures, + }, format, cursorValue, adjustments); if (countOnly) { List countResults; @@ -237,7 +254,7 @@ private JsonNode ExecuteSearchRecipeList(JsonNode? id) return CreateToolResult(id, $"Found {registry.Recipes.Count} search recipe(s).", payload); } - private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipeName) + private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipeName, FindSemanticFilters? semanticFilters) { var registry = SearchAuditRecipes.Load(); var recipe = registry.Recipes.FirstOrDefault(r => string.Equals(r.Name, recipeName, StringComparison.OrdinalIgnoreCase)); @@ -309,6 +326,43 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe out var queryPathPatterns, out var queryExcludePaths); var requiredPathPatterns = GetMcpSearchRecipeRequiredPathPatterns(requestedPathPatterns, recipeQuery); + if (semanticFilters is not null) + { + var semanticPage = QueryCommandRunner.ReadSemanticSearchRows(reader, new QueryCommandOptions + { + Query = recipeQuery.Query, Limit = limit, Lang = lang, + PathPatterns = queryPathPatterns ?? [], ExcludePaths = queryExcludePaths, ExcludeTests = excludeTests, + NoDedup = !deduplicate, Since = since, Exact = exact, TokenBoundary = tokenBoundary, + GuardFilters = guardFilters, GuardWindow = guardWindow, GuardScope = guardScope, + SnippetLines = snippetLines, MaxLineWidth = maxLineWidth, + MatchOrigins = [.. semanticFilters.Origins], ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], + ResultKinds = [.. semanticFilters.ResultKinds], ExcludeComments = semanticFilters.ExcludeComments, + ExcludeStrings = semanticFilters.ExcludeStrings, ExcludeFixtures = semanticFilters.ExcludeFixtures, + }, limit + 1, recipeQuery, requiredPathPatterns); + var semanticRows = semanticPage.Rows.Take(limit).ToList(); + QueryCommandRunner.ApplyXmlSettingsAuditClassifications(reader, recipeQuery, semanticRows); + QueryCommandRunner.MarkSearchRecipeQueryExecuted(scope, recipeQuery.Name); + total += semanticRows.Count; + queryResults.Add(new JsonObject + { + ["name"] = recipeQuery.Name, ["query"] = recipeQuery.Query, + ["description"] = recipeQuery.Description, + ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), + ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, + ["exact_substring"] = exact, ["token_boundary"] = tokenBoundary, + ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), + ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), + ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), + ["count"] = semanticRows.Count, + ["top_files"] = BuildTopFileHistogram(semanticRows, row => row.Path), + ["truncated"] = !semanticPage.ScanComplete || semanticPage.Rows.Count > limit, + ["origin_classification_complete"] = semanticPage.ClassificationComplete, + ["candidate_scan_complete"] = semanticPage.ScanComplete, + ["total_count_authoritative"] = semanticPage.ScanComplete && semanticPage.ClassificationComplete, + ["results"] = ToJsonArray(semanticRows), + }); + continue; + } List results; try { diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs index 4c5fee348..c4ccb3e9e 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -440,7 +440,7 @@ private static void TrimExcerptCoordinatePayload(JsonObject payload, int retaine private readonly record struct ExcerptPayloadSpan(int SourceLine, int SourceStartColumn, int SourceEndColumn); - private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) + private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args, bool allowAll = false) { if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); @@ -448,14 +448,17 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); var pathPatterns = ReadScopedPathList(args); - if (pathPatterns == null || pathPatterns.Count == 0) + var all = allowAll && (args?["all"]?.GetValue() ?? false); + if (all && (pathPatterns is { Count: > 0 } || HasBlankPathFilter(args))) + return CreateToolErrorResponse(id, "find accepts either path or all=true, not both."); + if (!all && (pathPatterns == null || pathPatterns.Count == 0)) return CreateToolErrorResponse(id, HasBlankPathFilter(args) ? "Parameter \"path\" cannot be empty or whitespace-only" - : "Missing required parameter: path"); + : allowAll ? "find requires path or all=true." : "Missing required parameter: path"); var adjustments = new ArgumentAdjustmentCollector(); var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; var beforeValue = ReadOptionalIntArgument(args, "before"); @@ -489,64 +492,31 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) return maxLineWidthError; var exact = args?["exact"]?.GetValue() ?? false; var regex = args?["regex"]?.GetValue() ?? false; - - return WithDbReader(id, args, reader => + if (ReadSemanticSearchFilters(id, args, out var semanticFilters) is { } semanticError) + return semanticError; + if (semanticFilters is not null && (!regex || semanticFilters.ResultKinds.Any(kind => kind is "declaration" or "call_site"))) + return CreateToolErrorResponse(id, "find semantic filters require regex=true; resultKind supports origins and identifier only."); + var lineScanLimit = ReadOptionalIntArgument(args, "lineScanLimit"); + if (lineScanLimit.HasValue && !all) + return CreateToolErrorResponse(id, "lineScanLimit requires all=true."); + if (lineScanLimit is <= 0 or > QueryCommandRunner.MaxFindLineScanLimit) + return CreateToolErrorResponse(id, $"lineScanLimit must be between 1 and {QueryCommandRunner.MaxFindLineScanLimit}."); + var maxBytes = ReadOptionalIntArgument(args, "maxBytes") ?? DefaultFindMaxBytes; + if (maxBytes <= 0 || maxBytes > MaxConfiguredResponseBytes) + return CreateToolErrorResponse(id, $"maxBytes must be between 1 and {MaxConfiguredResponseBytes}."); + var cursor = args?["cursor"]?.GetValue(); + if (cursor?.Length > MaxMcpQueryCursorCharacters) + return CreateMcpCursorError(id, allowAll ? "find" : "find_in_file", "cursor_malformed", "Find cursor is too long.", stale: false); + var options = new QueryCommandOptions { - List results; - try - { - results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex).Results; - } - catch (RegexMatchTimeoutException ex) when (regex) - { - return CreateToolErrorResponse( - id, - RegexTimeoutPolicy.FormatFindTimeout(ex), - category: RegexTimeoutPolicy.RegexTimeoutCategory, - suggestion: RegexTimeoutPolicy.McpFindTimeoutSuggestion, - retrySafe: true, - extraData: new JsonObject - { - ["error_code"] = CommandErrorCodes.RegexMatchTimeout, - ["timeout_ms"] = ex.MatchTimeout.TotalMilliseconds, - }); - } - catch (ArgumentException) when (regex) - { - return CreateToolErrorResponse(id, "invalid regular expression. Check regex syntax and retry."); - } - var structured = new JsonObject - { - ["query"] = query, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["before"] = before, - ["after"] = after, - ["contextTruncated"] = contextTruncated, - ["maxLineWidth"] = maxLineWidth, - ["exact"] = exact, - ["regex"] = regex, - ["count"] = results.Count, - ["fileCount"] = results.Select(r => r.Path).Distinct().Count(), - ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), - }; - if (snippetLinesValue.HasValue) - structured["snippetLines"] = snippetLinesValue.Value; - if (focusLine.HasValue) - structured["focusLine"] = focusLine.Value; - if (focusColumn.HasValue) - structured["focusColumn"] = focusColumn.Value; - if (results.Count == 0) - { - AddFreshnessHint(structured, reader); - adjustments.ApplyTo(structured); - return CreateToolResult(id, "No matches found.", structured); - } - - var fileCount = structured["fileCount"]!.GetValue(); - adjustments.ApplyTo(structured); - return CreateToolResult(id, $"Found {ConsoleUi.Counted(results.Count, "in-file match", "in-file matches")} across {ConsoleUi.Counted(fileCount, "file")}.", structured); - }); + Query = query, Limit = limit, Lang = lang, All = all, PathPatterns = pathPatterns ?? [], + ExcludePaths = excludePaths, ExcludeTests = excludeTests, IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false, + ContextBefore = before, ContextAfter = after, Exact = exact, Regex = regex, + MaxLineWidth = maxLineWidth, FocusLine = focusLine, FocusColumn = focusColumn, + CountOnly = ReadCountOnly(args), + }; + return WithDbReader(id, args, reader => ExecuteFindPage(id, reader, options, semanticFilters, + cursor, lineScanLimit, maxBytes, contextTruncated, snippetLinesValue, adjustments)); } private static int ClampContextLines(int value) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs b/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs new file mode 100644 index 000000000..99b4339cc --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private static bool HasSemanticSearchArguments(JsonNode? args) + => new[] { "origin", "excludeOrigin", "resultKind", "excludeComments", "excludeStrings", "excludeFixtures" } + .Any(name => args?[name] is not null); + + private JsonNode? ReadSemanticSearchFilters(JsonNode? id, JsonNode? args, out FindSemanticFilters? filters) + { + filters = null; + if (!HasSemanticSearchArguments(args)) + return null; + var origins = new List(); + var exclusions = new List(); + var kinds = new List(); + string? error = null; + foreach (var value in ReadStringList(args, "origin")) + QueryCommandRunner.AddSearchMatchOrigins("--origin", value, origins, message => error ??= message); + foreach (var value in ReadStringList(args, "excludeOrigin")) + QueryCommandRunner.AddSearchMatchOrigins("--exclude-origin", value, exclusions, message => error ??= message); + foreach (var value in ReadStringList(args, "resultKind")) + QueryCommandRunner.AddSearchResultKinds(value, kinds, message => error ??= message); + if (error is not null) + return CreateToolErrorResponse(id, error); + var excludeComments = args?["excludeComments"]?.GetValue() ?? false; + var excludeStrings = args?["excludeStrings"]?.GetValue() ?? false; + var excludeFixtures = args?["excludeFixtures"]?.GetValue() ?? false; + if (origins.Count == 0 && exclusions.Count == 0 && kinds.Count == 0 + && !excludeComments && !excludeStrings && !excludeFixtures) + return null; + filters = new(origins, exclusions, kinds, + excludeComments, excludeStrings, excludeFixtures); + return null; + } + + private JsonNode ExecuteSemanticSearchPage(JsonNode? id, JsonNode? args, DbReader reader, + QueryCommandOptions options, string format, string? cursorValue, ArgumentAdjustmentCollector adjustments) + { + if (options.CountOnly && cursorValue is not null) + return CreateToolErrorResponse(id, "countOnly semantic search does not accept cursor; omit cursor to count the bounded candidate set."); + McpQueryCursor? cursor = null; + if (cursorValue is not null && !TryParseMcpQueryCursor(cursorValue, out cursor)) + return CreateMcpCursorError(id, "search", "cursor_malformed", "Invalid semantic search cursor.", stale: false); + var fingerprint = BuildMcpQueryFingerprint("search-semantic", 0, format, + (args?.AsObject() ?? new JsonObject()) + .Where(property => property.Key is not ("cursor" or "limit")) + .Select(property => new KeyValuePair(property.Key, property.Value?.ToJsonString()))); + var generation = BuildMcpGenerationFingerprint(reader, includeFoldState: true); + if (ValidateMcpQueryCursor(id, "search", cursor, fingerprint, generation.Fingerprint, MaxMcpPaginationOffset) is { } cursorError) + return cursorError; + var offset = cursor?.Offset ?? 0; + var requested = options.CountOnly ? MaxMcpPaginationOffset + 1 : Math.Min(MaxMcpPaginationOffset + 1, offset + options.Limit + 1); + var page = QueryCommandRunner.ReadSemanticSearchRows(reader, options, requested); + var total = page.Rows.Count; + var rows = options.CountOnly ? page.Rows : page.Rows.Skip(offset).Take(options.Limit).ToList(); + var payload = options.CountOnly + ? BuildCountOnlyPayload(total, page.ScanComplete ? total : null, !page.ScanComplete, rows, row => row.Path) + : new JsonObject { ["count"] = rows.Count, ["results"] = ToJsonArray(rows) }; + payload["query"] = options.Query; + payload["path"] = PathEcho(options.PathPatterns); + payload["excludeTests"] = options.ExcludeTests; + payload["origin_classification_complete"] = page.ClassificationComplete; + payload["candidate_scan_complete"] = page.ScanComplete; + payload["partial_result"] = !page.ScanComplete || !page.ClassificationComplete; + var authoritative = page.ScanComplete && page.ClassificationComplete; + if (options.CountOnly) + { + payload["authoritative_count"] = authoritative; + payload["total_count_authoritative"] = authoritative; + } + else + { + AddMcpPaginationEnvelope(payload, total, rows.Count, offset, options.Limit, fingerprint, generation, authoritative); + if (offset + rows.Count >= MaxMcpPaginationOffset && total > offset + rows.Count) + { + payload["next_cursor"] = null; + payload["pagination_window_exhausted"] = true; + } + if (format == "compact") + ApplyCompactResults(payload, rows, row => row.Path, + row => row.MatchLines.Count > 0 ? row.MatchLines[0] : row.ChunkStartLine); + } + if (!authoritative) + payload["recovery_guidance"] = "Inspect unknown matches without semantic exclusions; narrow the path/query when candidate or pagination bounds are reached. Filtered absence is not authoritative."; + AddSameSymbolGuardContext(payload, options.GuardFilters, options.GuardScope, options.GuardWindow); + AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); + return CreateToolResult(id, $"Found {rows.Count} semantic search result(s).", payload); + } +} diff --git a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs index 501bf1d69..0419f161c 100644 --- a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs +++ b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs @@ -30,7 +30,7 @@ public static JsonObject Create(string toolName) "files" => RowsProperties(), "excerpt" => ExcerptProperties(), "read_resource" => ReadResourceProperties(), - "find_in_file" => QueryRowsProperties(), + "find" or "find_in_file" => QueryRowsProperties(), "map" => MapProperties(), "analyze_symbol" => AnalyzeSymbolProperties(), "impact_analysis" => ImpactAnalysisProperties(), @@ -141,7 +141,7 @@ private static JsonArray RequiredToolProperties(string toolName) { "search" => StringArray(), "definition" or "references" or "callers" or "callees" - or "symbols" or "files" or "find_in_file" => StringArray("count", "results"), + or "symbols" or "files" or "find" or "find_in_file" => StringArray("count", "results"), "excerpt" => StringArray("path", "totalLines"), "read_resource" => StringArray("resource", "_meta"), "map" => StringArray("fileCount"), diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index cd8d4d769..bc171c1bb 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -667,8 +667,10 @@ public class ExcerptRecoveryHint public class FileFindResult { + [JsonPropertyName("match_facets")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? MatchFacets { get; set; } + [JsonPropertyName("result_kinds")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? ResultKinds { get; set; } [JsonPropertyName("api_version")] diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 13554c4c6..840e12e75 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -45,6 +45,39 @@ public void Constructor_DoesNotInitializeDatabase() Assert.False(_fixture.IsValueCreated); } + [Fact] + public async Task HttpTransport_SemanticFindAndSearchShareContinuationContract_Issue5349() + { + TestProjectHelper.InsertIndexedFile(_dbPath, "src/http5349.cs", "csharp", "// Needle5349\nNeedle5349(); Needle5349();\n"); + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + using (var initialize = await harness.PostJsonAsync("""{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}""")) + Assert.Equal(HttpStatusCode.OK, initialize.StatusCode); + using (var discovery = await harness.PostJsonAsync("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"format":"full","names":["find"]}}""")) + { + var catalog = JsonNode.Parse(await discovery.Content.ReadAsStringAsync())!; + Assert.Equal("find", catalog["result"]!["tools"]![0]!["name"]!.GetValue()); + } + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"find","arguments":{"query":"Needle5349","all":true,"regex":true,"origin":"code","limit":1}}}""")!; + var columns = new List(); + for (var page = 0; page < 2; page++) + { + using var response = await harness.PostJsonAsync(request.ToJsonString()); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + Assert.Null(body["error"]); + var payload = body["result"]!["structuredContent"]!; + columns.Add(payload["results"]![0]!["column"]!.GetValue()); + if (page == 0) + request["params"]!["arguments"]!["cursor"] = payload["next_cursor"]!.DeepClone(); + else + Assert.Null(payload["next_cursor"]); + } + Assert.Equal(new[] { 1, 15 }, columns); + using var search = await harness.PostJsonAsync("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"query":"Needle5349","origin":"comment"}}}"""); + var found = JsonNode.Parse(await search.Content.ReadAsStringAsync())!; + Assert.Equal(1, found["result"]!["structuredContent"]!["count"]!.GetValue()); + } + private long InsertIndexedFile(string path, string content, bool splitIntoProductionChunks = false) { var normalized = content.Replace("\r\n", "\n", StringComparison.Ordinal); diff --git a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs new file mode 100644 index 000000000..987b3faf0 --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs @@ -0,0 +1,281 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Mcp; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public partial class McpServerTests +{ + [Fact] + public void ToolsCall_SemanticFiltersMatchCliRowsAndCounts_Issue5349() + { + InsertIndexedFile("audit5349/src/a.cs", "csharp", "// Needle5349\nvar s = \"Needle5349\"; Needle5349(); Needle5349();\n"); + InsertIndexedFile("audit5349/src/b.cs", "csharp", "Needle5349();\n"); + InsertIndexedFile("audit5349/tests/Fixture.cs", "csharp", "var fixture = \"Needle5349\";\n"); + InsertIndexedFile("audit5349/unknown.txt", "text", "Needle5349\n"); + InsertIndexedFile("audit5349/unknown.cs", "csharp", new string('\n', 4096) + "Needle5349();\n"); + foreach (var (json, cliFilters) in new (string, string[])[] + { + ("{\"origin\":\"code\"}", ["--origin", "code"]), + ("{\"origin\":\"comment\"}", ["--origin", "comment"]), + ("{\"origin\":\"string_literal\"}", ["--origin", "string_literal"]), + ("{\"origin\":\"unknown\"}", ["--origin", "unknown"]), + ("{\"origin\":[\"code\",\"comment\"]}", ["--origin", "code,comment"]), + ("{\"excludeOrigin\":\"code,comment\"}", ["--exclude-origin", "code,comment"]), + ("{\"resultKind\":\"identifier\"}", ["--result-kind", "identifier"]), + ("{\"excludeComments\":true,\"excludeStrings\":true}", ["--exclude-comments", "--exclude-strings"]), + ("{\"excludeFixtures\":true}", ["--exclude-fixtures"]), + }) + { + foreach (var tool in new[] { "search", "find_in_file", "find" }) + { + var args = JsonNode.Parse(json)!.AsObject(); + args["query"] = "Needle5349"; + args["path"] = "audit5349/"; + args["limit"] = 100; + var command = tool == "search" ? "search" : "find"; + if (command == "find") args["regex"] = true; + var payload = Payload5349(Call5349(tool, args)); + string[] cliArgs = [command, "Needle5349", "--path", "audit5349/", "--db", _dbPath, + "--json", "--limit", "100", .. command == "find" ? new[] { "--regex" } : Array.Empty(), .. cliFilters]; + var (_, output, _) = CaptureConsole(() => ProgramRunner.Run(cliArgs, JsonOptions, "test")); + var cliRows = output.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonNode.Parse(line)!).Where(row => row["path"] is JsonValue).ToList(); + var mcpRows = payload["results"]!.AsArray(); + Assert.Equal(cliRows.Select(row => Location5349(row, command)).Order(), + mcpRows.Select(row => Location5349(row!, command)).Order()); + args["countOnly"] = true; + var counted = Payload5349(Call5349(tool, args)); + var (_, countOutput, _) = CaptureConsole(() => ProgramRunner.Run([.. cliArgs, "--count"], JsonOptions, "test")); + var cliCount = JsonNode.Parse(countOutput)!; + Assert.Equal(cliCount["count"]!.GetValue(), counted["count"]!.GetValue()); + } + } + var unknown = Payload5349(Call5349("find", new JsonObject + { ["query"] = "Needle5349", ["all"] = true, ["regex"] = true, ["origin"] = "code" })); + Assert.Equal(3, unknown["count"]!.GetValue()); + Assert.Equal(2, unknown["unknown_origin_matches"]!.GetValue()); + Assert.False(unknown["authoritative_rows"]!.GetValue()); + Assert.True(unknown["partial_result"]!.GetValue()); + } + + [Fact] + public void ToolsCall_FindResumesScanAndByteCapsWithoutLostZeroWidthMatches_Issue5349() + { + InsertIndexedFile("audit5349/a.cs", "csharp", "// Needle5349\nNeedle5349(); Needle5349();\nNeedle5349();\n"); + InsertIndexedFile("audit5349/b.cs", "csharp", "var s = \"Needle5349\"; Needle5349();\nNeedle5349();\n"); + foreach (var query in new[] { "Needle5349", "(?=Needle5349)" }) + { + var args = new JsonObject { ["query"] = query, ["all"] = true, ["regex"] = true, + ["origin"] = "code", ["limit"] = 1, ["lineScanLimit"] = 1 }; + var seen = new List(); + string? cursor = null; + for (var page = 0; page < 100; page++) + { + if (cursor is not null) args["cursor"] = cursor; + var payload = Payload5349(Call5349("find", args)); + if (page > 0) Assert.False(payload["authoritative_rows"]!.GetValue()); + foreach (var row in payload["results"]!.AsArray()) + { + seen.Add(Location5349(row!, "find")); + Assert.Equal("code", row!["match_facets"]![0]!["origin"]!.GetValue()); + if (query[0] == '(') Assert.Equal(0, row["length"]!.GetValue()); + } + cursor = payload["next_cursor"]?.GetValue(); + Assert.Equal(cursor is not null, payload["has_more"]!.GetValue()); + if (cursor is null) break; + args["limit"] = 2; + args["lineScanLimit"] = 2; + } + Assert.Null(cursor); + Assert.Equal(5, seen.Count); + Assert.Equal(5, seen.Distinct().Count()); + } + + InsertIndexedFile("audit5349/wide.cs", "csharp", string.Join('\n', Enumerable.Repeat("Needle5349(); " + new string('x', 400), 8))); + var wideArgs = new JsonObject { ["query"] = "Needle5349", ["path"] = "audit5349/wide.cs", ["regex"] = true, + ["origin"] = "code", ["limit"] = 8, ["maxBytes"] = 3000 }; + var wideRows = new List(); + string? next = null; + for (var page = 0; page < 10; page++) + { + if (next is not null) wideArgs["cursor"] = next; + var payload = Payload5349(Call5349("find", wideArgs)); + Assert.True(Encoding.UTF8.GetByteCount(payload.ToJsonString()) <= 3000, payload.ToJsonString()); + wideRows.AddRange(payload["results"]!.AsArray().Select(row => row!["line"]!.GetValue())); + next = payload["next_cursor"]?.GetValue(); + if (next is null) break; + } + Assert.Null(next); + Assert.Equal(Enumerable.Range(1, 8), wideRows); + wideArgs.Remove("cursor"); + wideArgs["maxBytes"] = 1; + var tooSmall = Call5349("find", wideArgs); + Assert.Contains(CommandErrorCodes.ResponseBudgetTooSmall, tooSmall.ToJsonString(), StringComparison.Ordinal); + Assert.DoesNotContain("next_cursor", tooSmall.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public void ToolsCall_SemanticSearchAndFindRejectChangedCursors_Issue5349() + { + for (var i = 0; i < 4; i++) + InsertIndexedFile($"audit5349/{i}.cs", "csharp", "Needle5349();\n"); + foreach (var tool in new[] { "search", "find", "find_in_file" }) + { + var args = new JsonObject { ["query"] = "Needle5349", ["path"] = "audit5349/", ["origin"] = "code", ["limit"] = 1 }; + if (tool != "search") args["regex"] = true; + var first = Payload5349(Call5349(tool, args)); + var cursor = first["next_cursor"]!.GetValue(); + var seen = new List { Location5349(first["results"]![0]!, tool == "search" ? "search" : "find") }; + args["cursor"] = cursor; + var second = Payload5349(Call5349(tool, args)); + seen.Add(Location5349(second["results"]![0]!, tool == "search" ? "search" : "find")); + Assert.Equal(2, seen.Distinct().Count()); + foreach (var (key, value) in new (string, JsonNode)[] + { + ("origin", JsonValue.Create("comment")), ("excludeOrigin", JsonValue.Create("string_literal")), + ("excludeFixtures", JsonValue.Create(true)), ("path", JsonValue.Create("audit5349/0.cs")), + ("excludeTests", JsonValue.Create(true)), ("includeGenerated", JsonValue.Create(true)), + }) + { + var changed = args.DeepClone().AsObject(); + changed[key] = value; + Assert.Contains("cursor_", Call5349(tool, changed).ToJsonString(), StringComparison.Ordinal); + } + InsertIndexedFile($"audit5349/changed-{tool}.cs", "csharp", "Needle5349();\n"); + Assert.Contains("cursor_stale", Call5349(tool, args).ToJsonString(), StringComparison.Ordinal); + } + } + + [Fact] + public void ToolsCall_FindCountCapsAndSemanticRecipeBatchKeepMetadata_Issue5349() + { + InsertIndexedFile("audit5349/a.cs", "csharp", "info.ArgumentList.Add(value);\ninfo.ArgumentList.Add(value);\n"); + InsertIndexedFile("audit5349/b.cs", "csharp", "// ArgumentList\n"); + var args = new JsonObject { ["query"] = "ArgumentList", ["all"] = true, ["regex"] = true, + ["origin"] = "code", ["countOnly"] = true, ["lineScanLimit"] = 1 }; + var count = 0; + string? cursor = null; + for (var page = 0; page < 100; page++) + { + if (cursor is not null) args["cursor"] = cursor; + var payload = Payload5349(Call5349("find", args)); + count += payload["count"]!.GetValue(); + if (page > 0) Assert.False(payload["authoritative_count"]!.GetValue()); + cursor = payload["next_cursor"]?.GetValue(); + if (cursor is null) break; + } + Assert.Null(cursor); + Assert.Equal(2, count); + var recipe = Payload5349(Call5349("search", new JsonObject + { ["recipe"] = "dogfood-risk-patterns", ["path"] = "audit5349/", ["origin"] = "code", ["limit"] = 10 })); + var child = recipe["queries"]!.AsArray().Single(q => q!["name"]!.GetValue() == "process-argument-list")!; + Assert.Equal(2, child["count"]!.GetValue()); + Assert.True(child["origin_classification_complete"]!.GetValue()); + var batch = Call5349("batch_query", new JsonObject { ["queries"] = new JsonArray + { + new JsonObject { ["tool"] = "find", ["arguments"] = new JsonObject + { ["query"] = "ArgumentList", ["all"] = true, ["regex"] = true, ["origin"] = "code", ["lineScanLimit"] = 1 } }, + new JsonObject { ["tool"] = "search", ["arguments"] = new JsonObject + { ["query"] = "ArgumentList", ["origin"] = "code", ["path"] = "audit5349/" } }, + } }); + Assert.Contains("next_cursor", batch.ToJsonString(), StringComparison.Ordinal); + Assert.Contains("scan_complete", batch.ToJsonString(), StringComparison.Ordinal); + Assert.DoesNotContain("unknown_argument", batch.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public void ToolsCall_FindDiscoveryAndInvalidArgumentsStaySynchronized_Issue5349() + { + var list = _server.HandleMessage(JsonNode.Parse("""{"jsonrpc":"2.0","id":5349,"method":"tools/list","params":{"format":"full","names":["search","find","find_in_file"]}}""")!)!; + var tools = list["result"]!["tools"]!.AsArray(); + Assert.Equal(3, tools.Count); + foreach (var tool in tools) + { + Assert.NotNull(tool!["inputSchema"]!["properties"]!["origin"]); + Assert.NotNull(tool["outputSchema"]); + } + var scoped = tools.Single(tool => tool!["name"]!.GetValue() == "find_in_file")!; + Assert.Contains(scoped["inputSchema"]!["required"]!.AsArray(), value => value!.GetValue() == "path"); + var defaults = Payload5349(Call5349("find_in_file", new JsonObject + { ["query"] = "Read", ["path"] = "src/", ["excludeComments"] = false, + ["excludeStrings"] = false, ["excludeFixtures"] = false, ["origin"] = new JsonArray() })); + Assert.Null(defaults["origin_classification_complete"]); + foreach (var (tool, json) in new[] + { + ("find_in_file", "{\"query\":\"Read\"}"), + ("find", "{\"query\":\"Read\"}"), + ("find", "{\"query\":\"Read\",\"all\":true,\"path\":\"src/\"}"), + ("find", "{\"query\":\"Read\",\"path\":\"src/\",\"lineScanLimit\":1}"), + ("find", "{\"query\":\"Read\",\"all\":true,\"lineScanLimit\":10000001}"), + ("find", "{\"query\":\"Read\",\"all\":true,\"origin\":\"code\"}"), + ("find", "{\"query\":\"Read\",\"all\":true,\"regex\":true,\"resultKind\":\"call_site\"}"), + ("search", "{\"query\":\"Read\",\"origin\":\"invalid\"}"), + ("search", "{\"query\":\"Read\",\"origin\":[\"code\",123]}"), + ("search", "{\"query\":\"Read\",\"excludeFixtures\":1}"), + ("find", "{\"query\":\"(\",\"all\":true,\"regex\":true}"), + ("find", "{\"query\":\"Read\",\"all\":true,\"cursor\":\"bad\"}"), + }) + { + var response = Call5349(tool, JsonNode.Parse(json)!.AsObject()); + Assert.True(response["error"] is not null || response["result"]?["isError"]?.GetValue() == true, response.ToJsonString()); + } + } + + [Fact] + public async Task ToolsCall_FindTimeoutAndRequestCancellationDoNotIssueCursors_Issue5349() + { + InsertIndexedFile("audit5349/slow.cs", "csharp", new string('a', 100000) + "!"); + try + { + DbReader.FindRegexMatchTimeoutForTesting = TimeSpan.FromMilliseconds(1); + foreach (var tool in new[] { "find", "find_in_file" }) + { + var response = Call5349(tool, new JsonObject + { ["query"] = "(a+)+$", ["regex"] = true, ["path"] = "audit5349/slow.cs", ["origin"] = "code" }); + Assert.Contains(CommandErrorCodes.RegexMatchTimeout, response.ToJsonString(), StringComparison.Ordinal); + Assert.DoesNotContain("next_cursor", response.ToJsonString(), StringComparison.Ordinal); + } + } + finally { DbReader.FindRegexMatchTimeoutForTesting = null; } + + InsertIndexedFile("audit5349/cancel.cs", "csharp", "Needle5349();\nNeedle5349();\n"); + using var cancel = new CancellationTokenSource(); + var scanned = 0; + try + { + DbReader.FindLineScannedForTesting = () => { scanned++; cancel.Cancel(); }; + using var server = new McpServer(_dbPath, "test", dbPathExplicit: true); + var transport = new QueuedFrameTransport( + """{"jsonrpc":"2.0","id":5349,"method":"tools/call","params":{"name":"find","arguments":{"query":"Needle5349","path":"audit5349/cancel.cs","regex":true,"origin":"code"}}}"""); + await server.RunAsync(transport, cancel.Token).WaitAsync(TestDeterminism.DefaultTimeout); + Assert.True(scanned > 0); + Assert.All(transport.WrittenFrames, frame => Assert.DoesNotContain("next_cursor", frame ?? string.Empty, StringComparison.Ordinal)); + } + finally { DbReader.FindLineScannedForTesting = null; } + var recovered = Payload5349(Call5349("find", new JsonObject + { ["query"] = "Needle5349", ["path"] = "audit5349/cancel.cs", ["regex"] = true, ["origin"] = "code" })); + Assert.Equal(2, recovered["count"]!.GetValue()); + } + + private JsonNode Call5349(string tool, JsonObject args) => _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", ["id"] = 5349, ["method"] = "tools/call", + ["params"] = new JsonObject { ["name"] = tool, ["arguments"] = args.DeepClone() }, + })!; + + private static JsonNode Payload5349(JsonNode response) + { + Assert.Null(response["error"]); + Assert.False(response["result"]?["isError"]?.GetValue() ?? false, response.ToJsonString()); + return response["result"]!["structuredContent"]!; + } + + private static string Location5349(JsonNode row, string command) + => row["path"]!.GetValue() + ":" + (command == "search" + ? (row["match_lines"] ?? row["matchLines"])?.ToJsonString() : row["line"] + ":" + row["column"] + ":" + row["length"]); +} diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index 048a1335a..73650433f 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -102,7 +102,7 @@ public void ToolsList_EachToolPublishesSchemaAndExampleContract() 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(); + var tools = ReadAllToolsListPages(response); Assert.Equal(McpToolFilter.KnownToolNames.Count, tools.Count); foreach (var tool in tools) { @@ -141,7 +141,10 @@ public void ToolsList_DefaultCatalogIsAgentSafeAndPointsToFullDefinitions_Issues var compactTools = compactResult["tools"]!.AsArray(); var fullTools = fullResponse["result"]!["tools"]!.AsArray(); - Assert.Equal(McpToolFilter.KnownToolNames.Count, compactTools.Count); + Assert.Equal(Math.Min(McpServer.DefaultToolsListPageSize, McpToolFilter.KnownToolNames.Count), compactTools.Count); + Assert.Equal( + McpToolFilter.KnownToolNames.Order(StringComparer.Ordinal), + ReadAllToolsListPages(compactResponse).Select(tool => tool!["name"]!.GetValue()).Order(StringComparer.Ordinal)); Assert.Equal( fullTools.Select(tool => tool!["name"]!.GetValue()), compactTools.Select(tool => tool!["name"]!.GetValue())); @@ -312,7 +315,7 @@ public void ToolsList_ReturnsAllKnownTools() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; var response = _server.HandleMessage(request)!; - var tools = response["result"]!["tools"]!.AsArray(); + var tools = ReadAllToolsListPages(response); Assert.Equal(McpToolFilter.KnownToolNames.Count, tools.Count); var names = tools.Select(t => t!["name"]!.GetValue()).ToList(); @@ -324,6 +327,7 @@ public void ToolsList_ReturnsAllKnownTools() Assert.Contains("callees", names); Assert.Contains("symbols", names); Assert.Contains("files", names); + Assert.Contains("find", names); Assert.Contains("find_in_file", names); Assert.Contains("excerpt", names); Assert.Contains("read_resource", names); @@ -874,7 +878,7 @@ public void ToolsList_KnownToolNamesMatchAdvertisedTools_Issue3829() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; var response = _server.HandleMessage(request)!; - var advertised = response["result"]!["tools"]!.AsArray() + var advertised = ReadAllToolsListPages(response) .Select(tool => tool!["name"]!.GetValue()) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); @@ -885,6 +889,24 @@ public void ToolsList_KnownToolNamesMatchAdvertisedTools_Issue3829() Assert.Equal(known, advertised); } + private JsonArray ReadAllToolsListPages(JsonNode response) + { + var result = response["result"]!; + var tools = result["tools"]!.DeepClone().AsArray(); + while (result["nextCursor"]?.GetValue() is { } cursor) + { + result = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", ["id"] = 100, ["method"] = "tools/list", + ["params"] = new JsonObject { ["cursor"] = cursor }, + })!["result"]!; + foreach (var tool in result["tools"]!.AsArray()) + tools.Add(tool!.DeepClone()); + Assert.True(tools.Count <= McpToolFilter.KnownToolNames.Count); + } + return tools; + } + [Fact] public void ToolsList_FilteredByDenyList_HidesDeniedTools() { diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index a362d9d4a..95bfb759a 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -437,8 +437,19 @@ private static JsonObject GetToolsListResult(McpToolFilter? filter = null, bool var response = server.HandleMessage(request) ?? throw new InvalidOperationException("tools/list returned no response."); - return response["result"]?.AsObject() + var result = response["result"]?.AsObject() ?? throw new InvalidOperationException("tools/list response did not contain a result object."); + var page = result; + while (page["nextCursor"]?.GetValue() is { } cursor) + { + request["params"] = new JsonObject { ["cursor"] = cursor }; + page = server.HandleMessage(request)!["result"]!.AsObject(); + foreach (var tool in page["tools"]!.AsArray()) + result["tools"]!.AsArray().Add(tool!.DeepClone()); + Assert.True(result["tools"]!.AsArray().Count <= McpToolFilter.KnownToolNames.Count); + } + result.Remove("nextCursor"); + return result; } private static Dictionary GetAdvertisedTools(bool full = false) From 8485624dcde4f05433d01c6a49c1860642a97780 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 13 Sep 2026 01:30:36 +0900 Subject: [PATCH 2/4] Preserve semantic MCP count units and incomplete coverage (#5349) --- TESTING_GUIDE.md | 4 + changelog.d/unreleased/5349.added.md | 4 +- docs/find-scan-controls.md | 14 +++ .../Cli/QueryCommandRunner.SearchResults.cs | 15 ++- .../Mcp/McpToolHandlers.Query.Search.cs | 80 ++++++++------- .../Mcp/McpToolHandlers.SearchSemantics.cs | 41 ++++++-- .../McpServerIssue5349Tests.cs | 97 +++++++++++++++++++ .../McpServerSameSymbolGuardIssue5300Tests.cs | 12 ++- 8 files changed, 217 insertions(+), 50 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 4ef11fd28..fdead8c52 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -6,6 +6,8 @@ with existing MCP schema/dispatch and CLI find/search-classification tests. Keep CLI row/count comparisons, recipe/batch execution, path-required controls, unknown origins, scan/byte caps, same-line/zero-width continuation, changed filters/generation, timeout and request cancellation in shared small fixtures. +Include guarded count-unit and query-error parity, plus deduplicated ranking-cap +exhaustion and unknown recipe coverage at child, parent, and batch levels. Dependency summary regressions in `QueryCommandRunnerGraphTests` (#5346) separate page counts, SQL/C# candidate boundaries, extraction completeness, and response @@ -1447,6 +1449,8 @@ MCP スキーマ・dispatch と CLI find・検索分類のテストも併せて 小さな共通フィクスチャで CLI の行・件数比較、recipe・batch 実行、path 必須の契約、 unknown、走査・サイズ上限、同一行・ゼロ幅一致の継続、条件・世代の変更、タイムアウトと リクエストのキャンセルを維持します。 +guard 付き件数の単位と検索エラーの同等性、重複除去後の順位付け候補上限、recipe の +unknown 状態を子結果・全体・batch で保持することも検証します。 `QueryCommandRunnerGraphTests` の依存関係 summary 回帰テスト(#5346)は、ページ件数、 SQL/C# の候補上限、抽出の完全性、応答サイズ上限を区別します。3 edge に対する diff --git a/changelog.d/unreleased/5349.added.md b/changelog.d/unreleased/5349.added.md index ff4c40829..9dcc559a2 100644 --- a/changelog.d/unreleased/5349.added.md +++ b/changelog.d/unreleased/5349.added.md @@ -9,8 +9,8 @@ affected: ## English -- **MCP semantic search and bounded repository find (#5349)** — `search`, recipes, and regex `find_in_file` now accept CLI origin/result-kind and fixture filters. The additive `find` tool supports repository-wide scanning, scan budgets, count pages, and generation-bound continuation. Structured results retain unknown-origin authority, partial results, and recovery guidance across STDIO, HTTP, and batches; byte-limited find pages preserve omitted matches for resumption. +- **MCP semantic search and bounded repository find (#5349)** — `search`, recipes, and regex `find_in_file` now accept CLI origin/result-kind and fixture filters. The additive `find` tool supports repository-wide scanning, scan budgets, count pages, and generation-bound continuation. Structured results retain unknown-origin authority, partial results, and recovery guidance across STDIO, HTTP, and batches; byte-limited find pages preserve omitted matches for resumption. Guarded semantic counts follow CLI result units, and incomplete recipe classification/candidate coverage remains non-authoritative with recovery metadata. ## 日本語 -- **MCP の意味フィルターと上限付きリポジトリ横断検索 (#5349)** — `search`、recipe、正規表現の `find_in_file` で CLI と同じ origin・結果種別・fixture フィルターを使えるようになりました。新しい `find` は横断走査、走査上限、件数ページ、索引世代に紐づく継続取得に対応します。STDIO・HTTP・batch の構造化結果で unknown の確定性、部分結果、復旧案内を保持し、応答サイズで省略した一致も次ページから取得できます。 +- **MCP の意味フィルターと上限付きリポジトリ横断検索 (#5349)** — `search`、recipe、正規表現の `find_in_file` で CLI と同じ origin・結果種別・fixture フィルターを使えるようになりました。新しい `find` は横断走査、走査上限、件数ページ、索引世代に紐づく継続取得に対応します。STDIO・HTTP・batch の構造化結果で unknown の確定性、部分結果、復旧案内を保持し、応答サイズで省略した一致も次ページから取得できます。guard 付き意味フィルターの件数は CLI と同じ結果単位で数え、recipe の分類・候補走査が不完全な場合は復旧情報を保持して非確定とします。 diff --git a/docs/find-scan-controls.md b/docs/find-scan-controls.md index 3d6ddfda8..51f745bb0 100644 --- a/docs/find-scan-controls.md +++ b/docs/find-scan-controls.md @@ -44,6 +44,13 @@ completeness; bounded or unknown coverage cannot prove absence. Its cursor binds filters and generation and supports changing the row limit. STDIO, HTTP, and `batch_query` share these handlers and structured results. +Guarded semantic `countOnly` requests use the CLI's indexed result units; with +`tokenBoundary:true` they retain its row-count convention. Query-limit and +unavailable same-symbol-scope errors preserve the existing argument-error +recovery. Recipe children and their parent expose `partial_result`, `degraded`, +and recovery guidance when classification or candidate coverage is incomplete. +An empty page inside a capped ranking window does not establish complete absence. + ### Regex origin filters (#5324) Use `cdidx find 'XmlReader\.Create' --regex --path src/ --origin code --json`. @@ -161,6 +168,13 @@ MCP の find は走査上限、`scan_complete`、`partial_result`、`authoritati 世代に紐づき、行数上限は変更できます。STDIO・HTTP・`batch_query` は共通ハンドラーと 構造化結果を使います。 +guard 付きの意味フィルター検索で `countOnly` を指定すると、CLI と同じ索引結果単位で +数えます。`tokenBoundary:true` 時は CLI の行数カウントを維持します。検索上限や +same-symbol 範囲を利用できない場合のエラーは、従来の引数エラーと復旧案内を保ちます。 +分類や候補の走査が不完全な recipe は、子結果と全体の両方に `partial_result`、 +`degraded` と復旧案内を含めます。順位付けの候補上限内で空ページになっても、完全な不在を +示すものではありません。 + ### 正規表現の origin フィルター (#5324) `cdidx find 'XmlReader\.Create' --regex --path src/ --origin code --json` を使います。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index f6ed05419..d2b877a0c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -1393,6 +1393,8 @@ internal static (List Rows, bool ScanComplete, bool Classif options.Exact || options.ExactSubstring || options.TokenBoundary, requestedLimit, complete => classificationComplete &= complete, complete => scanComplete = complete, recipeQuery, requiredPathPatterns); + if (options.CountOnly && options.GuardFilters.Count > 0 && !options.TokenBoundary) + rows = rows.DistinctBy(row => SearchDisplayResultUnitKey.Create(row.Result)).ToList(); return (rows.Select(row => row.Compact).ToList(), scanComplete, classificationComplete); } @@ -1429,12 +1431,13 @@ private static List ReadOriginFilteredSearchDisplayRows( // The extra display candidate is only a pagination probe. Guard evaluation must // retain the user's requested budget or its bounded candidate scan can stop before // the first qualifying row. + var candidateWindowIncomplete = false; var page = ReadSearchResults(reader, options, exact, pageLimit, cursor, options.Limit, - recipeQuery, requiredPathPatterns); + recipeQuery, requiredPathPatterns, incomplete => candidateWindowIncomplete = incomplete); pagesRead++; if (page.Count == 0) { - candidateCoverageObserver?.Invoke(true); + candidateCoverageObserver?.Invoke(!candidateWindowIncomplete); break; } @@ -1476,9 +1479,11 @@ private static int GetSearchDisplayCandidateLimit(QueryCommandOptions options) } private static List ReadSearchResults(DbReader reader, QueryCommandOptions options, bool exact, int limit, SearchCursor? cursor = null, int? guardRequestedLimit = null, - SearchAuditRecipeQuery? recipeQuery = null, IReadOnlyList? requiredPathPatterns = null) - => reader.Search(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary, - requiredPathPatterns: requiredPathPatterns, resultRanking: recipeQuery?.ResultRanking ?? default); + SearchAuditRecipeQuery? recipeQuery = null, IReadOnlyList? requiredPathPatterns = null, + Action? candidateWindowObserver = null) + => reader.SearchWithCandidateEvidence(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary, + requiredPathPatterns: requiredPathPatterns, resultRanking: recipeQuery?.ResultRanking ?? default, + candidateWindowObserver: candidateWindowObserver); private static QueryCountResult CountFilteredSearchResults(DbReader reader, QueryCommandOptions options, bool exact) { diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs index 54239213d..1cfe266fb 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs @@ -92,7 +92,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { if (semanticFilters is not null) - return ExecuteSemanticSearchPage(id, args, reader, new QueryCommandOptions + return WithSearchQueryErrors(id, () => ExecuteSemanticSearchPage(id, args, reader, new QueryCommandOptions { Query = query, Limit = limit, Lang = lang, RawFts = rawQuery, PathPatterns = pathPatterns ?? [], ExcludePaths = excludePaths, ExcludeTests = excludeTests, @@ -103,7 +103,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], ResultKinds = [.. semanticFilters.ResultKinds], ExcludeComments = semanticFilters.ExcludeComments, ExcludeStrings = semanticFilters.ExcludeStrings, ExcludeFixtures = semanticFilters.ExcludeFixtures, - }, format, cursorValue, adjustments); + }, format, cursorValue, adjustments)); if (countOnly) { List countResults; @@ -328,39 +328,44 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe var requiredPathPatterns = GetMcpSearchRecipeRequiredPathPatterns(requestedPathPatterns, recipeQuery); if (semanticFilters is not null) { - var semanticPage = QueryCommandRunner.ReadSemanticSearchRows(reader, new QueryCommandOptions + var semanticResult = WithSearchQueryErrors(id, () => { - Query = recipeQuery.Query, Limit = limit, Lang = lang, - PathPatterns = queryPathPatterns ?? [], ExcludePaths = queryExcludePaths, ExcludeTests = excludeTests, - NoDedup = !deduplicate, Since = since, Exact = exact, TokenBoundary = tokenBoundary, - GuardFilters = guardFilters, GuardWindow = guardWindow, GuardScope = guardScope, - SnippetLines = snippetLines, MaxLineWidth = maxLineWidth, - MatchOrigins = [.. semanticFilters.Origins], ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], - ResultKinds = [.. semanticFilters.ResultKinds], ExcludeComments = semanticFilters.ExcludeComments, - ExcludeStrings = semanticFilters.ExcludeStrings, ExcludeFixtures = semanticFilters.ExcludeFixtures, - }, limit + 1, recipeQuery, requiredPathPatterns); - var semanticRows = semanticPage.Rows.Take(limit).ToList(); - QueryCommandRunner.ApplyXmlSettingsAuditClassifications(reader, recipeQuery, semanticRows); - QueryCommandRunner.MarkSearchRecipeQueryExecuted(scope, recipeQuery.Name); - total += semanticRows.Count; - queryResults.Add(new JsonObject - { - ["name"] = recipeQuery.Name, ["query"] = recipeQuery.Query, - ["description"] = recipeQuery.Description, - ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), - ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, - ["exact_substring"] = exact, ["token_boundary"] = tokenBoundary, - ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), - ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), - ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), - ["count"] = semanticRows.Count, - ["top_files"] = BuildTopFileHistogram(semanticRows, row => row.Path), - ["truncated"] = !semanticPage.ScanComplete || semanticPage.Rows.Count > limit, - ["origin_classification_complete"] = semanticPage.ClassificationComplete, - ["candidate_scan_complete"] = semanticPage.ScanComplete, - ["total_count_authoritative"] = semanticPage.ScanComplete && semanticPage.ClassificationComplete, - ["results"] = ToJsonArray(semanticRows), - }); + var semanticPage = QueryCommandRunner.ReadSemanticSearchRows(reader, new QueryCommandOptions + { + Query = recipeQuery.Query, Limit = limit, Lang = lang, + PathPatterns = queryPathPatterns ?? [], ExcludePaths = queryExcludePaths, ExcludeTests = excludeTests, + NoDedup = !deduplicate, Since = since, Exact = exact, TokenBoundary = tokenBoundary, + GuardFilters = guardFilters, GuardWindow = guardWindow, GuardScope = guardScope, + SnippetLines = snippetLines, MaxLineWidth = maxLineWidth, + MatchOrigins = [.. semanticFilters.Origins], ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], + ResultKinds = [.. semanticFilters.ResultKinds], ExcludeComments = semanticFilters.ExcludeComments, + ExcludeStrings = semanticFilters.ExcludeStrings, ExcludeFixtures = semanticFilters.ExcludeFixtures, + }, limit + 1, recipeQuery, requiredPathPatterns); + var semanticRows = semanticPage.Rows.Take(limit).ToList(); + QueryCommandRunner.ApplyXmlSettingsAuditClassifications(reader, recipeQuery, semanticRows); + QueryCommandRunner.MarkSearchRecipeQueryExecuted(scope, recipeQuery.Name); + var child = new JsonObject + { + ["name"] = recipeQuery.Name, ["query"] = recipeQuery.Query, + ["description"] = recipeQuery.Description, + ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), + ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, + ["exact_substring"] = exact, ["token_boundary"] = tokenBoundary, + ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), + ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), + ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), + ["count"] = semanticRows.Count, + ["top_files"] = BuildTopFileHistogram(semanticRows, row => row.Path), + ["truncated"] = !semanticPage.ScanComplete || semanticPage.Rows.Count > limit, + ["results"] = ToJsonArray(semanticRows), + }; + AddSemanticSearchCoverage(child, semanticPage.ScanComplete, semanticPage.ClassificationComplete); + return child; + }, recipe.Name, recipeQuery.Name); + if (semanticResult["error"] is not null || semanticResult["result"]?["isError"]?.GetValue() == true) + return semanticResult; + total += semanticResult["count"]!.GetValue(); + queryResults.Add(semanticResult); continue; } List results; @@ -446,6 +451,13 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe ["excludeTests"] = excludeTests, ["queries"] = queryResults }; + if (semanticFilters is not null) + { + AddSemanticSearchCoverage(payload, + queryResults.All(child => child!["candidate_scan_complete"]!.GetValue()), + queryResults.All(child => child!["origin_classification_complete"]!.GetValue())); + payload["truncated"] = queryResults.Any(child => child!["truncated"]!.GetValue()); + } AddFreshnessHint(payload, reader); AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); AddSameSymbolGuardContext(payload, guardFilters, guardScope, guardWindow); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs b/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs index 99b4339cc..5a86bf6fa 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.SearchSemantics.cs @@ -1,11 +1,45 @@ using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Diagnostics; namespace CodeIndex.Mcp; public partial class McpServer { + private JsonNode WithSearchQueryErrors(JsonNode? id, Func action, + string? recipeName = null, string? recipeQueryName = null) + { + try { return action(); } + catch (CodeIndexException ex) when (ex.Code == "same_symbol_scope_unavailable") + { + return CreateToolErrorResponse(id, ex.Message + " " + ex.Hint); + } + catch (SearchQueryLimitException) + { + return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); + } + catch (SearchGuardCandidateLimitException ex) + { + return CreateToolErrorResponse(id, recipeName is null + ? FormatSearchGuardCandidateLimitError(ex) + : FormatSearchRecipeGuardCandidateLimitError(recipeName, recipeQueryName!, ex)); + } + } + + private static bool AddSemanticSearchCoverage(JsonObject payload, bool scanComplete, bool classificationComplete) + { + var authoritative = scanComplete && classificationComplete; + payload["candidate_scan_complete"] = scanComplete; + payload["origin_classification_complete"] = classificationComplete; + payload["partial_result"] = !authoritative; + payload["degraded"] = !authoritative; + payload["total_count_authoritative"] = authoritative; + if (!authoritative) + payload["recovery_guidance"] = "Inspect unknown matches without semantic exclusions; narrow the path/query when candidate or pagination bounds are reached. Filtered absence is not authoritative."; + return authoritative; + } + private static bool HasSemanticSearchArguments(JsonNode? args) => new[] { "origin", "excludeOrigin", "resultKind", "excludeComments", "excludeStrings", "excludeFixtures" } .Any(name => args?[name] is not null); @@ -64,10 +98,7 @@ private JsonNode ExecuteSemanticSearchPage(JsonNode? id, JsonNode? args, DbReade payload["query"] = options.Query; payload["path"] = PathEcho(options.PathPatterns); payload["excludeTests"] = options.ExcludeTests; - payload["origin_classification_complete"] = page.ClassificationComplete; - payload["candidate_scan_complete"] = page.ScanComplete; - payload["partial_result"] = !page.ScanComplete || !page.ClassificationComplete; - var authoritative = page.ScanComplete && page.ClassificationComplete; + var authoritative = AddSemanticSearchCoverage(payload, page.ScanComplete, page.ClassificationComplete); if (options.CountOnly) { payload["authoritative_count"] = authoritative; @@ -85,8 +116,6 @@ private JsonNode ExecuteSemanticSearchPage(JsonNode? id, JsonNode? args, DbReade ApplyCompactResults(payload, rows, row => row.Path, row => row.MatchLines.Count > 0 ? row.MatchLines[0] : row.ChunkStartLine); } - if (!authoritative) - payload["recovery_guidance"] = "Inspect unknown matches without semantic exclusions; narrow the path/query when candidate or pagination bounds are reached. Filtered absence is not authoritative."; AddSameSymbolGuardContext(payload, options.GuardFilters, options.GuardScope, options.GuardWindow); AddFreshnessHint(payload, reader); adjustments.ApplyTo(payload); diff --git a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs index 987b3faf0..789d9101d 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs @@ -4,6 +4,7 @@ using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Mcp; +using CodeIndex.Models; using static CodeIndex.Tests.QueryCommandTestSupport; namespace CodeIndex.Tests; @@ -63,6 +64,102 @@ public void ToolsCall_SemanticFiltersMatchCliRowsAndCounts_Issue5349() Assert.True(unknown["partial_result"]!.GetValue()); } + [Fact] + public void ToolsCall_SemanticGuardCountsAndQueryErrorsMatchSharedContracts_Issue5349() + { + InsertIndexedFile("audit5349/guard-a.cs", "csharp", "Guard5349();\nNeedle5349();\nNeedle5349();\n"); + InsertIndexedFile("audit5349/guard-b.cs", "csharp", "Guard5349();\nNeedle5349();\n"); + foreach (var tokenBoundary in new[] { false, true }) + { + var args = new JsonObject { ["query"] = "Needle5349", ["path"] = "audit5349/", + ["origin"] = "code", [tokenBoundary ? "tokenBoundary" : "exact"] = true, ["requireBefore"] = "Guard5349", + ["guardWindow"] = 8, ["countOnly"] = true }; + var counted = Payload5349(Call5349("search", args)); + string[] cliArgs = ["search", "Needle5349", "--path", "audit5349/", "--origin", "code", + tokenBoundary ? "--token-boundary" : "--exact", "--require-before", "Guard5349", "--guard-window", "8", + "--count", "--json", "--db", _dbPath]; + var (_, output, _) = CaptureConsole(() => ProgramRunner.Run(cliArgs, JsonOptions, "test")); + var expected = JsonNode.Parse(output)!["count"]!.GetValue(); + Assert.Equal(expected, counted["count"]!.GetValue()); + Assert.Equal(expected, counted["top_files"]!.AsArray().Sum(file => file!["count"]!.GetValue())); + if (!tokenBoundary) Assert.Equal(2, expected); + } + + foreach (var countOnly in new[] { false, true }) + { + var args = new JsonObject { ["query"] = string.Join(' ', Enumerable.Repeat("a", 129)), + ["countOnly"] = countOnly }; + var expected = Call5349("search", args)["result"]!; + args["origin"] = "code"; + var actual = Call5349("search", args)["result"]!; + Assert.True(actual["isError"]!.GetValue()); + Assert.Equal("invalid_argument", actual["structuredContent"]!["category"]!.GetValue()); + Assert.Equal(expected["content"]!.ToJsonString(), actual["content"]!.ToJsonString()); + } + } + + [Fact] + public void ToolsCall_SemanticRecipeCapsAndUnknownsStayPartialThroughBatch_Issue5349() + { + const string cappedPath = "audit5349/capped-auth.cs"; + const string content = "// Authorization\n"; + var writer = new DbWriter(_db.Connection); + var fileId = writer.UpsertFile(new FileRecord { Path = cappedPath, Lang = "csharp", + Size = content.Length, Lines = 1, Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime }); + writer.InsertChunks(Enumerable.Range(0, DbReader.MaxContextRankingCandidates + 1).Select(index => new ChunkRecord + { + FileId = fileId, ChunkIndex = index, StartLine = 1, EndLine = 1, Content = content, + }).ToList()); + var capped = Payload5349(Call5349("search", new JsonObject + { ["recipe"] = "auth-token-audit", ["path"] = cappedPath, ["origin"] = "code", ["limit"] = 20 })); + var authorization = capped["queries"]!.AsArray().Single(child => child!["name"]!.GetValue() == "authorization-header")!; + Assert.Equal(0, authorization["count"]!.GetValue()); + Assert.False(authorization["candidate_scan_complete"]!.GetValue()); + AssertPartial5349(authorization); + AssertPartial5349(capped); + + foreach (var recipeMode in new[] { false, true }) + { + var guardedArgs = new JsonObject { ["path"] = cappedPath, ["limit"] = 1, + ["requireBefore"] = "AbsentGuard5349" }; + guardedArgs[recipeMode ? "recipe" : "query"] = recipeMode ? "auth-token-audit" : "Authorization"; + var expectedError = Call5349("search", guardedArgs)["result"]!; + guardedArgs["origin"] = "code"; + var actualError = Call5349("search", guardedArgs)["result"]!; + Assert.True(actualError["isError"]!.GetValue()); + Assert.Equal("invalid_argument", actualError["structuredContent"]!["category"]!.GetValue()); + Assert.Contains("candidate", expectedError["content"]!.ToJsonString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("candidate", actualError["content"]!.ToJsonString(), StringComparison.OrdinalIgnoreCase); + if (recipeMode) + Assert.Contains("auth-token-audit", actualError["content"]!.ToJsonString(), StringComparison.Ordinal); + } + + InsertIndexedFile("audit5349/unknown-recipe.cs", "csharp", new string('\n', 4096) + "info.ArgumentList.Add(value);\n"); + var args = new JsonObject { ["recipe"] = "dogfood-risk-patterns", ["path"] = "audit5349/unknown-recipe.cs", + ["origin"] = "code", ["limit"] = 20 }; + var unknown = Payload5349(Call5349("search", args)); + var child = unknown["queries"]!.AsArray().Single(query => query!["name"]!.GetValue() == "process-argument-list")!; + Assert.Equal(0, child["count"]!.GetValue()); + Assert.True(child["candidate_scan_complete"]!.GetValue()); + Assert.False(child["origin_classification_complete"]!.GetValue()); + Assert.False(child["truncated"]!.GetValue()); + AssertPartial5349(child); + AssertPartial5349(unknown); + var batch = Payload5349(Call5349("batch_query", new JsonObject { ["queries"] = new JsonArray + { new JsonObject { ["tool"] = "search", ["arguments"] = args.DeepClone() } } })); + var batchRecipe = batch["results"]![0]!["result"]!; + AssertPartial5349(batchRecipe); + AssertPartial5349(batchRecipe["queries"]!.AsArray().Single(query => query!["name"]!.GetValue() == "process-argument-list")!); + } + + private static void AssertPartial5349(JsonNode payload) + { + Assert.True(payload["partial_result"]!.GetValue()); + Assert.True(payload["degraded"]!.GetValue()); + Assert.False(payload["total_count_authoritative"]!.GetValue()); + Assert.NotNull(payload["recovery_guidance"]); + } + [Fact] public void ToolsCall_FindResumesScanAndByteCapsWithoutLostZeroWidthMatches_Issue5349() { diff --git a/tests/CodeIndex.Tests/McpServerSameSymbolGuardIssue5300Tests.cs b/tests/CodeIndex.Tests/McpServerSameSymbolGuardIssue5300Tests.cs index 90fa88c5d..e2ef8433f 100644 --- a/tests/CodeIndex.Tests/McpServerSameSymbolGuardIssue5300Tests.cs +++ b/tests/CodeIndex.Tests/McpServerSameSymbolGuardIssue5300Tests.cs @@ -12,9 +12,9 @@ public void ToolsCall_SameSymbolGuardRowsCountsEmptyAndStale_Issue5300() const string source = "class Guarded\n{\n void M()\n {\n Clear();\n Return();\n }\n void N()\n {\n Return();\n }\n}"; TestProjectHelper.InsertFreshIndexedFile(_projectRoot, _dbPath, "src/guard.cs", "csharp", source); new DbWriter(_db.Connection).SetMeta(DbContext.SymbolKindFilterMetaKey, SymbolKindFilter.Empty.Signature); - JsonNode Call(string query, bool count = false) + JsonNode Call(string query, bool count = false, bool semantic = false) { - return _server.HandleMessage(new JsonObject + var request = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 5300, @@ -32,7 +32,10 @@ JsonNode Call(string query, bool count = false) ["countOnly"] = count, }, }, - })!["result"]!; + }; + if (semantic) + request["params"]!["arguments"]!["origin"] = "code"; + return _server.HandleMessage(request)!["result"]!; } var rows = Call("Return"); Assert.False(rows["isError"]?.GetValue() ?? false); @@ -48,5 +51,8 @@ JsonNode Call(string query, bool count = false) var failed = Call("Return"); Assert.True(failed["isError"]!.GetValue()); Assert.Contains("same_symbol_scope_unavailable", failed.ToJsonString()); + var semanticFailed = Call("Return", semantic: true); + Assert.Equal(failed["content"]!.ToJsonString(), semanticFailed["content"]!.ToJsonString()); + Assert.Equal("invalid_argument", semanticFailed["structuredContent"]!["category"]!.GetValue()); } } From 3c276996861beb6b330c0f1df1e2ac56526f4ea8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 13 Sep 2026 01:59:39 +0900 Subject: [PATCH 3/4] Distinguish candidate caps from search page fullness (#5349) --- TESTING_GUIDE.md | 4 +++ changelog.d/unreleased/5349.added.md | 4 +-- docs/find-scan-controls.md | 2 ++ .../Cli/QueryCommandRunner.SearchResults.cs | 10 +++--- src/CodeIndex/Database/DbSearchReader.cs | 3 +- .../McpServerIssue5349Tests.cs | 33 +++++++++++++++++++ 6 files changed, 48 insertions(+), 8 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index fdead8c52..735756bc3 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -8,6 +8,8 @@ unknown origins, scan/byte caps, same-line/zero-width continuation, changed filters/generation, timeout and request cancellation in shared small fixtures. Include guarded count-unit and query-error parity, plus deduplicated ranking-cap exhaustion and unknown recipe coverage at child, parent, and batch levels. +An uncapped token-boundary scan spanning multiple pages must retain complete +coverage independently of the requested row limit. Dependency summary regressions in `QueryCommandRunnerGraphTests` (#5346) separate page counts, SQL/C# candidate boundaries, extraction completeness, and response @@ -1451,6 +1453,8 @@ unknown、走査・サイズ上限、同一行・ゼロ幅一致の継続、条 リクエストのキャンセルを維持します。 guard 付き件数の単位と検索エラーの同等性、重複除去後の順位付け候補上限、recipe の unknown 状態を子結果・全体・batch で保持することも検証します。 +上限未到達で複数ページにまたがる token-boundary 走査は、要求行数にかかわらず +完全性を維持することを確認します。 `QueryCommandRunnerGraphTests` の依存関係 summary 回帰テスト(#5346)は、ページ件数、 SQL/C# の候補上限、抽出の完全性、応答サイズ上限を区別します。3 edge に対する diff --git a/changelog.d/unreleased/5349.added.md b/changelog.d/unreleased/5349.added.md index 9dcc559a2..06268a362 100644 --- a/changelog.d/unreleased/5349.added.md +++ b/changelog.d/unreleased/5349.added.md @@ -9,8 +9,8 @@ affected: ## English -- **MCP semantic search and bounded repository find (#5349)** — `search`, recipes, and regex `find_in_file` now accept CLI origin/result-kind and fixture filters. The additive `find` tool supports repository-wide scanning, scan budgets, count pages, and generation-bound continuation. Structured results retain unknown-origin authority, partial results, and recovery guidance across STDIO, HTTP, and batches; byte-limited find pages preserve omitted matches for resumption. Guarded semantic counts follow CLI result units, and incomplete recipe classification/candidate coverage remains non-authoritative with recovery metadata. +- **MCP semantic search and bounded repository find (#5349)** — `search`, recipes, and regex `find_in_file` now accept CLI origin/result-kind and fixture filters. The additive `find` tool supports repository-wide scanning, scan budgets, count pages, and generation-bound continuation. Structured results retain unknown-origin authority, partial results, and recovery guidance across STDIO, HTTP, and batches; byte-limited find pages preserve omitted matches for resumption. Guarded semantic counts follow CLI result units, and incomplete recipe classification/candidate coverage remains non-authoritative with recovery metadata. Completed scans retain authority regardless of page size. ## 日本語 -- **MCP の意味フィルターと上限付きリポジトリ横断検索 (#5349)** — `search`、recipe、正規表現の `find_in_file` で CLI と同じ origin・結果種別・fixture フィルターを使えるようになりました。新しい `find` は横断走査、走査上限、件数ページ、索引世代に紐づく継続取得に対応します。STDIO・HTTP・batch の構造化結果で unknown の確定性、部分結果、復旧案内を保持し、応答サイズで省略した一致も次ページから取得できます。guard 付き意味フィルターの件数は CLI と同じ結果単位で数え、recipe の分類・候補走査が不完全な場合は復旧情報を保持して非確定とします。 +- **MCP の意味フィルターと上限付きリポジトリ横断検索 (#5349)** — `search`、recipe、正規表現の `find_in_file` で CLI と同じ origin・結果種別・fixture フィルターを使えるようになりました。新しい `find` は横断走査、走査上限、件数ページ、索引世代に紐づく継続取得に対応します。STDIO・HTTP・batch の構造化結果で unknown の確定性、部分結果、復旧案内を保持し、応答サイズで省略した一致も次ページから取得できます。guard 付き意味フィルターの件数は CLI と同じ結果単位で数え、recipe の分類・候補走査が不完全な場合は復旧情報を保持して非確定とします。完了済みの走査はページサイズにかかわらず確定性を維持します。 diff --git a/docs/find-scan-controls.md b/docs/find-scan-controls.md index 51f745bb0..bb0eadf7e 100644 --- a/docs/find-scan-controls.md +++ b/docs/find-scan-controls.md @@ -50,6 +50,7 @@ unavailable same-symbol-scope errors preserve the existing argument-error recovery. Recipe children and their parent expose `partial_result`, `degraded`, and recovery guidance when classification or candidate coverage is incomplete. An empty page inside a capped ranking window does not establish complete absence. +Page fullness alone does not degrade an otherwise completed scan. ### Regex origin filters (#5324) @@ -174,6 +175,7 @@ same-symbol 範囲を利用できない場合のエラーは、従来の引数 分類や候補の走査が不完全な recipe は、子結果と全体の両方に `partial_result`、 `degraded` と復旧案内を含めます。順位付けの候補上限内で空ページになっても、完全な不在を 示すものではありません。 +一方、ページが満杯になっただけで、完了済みの走査を不完全扱いにはしません。 ### 正規表現の origin フィルター (#5324) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index d2b877a0c..4437fe601 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -1431,13 +1431,13 @@ private static List ReadOriginFilteredSearchDisplayRows( // The extra display candidate is only a pagination probe. Guard evaluation must // retain the user's requested budget or its bounded candidate scan can stop before // the first qualifying row. - var candidateWindowIncomplete = false; + var candidateCapReached = false; var page = ReadSearchResults(reader, options, exact, pageLimit, cursor, options.Limit, - recipeQuery, requiredPathPatterns, incomplete => candidateWindowIncomplete = incomplete); + recipeQuery, requiredPathPatterns, capped => candidateCapReached = capped); pagesRead++; if (page.Count == 0) { - candidateCoverageObserver?.Invoke(!candidateWindowIncomplete); + candidateCoverageObserver?.Invoke(!candidateCapReached); break; } @@ -1480,10 +1480,10 @@ private static int GetSearchDisplayCandidateLimit(QueryCommandOptions options) private static List ReadSearchResults(DbReader reader, QueryCommandOptions options, bool exact, int limit, SearchCursor? cursor = null, int? guardRequestedLimit = null, SearchAuditRecipeQuery? recipeQuery = null, IReadOnlyList? requiredPathPatterns = null, - Action? candidateWindowObserver = null) + Action? candidateCapObserver = null) => reader.SearchWithCandidateEvidence(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary, requiredPathPatterns: requiredPathPatterns, resultRanking: recipeQuery?.ResultRanking ?? default, - candidateWindowObserver: candidateWindowObserver); + candidateCapObserver: candidateCapObserver); private static QueryCountResult CountFilteredSearchResults(DbReader reader, QueryCommandOptions options, bool exact) { diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 07c007c19..2a48c58dd 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -139,7 +139,7 @@ public List Search(string query, int limit = 20, string? lang = nu deduplicate, since, exact, prefix, visibilityRank, cursor, guardFilters, guardWindow, guardRequestedLimit, requiredPathPatterns, guardScope, tokenBoundary, resultRanking); - internal List SearchWithCandidateEvidence(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, int? guardRequestedLimit = null, IReadOnlyList? requiredPathPatterns = null, SearchGuardScope guardScope = SearchGuardScope.Window, bool tokenBoundary = false, SearchResultRanking resultRanking = SearchResultRanking.Default, Action? candidateWindowObserver = null) + internal List SearchWithCandidateEvidence(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, int? guardRequestedLimit = null, IReadOnlyList? requiredPathPatterns = null, SearchGuardScope guardScope = SearchGuardScope.Window, bool tokenBoundary = false, SearchResultRanking resultRanking = SearchResultRanking.Default, Action? candidateWindowObserver = null, Action? candidateCapObserver = null) { // Guard against empty/whitespace queries that would match everything // 空白のみのクエリが全件マッチするのを防止 @@ -381,6 +381,7 @@ FROM fts_chunks AttachSearchEnclosingSymbols(pagedResults, searchPrimaryMatchContext); AttachCSharpOriginLines(pagedResults); + candidateCapObserver?.Invoke(guardCandidateLimitReached || contextRankingCandidateLimitReached); candidateWindowObserver?.Invoke(guardCandidateLimitReached || contextRankingCandidateLimitReached || !hasCandidatePostProcessing && nextOffset - (cursor?.Offset ?? 0) >= limit || results.Count >= limit); diff --git a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs index 789d9101d..e339038cc 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs @@ -160,6 +160,39 @@ private static void AssertPartial5349(JsonNode payload) Assert.NotNull(payload["recovery_guidance"]); } + [Fact] + public void ToolsCall_SemanticSearchTerminalCoverageIgnoresPageFullness_Issue5349() + { + const string path = "audit5349/complete-pages.cs"; + const int count = 240; + var writer = new DbWriter(_db.Connection); + var fileId = writer.UpsertFile(new FileRecord { Path = path, Lang = "csharp", + Size = count * 24, Lines = count, Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime }); + writer.InsertChunks(Enumerable.Range(0, count / 10).Select(index => new ChunkRecord + { + FileId = fileId, ChunkIndex = index, StartLine = index * 10 + 1, EndLine = (index + 1) * 10, + Content = string.Join('\n', Enumerable.Repeat("using System;", 10)), + }).ToList()); + foreach (var limit in new[] { 1, 100 }) + { + var args = new JsonObject { ["query"] = "using System", ["path"] = path, + ["tokenBoundary"] = true, ["origin"] = "comment", ["limit"] = limit }; + var complete = Payload5349(Call5349("search", args)); + Assert.Equal(0, complete["count"]!.GetValue()); + Assert.True(complete["candidate_scan_complete"]!.GetValue()); + Assert.True(complete["origin_classification_complete"]!.GetValue()); + Assert.True(complete["total_count_authoritative"]!.GetValue()); + Assert.False(complete["partial_result"]!.GetValue()); + Assert.Null(complete["recovery_guidance"]); + + args["origin"] = "code"; + args["countOnly"] = true; + var counted = Payload5349(Call5349("search", args)); + Assert.Equal(count, counted["count"]!.GetValue()); + Assert.True(counted["authoritative_count"]!.GetValue()); + } + } + [Fact] public void ToolsCall_FindResumesScanAndByteCapsWithoutLostZeroWidthMatches_Issue5349() { From 065a133511f8b4b14f30a0e224c2f28875f1c271 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 13 Sep 2026 02:19:06 +0900 Subject: [PATCH 4/4] Fix MCP semantic search and find formatting for CI (#5349) --- src/CodeIndex/Mcp/McpToolCatalog.Search.cs | 14 +- .../Mcp/McpToolHandlers.Query.Find.cs | 19 ++- .../Mcp/McpToolHandlers.Query.Search.cs | 67 +++++--- .../Mcp/McpToolHandlers.Query.Source.cs | 19 ++- .../McpServerIssue5349Tests.cs | 149 ++++++++++++++---- .../McpServerToolsListTests.cs | 4 +- 6 files changed, 204 insertions(+), 68 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolCatalog.Search.cs b/src/CodeIndex/Mcp/McpToolCatalog.Search.cs index a9507d526..3e800fb97 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.Search.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.Search.cs @@ -27,13 +27,16 @@ private static void AddSemanticSearchTools(JsonArray tools) var scopedProperties = scopedFind["inputSchema"]!["properties"]!.AsObject(); scopedProperties["cursor"] = new JsonObject { - ["type"] = "string", ["maxLength"] = MaxMcpQueryCursorCharacters, + ["type"] = "string", + ["maxLength"] = MaxMcpQueryCursorCharacters, ["description"] = "Resume next_cursor with the same query, filters, and count mode. Limit and maxBytes may change. Restart after indexing.", }; scopedProperties["countOnly"] = new JsonObject { ["type"] = "boolean", ["default"] = false }; scopedProperties["maxBytes"] = new JsonObject { - ["type"] = "integer", ["minimum"] = 1, ["maximum"] = MaxConfiguredResponseBytes, + ["type"] = "integer", + ["minimum"] = 1, + ["maximum"] = MaxConfiguredResponseBytes, ["default"] = DefaultFindMaxBytes, ["description"] = "Maximum UTF-8 bytes in structuredContent (default 65536). Whole rows are paged without advancing past omitted matches. Server response limits also apply.", }; @@ -41,13 +44,16 @@ private static void AddSemanticSearchTools(JsonArray tools) schema["required"] = new JsonArray { "query" }; schema["properties"]!["all"] = new JsonObject { - ["type"] = "boolean", ["default"] = false, + ["type"] = "boolean", + ["default"] = false, ["description"] = "Explicitly scan all indexed files with file/line safety caps. Specify either all=true or path, never both.", }; schema["properties"]!["path"]!["description"] = "Explicit file/path scope instead of all=true; accepts a string or array."; schema["properties"]!["lineScanLimit"] = new JsonObject { - ["type"] = "integer", ["minimum"] = 1, ["maximum"] = QueryCommandRunner.MaxFindLineScanLimit, + ["type"] = "integer", + ["minimum"] = 1, + ["maximum"] = QueryCommandRunner.MaxFindLineScanLimit, ["default"] = QueryCommandRunner.FindAllLineScanLimit, ["description"] = "Maximum indexed lines per all=true scan page; may change when resuming a cursor. Requires all=true.", }; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs index 1103d8add..898046bbb 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Find.cs @@ -66,13 +66,20 @@ private JsonNode ExecuteFindPage(JsonNode? id, DbReader reader, QueryCommandOpti var next = QueryCommandRunner.BuildFindResumeCursor(cursorArgs, reader, scan); var payload = new JsonObject { - ["query"] = options.Query, ["path"] = PathEcho(options.PathPatterns), - ["excludeTests"] = options.ExcludeTests, ["before"] = options.ContextBefore, - ["after"] = options.ContextAfter, ["contextTruncated"] = contextTruncated, - ["maxLineWidth"] = options.MaxLineWidth, ["exact"] = options.Exact, ["regex"] = options.Regex, - ["count"] = count, ["fileCount"] = fileCount, + ["query"] = options.Query, + ["path"] = PathEcho(options.PathPatterns), + ["excludeTests"] = options.ExcludeTests, + ["before"] = options.ContextBefore, + ["after"] = options.ContextAfter, + ["contextTruncated"] = contextTruncated, + ["maxLineWidth"] = options.MaxLineWidth, + ["exact"] = options.Exact, + ["regex"] = options.Regex, + ["count"] = count, + ["fileCount"] = fileCount, ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), - ["max_bytes"] = maxBytes, ["byte_limit_reached"] = byteLimited, + ["max_bytes"] = maxBytes, + ["byte_limit_reached"] = byteLimited, }; if (snippetLines.HasValue) payload["snippetLines"] = snippetLines.Value; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs index 1cfe266fb..bd0348214 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs @@ -94,14 +94,30 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) if (semanticFilters is not null) return WithSearchQueryErrors(id, () => ExecuteSemanticSearchPage(id, args, reader, new QueryCommandOptions { - Query = query, Limit = limit, Lang = lang, RawFts = rawQuery, - PathPatterns = pathPatterns ?? [], ExcludePaths = excludePaths, ExcludeTests = excludeTests, - NoDedup = !deduplicate, Since = since, Exact = exactSearch, TokenBoundary = tokenBoundary, - Prefix = prefix, GuardFilters = guardFilters, GuardWindow = guardWindow, GuardScope = guardScope, - SnippetLines = snippetLines, SnippetFocus = snippetFocus, MaxLineWidth = maxLineWidth, - CountOnly = countOnly, MatchOrigins = [.. semanticFilters.Origins], - ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], ResultKinds = [.. semanticFilters.ResultKinds], - ExcludeComments = semanticFilters.ExcludeComments, ExcludeStrings = semanticFilters.ExcludeStrings, + Query = query, + Limit = limit, + Lang = lang, + RawFts = rawQuery, + PathPatterns = pathPatterns ?? [], + ExcludePaths = excludePaths, + ExcludeTests = excludeTests, + NoDedup = !deduplicate, + Since = since, + Exact = exactSearch, + TokenBoundary = tokenBoundary, + Prefix = prefix, + GuardFilters = guardFilters, + GuardWindow = guardWindow, + GuardScope = guardScope, + SnippetLines = snippetLines, + SnippetFocus = snippetFocus, + MaxLineWidth = maxLineWidth, + CountOnly = countOnly, + MatchOrigins = [.. semanticFilters.Origins], + ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], + ResultKinds = [.. semanticFilters.ResultKinds], + ExcludeComments = semanticFilters.ExcludeComments, + ExcludeStrings = semanticFilters.ExcludeStrings, ExcludeFixtures = semanticFilters.ExcludeFixtures, }, format, cursorValue, adjustments)); if (countOnly) @@ -332,25 +348,40 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe { var semanticPage = QueryCommandRunner.ReadSemanticSearchRows(reader, new QueryCommandOptions { - Query = recipeQuery.Query, Limit = limit, Lang = lang, - PathPatterns = queryPathPatterns ?? [], ExcludePaths = queryExcludePaths, ExcludeTests = excludeTests, - NoDedup = !deduplicate, Since = since, Exact = exact, TokenBoundary = tokenBoundary, - GuardFilters = guardFilters, GuardWindow = guardWindow, GuardScope = guardScope, - SnippetLines = snippetLines, MaxLineWidth = maxLineWidth, - MatchOrigins = [.. semanticFilters.Origins], ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], - ResultKinds = [.. semanticFilters.ResultKinds], ExcludeComments = semanticFilters.ExcludeComments, - ExcludeStrings = semanticFilters.ExcludeStrings, ExcludeFixtures = semanticFilters.ExcludeFixtures, + Query = recipeQuery.Query, + Limit = limit, + Lang = lang, + PathPatterns = queryPathPatterns ?? [], + ExcludePaths = queryExcludePaths, + ExcludeTests = excludeTests, + NoDedup = !deduplicate, + Since = since, + Exact = exact, + TokenBoundary = tokenBoundary, + GuardFilters = guardFilters, + GuardWindow = guardWindow, + GuardScope = guardScope, + SnippetLines = snippetLines, + MaxLineWidth = maxLineWidth, + MatchOrigins = [.. semanticFilters.Origins], + ExcludeOrigins = [.. semanticFilters.ExcludedOrigins], + ResultKinds = [.. semanticFilters.ResultKinds], + ExcludeComments = semanticFilters.ExcludeComments, + ExcludeStrings = semanticFilters.ExcludeStrings, + ExcludeFixtures = semanticFilters.ExcludeFixtures, }, limit + 1, recipeQuery, requiredPathPatterns); var semanticRows = semanticPage.Rows.Take(limit).ToList(); QueryCommandRunner.ApplyXmlSettingsAuditClassifications(reader, recipeQuery, semanticRows); QueryCommandRunner.MarkSearchRecipeQueryExecuted(scope, recipeQuery.Name); var child = new JsonObject { - ["name"] = recipeQuery.Name, ["query"] = recipeQuery.Query, + ["name"] = recipeQuery.Name, + ["query"] = recipeQuery.Query, ["description"] = recipeQuery.Description, ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, - ["exact_substring"] = exact, ["token_boundary"] = tokenBoundary, + ["exact_substring"] = exact, + ["token_boundary"] = tokenBoundary, ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs index c4ccb3e9e..3b92044f8 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -509,10 +509,21 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args, bool allowAll = return CreateMcpCursorError(id, allowAll ? "find" : "find_in_file", "cursor_malformed", "Find cursor is too long.", stale: false); var options = new QueryCommandOptions { - Query = query, Limit = limit, Lang = lang, All = all, PathPatterns = pathPatterns ?? [], - ExcludePaths = excludePaths, ExcludeTests = excludeTests, IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false, - ContextBefore = before, ContextAfter = after, Exact = exact, Regex = regex, - MaxLineWidth = maxLineWidth, FocusLine = focusLine, FocusColumn = focusColumn, + Query = query, + Limit = limit, + Lang = lang, + All = all, + PathPatterns = pathPatterns ?? [], + ExcludePaths = excludePaths, + ExcludeTests = excludeTests, + IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false, + ContextBefore = before, + ContextAfter = after, + Exact = exact, + Regex = regex, + MaxLineWidth = maxLineWidth, + FocusLine = focusLine, + FocusColumn = focusColumn, CountOnly = ReadCountOnly(args), }; return WithDbReader(id, args, reader => ExecuteFindPage(id, reader, options, semanticFilters, diff --git a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs index e339038cc..3d88deb13 100644 --- a/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs +++ b/tests/CodeIndex.Tests/McpServerIssue5349Tests.cs @@ -57,7 +57,7 @@ public void ToolsCall_SemanticFiltersMatchCliRowsAndCounts_Issue5349() } } var unknown = Payload5349(Call5349("find", new JsonObject - { ["query"] = "Needle5349", ["all"] = true, ["regex"] = true, ["origin"] = "code" })); + { ["query"] = "Needle5349", ["all"] = true, ["regex"] = true, ["origin"] = "code" })); Assert.Equal(3, unknown["count"]!.GetValue()); Assert.Equal(2, unknown["unknown_origin_matches"]!.GetValue()); Assert.False(unknown["authoritative_rows"]!.GetValue()); @@ -71,9 +71,16 @@ public void ToolsCall_SemanticGuardCountsAndQueryErrorsMatchSharedContracts_Issu InsertIndexedFile("audit5349/guard-b.cs", "csharp", "Guard5349();\nNeedle5349();\n"); foreach (var tokenBoundary in new[] { false, true }) { - var args = new JsonObject { ["query"] = "Needle5349", ["path"] = "audit5349/", - ["origin"] = "code", [tokenBoundary ? "tokenBoundary" : "exact"] = true, ["requireBefore"] = "Guard5349", - ["guardWindow"] = 8, ["countOnly"] = true }; + var args = new JsonObject + { + ["query"] = "Needle5349", + ["path"] = "audit5349/", + ["origin"] = "code", + [tokenBoundary ? "tokenBoundary" : "exact"] = true, + ["requireBefore"] = "Guard5349", + ["guardWindow"] = 8, + ["countOnly"] = true + }; var counted = Payload5349(Call5349("search", args)); string[] cliArgs = ["search", "Needle5349", "--path", "audit5349/", "--origin", "code", tokenBoundary ? "--token-boundary" : "--exact", "--require-before", "Guard5349", "--guard-window", "8", @@ -87,8 +94,11 @@ public void ToolsCall_SemanticGuardCountsAndQueryErrorsMatchSharedContracts_Issu foreach (var countOnly in new[] { false, true }) { - var args = new JsonObject { ["query"] = string.Join(' ', Enumerable.Repeat("a", 129)), - ["countOnly"] = countOnly }; + var args = new JsonObject + { + ["query"] = string.Join(' ', Enumerable.Repeat("a", 129)), + ["countOnly"] = countOnly + }; var expected = Call5349("search", args)["result"]!; args["origin"] = "code"; var actual = Call5349("search", args)["result"]!; @@ -104,14 +114,24 @@ public void ToolsCall_SemanticRecipeCapsAndUnknownsStayPartialThroughBatch_Issue const string cappedPath = "audit5349/capped-auth.cs"; const string content = "// Authorization\n"; var writer = new DbWriter(_db.Connection); - var fileId = writer.UpsertFile(new FileRecord { Path = cappedPath, Lang = "csharp", - Size = content.Length, Lines = 1, Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime }); + var fileId = writer.UpsertFile(new FileRecord + { + Path = cappedPath, + Lang = "csharp", + Size = content.Length, + Lines = 1, + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime + }); writer.InsertChunks(Enumerable.Range(0, DbReader.MaxContextRankingCandidates + 1).Select(index => new ChunkRecord { - FileId = fileId, ChunkIndex = index, StartLine = 1, EndLine = 1, Content = content, + FileId = fileId, + ChunkIndex = index, + StartLine = 1, + EndLine = 1, + Content = content, }).ToList()); var capped = Payload5349(Call5349("search", new JsonObject - { ["recipe"] = "auth-token-audit", ["path"] = cappedPath, ["origin"] = "code", ["limit"] = 20 })); + { ["recipe"] = "auth-token-audit", ["path"] = cappedPath, ["origin"] = "code", ["limit"] = 20 })); var authorization = capped["queries"]!.AsArray().Single(child => child!["name"]!.GetValue() == "authorization-header")!; Assert.Equal(0, authorization["count"]!.GetValue()); Assert.False(authorization["candidate_scan_complete"]!.GetValue()); @@ -120,8 +140,12 @@ public void ToolsCall_SemanticRecipeCapsAndUnknownsStayPartialThroughBatch_Issue foreach (var recipeMode in new[] { false, true }) { - var guardedArgs = new JsonObject { ["path"] = cappedPath, ["limit"] = 1, - ["requireBefore"] = "AbsentGuard5349" }; + var guardedArgs = new JsonObject + { + ["path"] = cappedPath, + ["limit"] = 1, + ["requireBefore"] = "AbsentGuard5349" + }; guardedArgs[recipeMode ? "recipe" : "query"] = recipeMode ? "auth-token-audit" : "Authorization"; var expectedError = Call5349("search", guardedArgs)["result"]!; guardedArgs["origin"] = "code"; @@ -135,8 +159,13 @@ public void ToolsCall_SemanticRecipeCapsAndUnknownsStayPartialThroughBatch_Issue } InsertIndexedFile("audit5349/unknown-recipe.cs", "csharp", new string('\n', 4096) + "info.ArgumentList.Add(value);\n"); - var args = new JsonObject { ["recipe"] = "dogfood-risk-patterns", ["path"] = "audit5349/unknown-recipe.cs", - ["origin"] = "code", ["limit"] = 20 }; + var args = new JsonObject + { + ["recipe"] = "dogfood-risk-patterns", + ["path"] = "audit5349/unknown-recipe.cs", + ["origin"] = "code", + ["limit"] = 20 + }; var unknown = Payload5349(Call5349("search", args)); var child = unknown["queries"]!.AsArray().Single(query => query!["name"]!.GetValue() == "process-argument-list")!; Assert.Equal(0, child["count"]!.GetValue()); @@ -145,8 +174,11 @@ public void ToolsCall_SemanticRecipeCapsAndUnknownsStayPartialThroughBatch_Issue Assert.False(child["truncated"]!.GetValue()); AssertPartial5349(child); AssertPartial5349(unknown); - var batch = Payload5349(Call5349("batch_query", new JsonObject { ["queries"] = new JsonArray - { new JsonObject { ["tool"] = "search", ["arguments"] = args.DeepClone() } } })); + var batch = Payload5349(Call5349("batch_query", new JsonObject + { + ["queries"] = new JsonArray + { new JsonObject { ["tool"] = "search", ["arguments"] = args.DeepClone() } } + })); var batchRecipe = batch["results"]![0]!["result"]!; AssertPartial5349(batchRecipe); AssertPartial5349(batchRecipe["queries"]!.AsArray().Single(query => query!["name"]!.GetValue() == "process-argument-list")!); @@ -166,17 +198,32 @@ public void ToolsCall_SemanticSearchTerminalCoverageIgnoresPageFullness_Issue534 const string path = "audit5349/complete-pages.cs"; const int count = 240; var writer = new DbWriter(_db.Connection); - var fileId = writer.UpsertFile(new FileRecord { Path = path, Lang = "csharp", - Size = count * 24, Lines = count, Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime }); + var fileId = writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = count * 24, + Lines = count, + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime + }); writer.InsertChunks(Enumerable.Range(0, count / 10).Select(index => new ChunkRecord { - FileId = fileId, ChunkIndex = index, StartLine = index * 10 + 1, EndLine = (index + 1) * 10, + FileId = fileId, + ChunkIndex = index, + StartLine = index * 10 + 1, + EndLine = (index + 1) * 10, Content = string.Join('\n', Enumerable.Repeat("using System;", 10)), }).ToList()); foreach (var limit in new[] { 1, 100 }) { - var args = new JsonObject { ["query"] = "using System", ["path"] = path, - ["tokenBoundary"] = true, ["origin"] = "comment", ["limit"] = limit }; + var args = new JsonObject + { + ["query"] = "using System", + ["path"] = path, + ["tokenBoundary"] = true, + ["origin"] = "comment", + ["limit"] = limit + }; var complete = Payload5349(Call5349("search", args)); Assert.Equal(0, complete["count"]!.GetValue()); Assert.True(complete["candidate_scan_complete"]!.GetValue()); @@ -200,8 +247,15 @@ public void ToolsCall_FindResumesScanAndByteCapsWithoutLostZeroWidthMatches_Issu InsertIndexedFile("audit5349/b.cs", "csharp", "var s = \"Needle5349\"; Needle5349();\nNeedle5349();\n"); foreach (var query in new[] { "Needle5349", "(?=Needle5349)" }) { - var args = new JsonObject { ["query"] = query, ["all"] = true, ["regex"] = true, - ["origin"] = "code", ["limit"] = 1, ["lineScanLimit"] = 1 }; + var args = new JsonObject + { + ["query"] = query, + ["all"] = true, + ["regex"] = true, + ["origin"] = "code", + ["limit"] = 1, + ["lineScanLimit"] = 1 + }; var seen = new List(); string? cursor = null; for (var page = 0; page < 100; page++) @@ -227,8 +281,15 @@ public void ToolsCall_FindResumesScanAndByteCapsWithoutLostZeroWidthMatches_Issu } InsertIndexedFile("audit5349/wide.cs", "csharp", string.Join('\n', Enumerable.Repeat("Needle5349(); " + new string('x', 400), 8))); - var wideArgs = new JsonObject { ["query"] = "Needle5349", ["path"] = "audit5349/wide.cs", ["regex"] = true, - ["origin"] = "code", ["limit"] = 8, ["maxBytes"] = 3000 }; + var wideArgs = new JsonObject + { + ["query"] = "Needle5349", + ["path"] = "audit5349/wide.cs", + ["regex"] = true, + ["origin"] = "code", + ["limit"] = 8, + ["maxBytes"] = 3000 + }; var wideRows = new List(); string? next = null; for (var page = 0; page < 10; page++) @@ -286,8 +347,15 @@ public void ToolsCall_FindCountCapsAndSemanticRecipeBatchKeepMetadata_Issue5349( { InsertIndexedFile("audit5349/a.cs", "csharp", "info.ArgumentList.Add(value);\ninfo.ArgumentList.Add(value);\n"); InsertIndexedFile("audit5349/b.cs", "csharp", "// ArgumentList\n"); - var args = new JsonObject { ["query"] = "ArgumentList", ["all"] = true, ["regex"] = true, - ["origin"] = "code", ["countOnly"] = true, ["lineScanLimit"] = 1 }; + var args = new JsonObject + { + ["query"] = "ArgumentList", + ["all"] = true, + ["regex"] = true, + ["origin"] = "code", + ["countOnly"] = true, + ["lineScanLimit"] = 1 + }; var count = 0; string? cursor = null; for (var page = 0; page < 100; page++) @@ -302,17 +370,20 @@ public void ToolsCall_FindCountCapsAndSemanticRecipeBatchKeepMetadata_Issue5349( Assert.Null(cursor); Assert.Equal(2, count); var recipe = Payload5349(Call5349("search", new JsonObject - { ["recipe"] = "dogfood-risk-patterns", ["path"] = "audit5349/", ["origin"] = "code", ["limit"] = 10 })); + { ["recipe"] = "dogfood-risk-patterns", ["path"] = "audit5349/", ["origin"] = "code", ["limit"] = 10 })); var child = recipe["queries"]!.AsArray().Single(q => q!["name"]!.GetValue() == "process-argument-list")!; Assert.Equal(2, child["count"]!.GetValue()); Assert.True(child["origin_classification_complete"]!.GetValue()); - var batch = Call5349("batch_query", new JsonObject { ["queries"] = new JsonArray + var batch = Call5349("batch_query", new JsonObject + { + ["queries"] = new JsonArray { new JsonObject { ["tool"] = "find", ["arguments"] = new JsonObject { ["query"] = "ArgumentList", ["all"] = true, ["regex"] = true, ["origin"] = "code", ["lineScanLimit"] = 1 } }, new JsonObject { ["tool"] = "search", ["arguments"] = new JsonObject { ["query"] = "ArgumentList", ["origin"] = "code", ["path"] = "audit5349/" } }, - } }); + } + }); Assert.Contains("next_cursor", batch.ToJsonString(), StringComparison.Ordinal); Assert.Contains("scan_complete", batch.ToJsonString(), StringComparison.Ordinal); Assert.DoesNotContain("unknown_argument", batch.ToJsonString(), StringComparison.Ordinal); @@ -332,8 +403,14 @@ public void ToolsCall_FindDiscoveryAndInvalidArgumentsStaySynchronized_Issue5349 var scoped = tools.Single(tool => tool!["name"]!.GetValue() == "find_in_file")!; Assert.Contains(scoped["inputSchema"]!["required"]!.AsArray(), value => value!.GetValue() == "path"); var defaults = Payload5349(Call5349("find_in_file", new JsonObject - { ["query"] = "Read", ["path"] = "src/", ["excludeComments"] = false, - ["excludeStrings"] = false, ["excludeFixtures"] = false, ["origin"] = new JsonArray() })); + { + ["query"] = "Read", + ["path"] = "src/", + ["excludeComments"] = false, + ["excludeStrings"] = false, + ["excludeFixtures"] = false, + ["origin"] = new JsonArray() + })); Assert.Null(defaults["origin_classification_complete"]); foreach (var (tool, json) in new[] { @@ -366,7 +443,7 @@ public async Task ToolsCall_FindTimeoutAndRequestCancellationDoNotIssueCursors_I foreach (var tool in new[] { "find", "find_in_file" }) { var response = Call5349(tool, new JsonObject - { ["query"] = "(a+)+$", ["regex"] = true, ["path"] = "audit5349/slow.cs", ["origin"] = "code" }); + { ["query"] = "(a+)+$", ["regex"] = true, ["path"] = "audit5349/slow.cs", ["origin"] = "code" }); Assert.Contains(CommandErrorCodes.RegexMatchTimeout, response.ToJsonString(), StringComparison.Ordinal); Assert.DoesNotContain("next_cursor", response.ToJsonString(), StringComparison.Ordinal); } @@ -388,13 +465,15 @@ public async Task ToolsCall_FindTimeoutAndRequestCancellationDoNotIssueCursors_I } finally { DbReader.FindLineScannedForTesting = null; } var recovered = Payload5349(Call5349("find", new JsonObject - { ["query"] = "Needle5349", ["path"] = "audit5349/cancel.cs", ["regex"] = true, ["origin"] = "code" })); + { ["query"] = "Needle5349", ["path"] = "audit5349/cancel.cs", ["regex"] = true, ["origin"] = "code" })); Assert.Equal(2, recovered["count"]!.GetValue()); } private JsonNode Call5349(string tool, JsonObject args) => _server.HandleMessage(new JsonObject { - ["jsonrpc"] = "2.0", ["id"] = 5349, ["method"] = "tools/call", + ["jsonrpc"] = "2.0", + ["id"] = 5349, + ["method"] = "tools/call", ["params"] = new JsonObject { ["name"] = tool, ["arguments"] = args.DeepClone() }, })!; diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index 73650433f..ed610c02a 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -897,7 +897,9 @@ private JsonArray ReadAllToolsListPages(JsonNode response) { result = _server.HandleMessage(new JsonObject { - ["jsonrpc"] = "2.0", ["id"] = 100, ["method"] = "tools/list", + ["jsonrpc"] = "2.0", + ["id"] = 100, + ["method"] = "tools/list", ["params"] = new JsonObject { ["cursor"] = cursor }, })!["result"]!; foreach (var tool in result["tools"]!.AsArray())