diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index aea3fc10f1..cfb1f1838b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -444,6 +444,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..948375c036 --- /dev/null +++ b/changelog.d/unreleased/1462.fixed.md @@ -0,0 +1,23 @@ +--- +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 + - 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 f3304c8e4d..553478cffe 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. Returns match-centered snippets with line metadata plus `next_step_suggestion` or `recovery_hint`. Use `prefix` or trailing `*` to widen token matching, `rawQuery` for FTS5 syntax, and `exactSubstring` for case-sensitive text identity. Details and examples: USER_GUIDE.md#search. / インデックス済みコードチャンクの全文検索。`prefix` / 末尾 `*` / `rawQuery` / `exactSubstring` の詳細と例は USER_GUIDE.md#search を参照。", + "Full-text search across indexed code chunks. Returns match-centered snippets with line metadata plus `result_stable_at` for index-drift checks, `next_cursor` for non-empty paginated responses, and `next_step_suggestion` or `recovery_hint`. Use `prefix` or trailing `*` to widen token matching, `rawQuery` for FTS5 syntax, and `exactSubstring` for case-sensitive text identity. Details and examples: USER_GUIDE.md#search. / インデックス済みコードチャンクの全文検索。レスポンスには index drift 検出用の `result_stable_at`、非空ページ継続用の `next_cursor`、`next_step_suggestion` または `recovery_hint` を含める。`prefix` / 末尾 `*` / `rawQuery` / `exactSubstring` の詳細と例は USER_GUIDE.md#search を参照。", 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 d19a80b90c..f144040475 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 969027f6f3..b84292aeb3 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() {