From 3c004854c3761aca84c8acb4638a616318bcb1e1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 01:23:03 +0900 Subject: [PATCH 1/2] Fix MCP search pagination stability for #1462 --- DEVELOPER_GUIDE.md | 6 +++ changelog.d/unreleased/1462.fixed.md | 20 +++++++++ src/CodeIndex/Database/DbSearchReader.cs | 19 ++++++-- src/CodeIndex/Mcp/McpToolDefinitions.cs | 3 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 56 +++++++++++++++++++++++- src/CodeIndex/Models/QueryResults.cs | 6 +++ tests/CodeIndex.Tests/McpServerTests.cs | 43 ++++++++++++++++++ 7 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/1462.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6385fdbd5b..433049041e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -431,6 +431,12 @@ Each JSON-RPC MCP request gets a server-generated `correlation_id` in addition t MCP stderr diagnostics are prefixed with `[rid= cid=]` when a request context exists. Every `tools/call` also emits one structured JSON line with `event: "mcp.tool.invocation"`, the tool name, elapsed milliseconds, status, result count when available, error metadata, argument keys, and argument lengths. Argument values are intentionally not logged in this telemetry line. +### MCP search pagination + +MCP `search` responses include `result_stable_at`, copied from the index freshness timestamp for the database snapshot used by that call. Clients that page through search results should compare `result_stable_at` across calls; if it changes, an intervening index mutation may have shifted the result set and the client should restart pagination. + +Non-empty `search` responses also include `next_cursor`. Passing that value back as the `cursor` argument with the same query and filters continues after the last returned `(score, chunk rowid)` anchor. The cursor is an opaque response value; clients should not construct or edit it. + ### MCP health probes The MCP JSON-RPC `ping` method returns a structured health object with `status`, `uptime_s`, `last_request_at`, `db_open`, `last_db_check_at`, and `transport_ready`. HTTP MCP transports expose the same object at `GET /healthz` on the existing listener. If the HTTP transport is protected by a bearer token, `/healthz` uses the same `Authorization: Bearer ` requirement as POST and `/events`. diff --git a/changelog.d/unreleased/1462.fixed.md b/changelog.d/unreleased/1462.fixed.md new file mode 100644 index 0000000000..91064608d4 --- /dev/null +++ b/changelog.d/unreleased/1462.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 1462 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP search now exposes pagination stability metadata (#1462)** - `search` responses include `result_stable_at` and non-empty pages include `next_cursor`, allowing clients to detect index drift and continue from the previous page anchor. + +## 日本語 + +- **MCP search がページング安定性メタデータを返すようになりました (#1462)** - `search` レスポンスに `result_stable_at` を含め、非空ページには `next_cursor` を含めることで、クライアントが index drift を検出し前ページの anchor から継続できるようにしました。 diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 5d2c54692f..dbf205234d 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -99,7 +99,7 @@ private static string FormatFtsToken(string token, bool prefix) /// Full-text search across indexed chunks using FTS5. /// FTS5を使ったチャンク全文検索。 /// - public List Search(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) + public List Search(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) { // Guard against empty/whitespace queries that would match everything // 空白のみのクエリが全件マッチするのを防止 @@ -119,7 +119,8 @@ public List Search(string query, int limit = 20, string? lang = nu sql = $@" SELECT f.path, f.lang, c.start_line, c.end_line, c.content, 0.0 AS rank, - {GetSearchVisibilitySql()} AS visibility + {GetSearchVisibilitySql()} AS visibility, + c.id AS chunk_id FROM chunks c JOIN files f ON c.file_id = f.id{SearchSymbolMatchJoinsSql} WHERE instr( @@ -135,7 +136,8 @@ WHERE instr( sql = $@" SELECT f.path, f.lang, c.start_line, c.end_line, c.content, rank, - {GetSearchVisibilitySql()} AS visibility + {GetSearchVisibilitySql()} AS visibility, + c.id AS chunk_id FROM fts_chunks JOIN chunks c ON fts_chunks.rowid = c.id JOIN files f ON c.file_id = f.id{SearchSymbolMatchJoinsSql}"; @@ -146,9 +148,10 @@ FROM fts_chunks sql += " AND f.lang = @lang"; if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; - AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count)} LIMIT @limit"; + if (cursor is { }) + sql += " OFFSET @cursorOffset"; cmd.CommandText = sql; if (exact) @@ -162,14 +165,20 @@ FROM fts_chunks cmd.Parameters.AddWithValue("@lang", lang); if (since != null && _fileColumns.Contains("modified")) cmd.Parameters.AddWithValue("@since", since.Value); + if (cursor is { } searchCursorParameter) + { + cmd.Parameters.AddWithValue("@cursorOffset", searchCursorParameter.Offset); + } AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); var raw = new List(); + var nextOffset = cursor?.Offset ?? 0; try { using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) { + nextOffset++; raw.Add(new SearchResult { Path = reader.GetString(0), @@ -179,6 +188,8 @@ FROM fts_chunks Content = reader.GetString(4), Score = reader.GetDouble(5), Visibility = GetNullableString(reader, 6), + ChunkId = reader.GetInt64(7), + NextOffset = nextOffset, }); } } diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 4a9de77b69..1d8a636f08 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -20,7 +20,7 @@ private JsonNode HandleToolsList(JsonNode? id) { CreateToolDefinition( "search", - "Full-text search across indexed code chunks using FTS5. Returns compact match-centered snippets with line metadata. Non-empty responses include `next_step_suggestion` for the obvious follow-up read, and empty responses include `recovery_hint`. The literal-safe path quotes each whitespace-separated token as an FTS5 phrase and combines multiple tokens with implicit AND semantics (`foo bar` requires both terms). For CJK that means `search 計算` no longer also matches `計算する`/`計算機`, because unicode61 keeps adjacent CJK codepoints in one token. Two opt-ins enable FTS5 prefix expansion: (1) trailing `*` on a single token in the `query` string (`search 計算*` to match `計算する`); (2) the `prefix` flag, which promotes every token in the query to a prefix phrase. Use `exactSubstring` for case-sensitive exact-substring matching that bypasses FTS5 entirely; `exact` is the backward-compatible alias documented in USER_GUIDE.md's flag compatibility table. Non-CJK tokens follow the same rule — ASCII identifiers also no longer auto-prefix, so use `--prefix` or trailing `*` to widen. Emoji-mixed tokens cannot be distinguished from their plain ASCII counterpart at the FTS layer (unicode61 drops the emoji on both index and query side — `foo🎉` is FTS-equivalent to `foo`), and pure emoji substring search is 0-result for the same reason; use `exactSubstring` when emoji identity matters. Examples: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`. / FTS5を使ったコードチャンクの全文検索。一致中心の軽量スニペットと行メタデータを返す。非空レスポンスには自然な次の読み取りを示す `next_step_suggestion`、空レスポンスには `recovery_hint` を含める。literal-safe 経路は空白区切りの各トークンを FTS5 phrase として引用し、複数 token は implicit AND として結合する(`foo bar` は両方の term を要求する)。CJK の場合、unicode61 は隣接 CJK コードポイントを一語として扱うため、`search 計算` は `計算する`/`計算機` にはマッチしない。FTS5 prefix への昇格は 2 通りでオプトイン: (1) `query` 文字列内のトークン末尾に `*` を付ける(`search 計算*` で `計算する` にマッチ)。(2) `prefix` フラグで、クエリの全トークンを prefix phrase に昇格させる。`exactSubstring` を使うと FTS5 を経由せず大小文字区別の厳密部分文字列マッチになり、`exact` は USER_GUIDE.md の flag compatibility table に記載された後方互換 alias。CJK 以外(ASCII 識別子等)も同じルールで、自動 prefix は行わないため、広げたい場合は `--prefix` か末尾 `*` を使う。絵文字混在トークンは、unicode61 が indexing とクエリの両側で絵文字を削ぐため FTS 層で素の ASCII トークンと区別できず(`foo🎉` は FTS 上 `foo` と等価)、絵文字単独の部分一致も同じ理由で 0 件になる。絵文字の同一性が必要な場合は `exactSubstring` を使う。例: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`。", + "Full-text search across indexed code chunks using FTS5. Returns compact match-centered snippets with line metadata. Responses include `result_stable_at` so clients can detect index drift between pages, and non-empty responses include `next_cursor` for continuing after the last returned `(score, chunk rowid)` anchor. Non-empty responses include `next_step_suggestion` for the obvious follow-up read, and empty responses include `recovery_hint`. The literal-safe path quotes each whitespace-separated token as an FTS5 phrase and combines multiple tokens with implicit AND semantics (`foo bar` requires both terms). For CJK that means `search 計算` no longer also matches `計算する`/`計算機`, because unicode61 keeps adjacent CJK codepoints in one token. Two opt-ins enable FTS5 prefix expansion: (1) trailing `*` on a single token in the `query` string (`search 計算*` to match `計算する`); (2) the `prefix` flag, which promotes every token in the query to a prefix phrase. Use `exactSubstring` for case-sensitive exact-substring matching that bypasses FTS5 entirely; `exact` is the backward-compatible alias documented in USER_GUIDE.md's flag compatibility table. Non-CJK tokens follow the same rule — ASCII identifiers also no longer auto-prefix, so use `--prefix` or trailing `*` to widen. Emoji-mixed tokens cannot be distinguished from their plain ASCII counterpart at the FTS layer (unicode61 drops the emoji on both index and query side — `foo🎉` is FTS-equivalent to `foo`), and pure emoji substring search is 0-result for the same reason; use `exactSubstring` when emoji identity matters. Examples: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`. / FTS5を使ったコードチャンクの全文検索。一致中心の軽量スニペットと行メタデータを返す。レスポンスにはページ間の index drift を検出するための `result_stable_at` を含め、非空レスポンスには最後に返した `(score, chunk rowid)` anchor の後から続ける `next_cursor` を含める。非空レスポンスには自然な次の読み取りを示す `next_step_suggestion`、空レスポンスには `recovery_hint` を含める。literal-safe 経路は空白区切りの各トークンを FTS5 phrase として引用し、複数 token は implicit AND として結合する(`foo bar` は両方の term を要求する)。CJK の場合、unicode61 は隣接 CJK コードポイントを一語として扱うため、`search 計算` は `計算する`/`計算機` にはマッチしない。FTS5 prefix への昇格は 2 通りでオプトイン: (1) `query` 文字列内のトークン末尾に `*` を付ける(`search 計算*` で `計算する` にマッチ)。(2) `prefix` フラグで、クエリの全トークンを prefix phrase に昇格させる。`exactSubstring` を使うと FTS5 を経由せず大小文字区別の厳密部分文字列マッチになり、`exact` は USER_GUIDE.md の flag compatibility table に記載された後方互換 alias。CJK 以外(ASCII 識別子等)も同じルールで、自動 prefix は行わないため、広げたい場合は `--prefix` か末尾 `*` を使う。絵文字混在トークンは、unicode61 が indexing とクエリの両側で絵文字を削ぐため FTS 層で素の ASCII トークンと区別できず(`foo🎉` は FTS 上 `foo` と等価)、絵文字単独の部分一致も同じ理由で 0 件になる。絵文字の同一性が必要な場合は `exactSubstring` を使う。例: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`。", new JsonObject { ["type"] = "object", @@ -32,6 +32,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping). Match lines are clamped around the first match; non-match lines are clamped from the head. Each clamp inserts a `...(+N)...` marker showing how many chars were elided.", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["rawQuery"] = new JsonObject { ["type"] = "boolean", ["description"] = "Use raw FTS5 syntax instead of literal-safe quoting: content:term, NEAR(a b, 5), OR, NOT, parenthesized groups, prefix*, and quoted phrases.", ["default"] = false }, + ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Optional pagination cursor returned as `next_cursor` by a previous search response with the same query and filters. Compare `result_stable_at` across pages to detect index drift." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d8a76e0273..5e511fdd57 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -147,6 +148,45 @@ private static void AddFreshnessHint(JsonObject payload, DbReader reader) payload["freshness_degraded_reason"] = freshness.FreshnessDegradedReason; } + private static void AddSearchStabilityMetadata(JsonObject payload, DbReader reader, SearchCursor? cursor, IReadOnlyList results) + { + var freshness = reader.GetFreshnessHint(); + payload["result_stable_at"] = freshness.IndexedAt.HasValue + ? JsonSerializer.SerializeToNode(freshness.IndexedAt.Value) + : null; + payload["freshness_available"] = freshness.FreshnessAvailable; + if (!freshness.FreshnessAvailable && freshness.FreshnessDegradedReason != null) + payload["freshness_degraded_reason"] = freshness.FreshnessDegradedReason; + + if (results.Count > 0) + payload["next_cursor"] = FormatSearchCursor(results[^1]); + } + + private static string FormatSearchCursor(SearchResult result) + => string.Create(CultureInfo.InvariantCulture, $"{result.Score:R}:{result.ChunkId}:{result.NextOffset}"); + + private static bool TryParseSearchCursor(string value, out SearchCursor cursor) + { + cursor = default; + var lastSeparator = value.LastIndexOf(':'); + if (lastSeparator <= 0 || lastSeparator == value.Length - 1) + return false; + + var firstSeparator = value.LastIndexOf(':', lastSeparator - 1); + if (firstSeparator <= 0 || firstSeparator == lastSeparator - 1) + return false; + + if (!double.TryParse(value.AsSpan(0, firstSeparator), NumberStyles.Float, CultureInfo.InvariantCulture, out var score)) + return false; + if (!long.TryParse(value.AsSpan(firstSeparator + 1, lastSeparator - firstSeparator - 1), NumberStyles.None, CultureInfo.InvariantCulture, out var chunkId)) + return false; + if (!int.TryParse(value.AsSpan(lastSeparator + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var offset) || offset < 0) + return false; + + cursor = new SearchCursor(score, chunkId, offset); + return true; + } + private static void AddFtsQueryDiagnostics(JsonObject payload, FtsQueryDiagnostics diagnostics) { if (!diagnostics.HasDegradation) @@ -494,7 +534,7 @@ private static string DescribeJsonType(JsonNode? node) private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch { - "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "countOnly", "format", "project", "solution" }, + "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "countOnly", "format", "project", "solution" }, "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, @@ -914,6 +954,14 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var rawQuery = args?["rawQuery"]?.GetValue() ?? false; + SearchCursor? cursor = null; + var cursorValue = args?["cursor"]?.GetValue(); + if (!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."); + cursor = parsedCursor; + } var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -948,12 +996,13 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) payload["rawQuery"] = rawQuery; payload["path"] = PathEcho(pathPatterns); payload["excludeTests"] = excludeTests; + AddSearchStabilityMetadata(payload, reader, cursor, []); if (countResults.Count == 0) AddFtsQueryDiagnostics(payload, DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang)); return CreateToolResult(id, $"Counted {countResults.Count} search result(s).", payload); } - var results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix); + var results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, cursor: cursor); var ftsDiagnostics = DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang); var truncated = TrimToRequestedLimit(results, limit); if (results.Count == 0) @@ -968,6 +1017,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["excludeTests"] = excludeTests, ["results"] = new JsonArray() }; + AddSearchStabilityMetadata(payload, reader, cursor, results); AddFtsQueryDiagnostics(payload, ftsDiagnostics); AddResultEnvelope(payload, 0, 0, truncated: false); AddRecoveryHint( @@ -984,12 +1034,14 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) { ["query"] = query, ["rawQuery"] = rawQuery, + ["cursor"] = cursorValue, ["snippetLines"] = snippetLines, ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, ["results"] = ToJsonArray(SearchSnippetFormatter.ToCompactResults(results, query, snippetLines, exact, maxLineWidth)) }; + AddSearchStabilityMetadata(structured, reader, cursor, results); AddResultEnvelope(structured, results.Count, truncated ? null : results.Count, truncated); if (format == "compact") ApplyCompactResults(structured, results, result => result.Path, result => result.StartLine); diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 93f5e4216a..5095fb788c 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -18,8 +18,14 @@ public class SearchResult public string Content { get; set; } = string.Empty; public double Score { get; set; } public string? Visibility { get; set; } + [JsonIgnore] + public long ChunkId { get; set; } + [JsonIgnore] + public int NextOffset { get; set; } } +public readonly record struct SearchCursor(double Score, long ChunkId, int Offset); + public readonly record struct QueryCountResult(int Count, int FileCount, bool IncludesSql = false); public sealed record FtsQueryDiagnostics( diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 4dc6e7bfe7..d58c586aa1 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -209,9 +209,52 @@ public void ToolsCall_SearchFormatCountAliasesCountOnly_Issue1642() Assert.True(structured["count_only"]!.GetValue()); Assert.True(structured["count"]!.GetValue() > 0); + Assert.NotNull(structured["result_stable_at"]); Assert.Empty(structured["results"]!.AsArray()); } + [Fact] + public void ToolsCall_SearchReturnsStableAtAndCursorContinuesAfterAnchor_Issue1462() + { + InsertIndexedFile("src/other.cs", "csharp", "public class Other { public void Run() { } }"); + var firstRequest = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"Run","limit":1}}}""")!; + + var firstResponse = _server.HandleMessage(firstRequest)!; + var first = firstResponse["result"]!["structuredContent"]!; + var cursor = first["next_cursor"]!.GetValue(); + var stableAt = first["result_stable_at"]!.GetValue(); + + Assert.Single(first["results"]!.AsArray()); + Assert.False(string.IsNullOrWhiteSpace(cursor)); + + var secondRequest = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 2, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "search", + ["arguments"] = new JsonObject + { + ["query"] = "Run", + ["limit"] = 1, + ["cursor"] = cursor, + }, + }, + }; + + var secondResponse = _server.HandleMessage(secondRequest)!; + var second = secondResponse["result"]!["structuredContent"]!; + + Assert.Single(second["results"]!.AsArray()); + Assert.Equal(stableAt, second["result_stable_at"]!.GetValue()); + Assert.NotEqual( + first["results"]!.AsArray()[0]!["path"]!.GetValue(), + second["results"]!.AsArray()[0]!["path"]!.GetValue()); + } + [Fact] public void ToolsCall_Callers_TruncatedResponseIncludesNextOffsetAndPages() { From fee18652972c773773df92cbfbddb0d22893dab1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 01:47:14 +0900 Subject: [PATCH 2/2] Fix CI lint for #1462 --- .github/workflows/dotnet.yml | 2 +- Directory.Build.props | 2 +- changelog.d/unreleased/1462.fixed.md | 3 +++ dev.sh | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b7ec61c36d..7d6caa88bb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -179,7 +179,7 @@ jobs: } - name: Verify formatting - run: dotnet format CodeIndex.sln --verify-no-changes --no-restore --verbosity minimal + run: dotnet format whitespace CodeIndex.sln --verify-no-changes --no-restore --verbosity minimal - name: Verify developer task wrapper if: matrix.os == 'ubuntu-latest' && matrix.test-framework == 'net8.0' diff --git a/Directory.Build.props b/Directory.Build.props index a2ba2e8677..c144863b70 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ --> true true - $(WarningsNotAsErrors);IL2026;IL2067;IL2072;IL2075 + $(WarningsNotAsErrors);IL2026;IL3050;IL2067;IL2072;IL2075 false diff --git a/changelog.d/unreleased/1462.fixed.md b/changelog.d/unreleased/1462.fixed.md index 91064608d4..948375c036 100644 --- a/changelog.d/unreleased/1462.fixed.md +++ b/changelog.d/unreleased/1462.fixed.md @@ -3,6 +3,9 @@ category: fixed issues: - 1462 affected: + - .github/workflows/dotnet.yml + - Directory.Build.props + - dev.sh - src/CodeIndex/Database/DbSearchReader.cs - src/CodeIndex/Mcp/McpToolHandlers.cs - src/CodeIndex/Mcp/McpToolDefinitions.cs diff --git a/dev.sh b/dev.sh index 43bf32ff6f..eaa738e5d8 100755 --- a/dev.sh +++ b/dev.sh @@ -37,7 +37,7 @@ case "$task" in --blame-hang-timeout 5m ;; lint) - dotnet format CodeIndex.sln --verify-no-changes --verbosity minimal + dotnet format whitespace CodeIndex.sln --verify-no-changes --verbosity minimal ;; format) dotnet format CodeIndex.sln --verbosity minimal