Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<json-rpc-id> cid=<correlation-id>]` 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 <token>` requirement as POST and `/events`.
Expand Down
23 changes: 23 additions & 0 deletions changelog.d/unreleased/1462.fixed.md
Original file line number Diff line number Diff line change
@@ -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 から継続できるようにしました。
19 changes: 15 additions & 4 deletions src/CodeIndex/Database/DbSearchReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ private static string FormatFtsToken(string token, bool prefix)
/// Full-text search across indexed chunks using FTS5.
/// FTS5を使ったチャンク全文検索。
/// </summary>
public List<SearchResult> Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true)
public List<SearchResult> Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? 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
// 空白のみのクエリが全件マッチするのを防止
Expand All @@ -119,7 +119,8 @@ public List<SearchResult> 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(
Expand All @@ -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}";
Expand All @@ -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)
Expand All @@ -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<SearchResult>();
var nextOffset = cursor?.Offset ?? 0;
try
{
using var reader = cmd.ExecuteTrackedReader();
while (reader.TrackedRead())
{
nextOffset++;
raw.Add(new SearchResult
{
Path = reader.GetString(0),
Expand All @@ -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,
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/CodeIndex/Mcp/McpToolDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 },
Expand Down
56 changes: 54 additions & 2 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -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<SearchResult> 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)
Expand Down Expand Up @@ -494,7 +534,7 @@ private static string DescribeJsonType(JsonNode? node)

private static IReadOnlySet<string> GetAllowedToolArguments(string toolName) => toolName switch
{
"search" => new HashSet<string>(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "countOnly", "format", "project", "solution" },
"search" => new HashSet<string>(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<string>(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" },
"references" => new HashSet<string>(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<string>(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" },
Expand Down Expand Up @@ -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<bool>() ?? false;
SearchCursor? cursor = null;
var cursorValue = args?["cursor"]?.GetValue<string>();
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<bool>() ?? false;
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/CodeIndex/Models/QueryResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,52 @@ public void ToolsCall_SearchFormatCountAliasesCountOnly_Issue1642()

Assert.True(structured["count_only"]!.GetValue<bool>());
Assert.True(structured["count"]!.GetValue<int>() > 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<string>();
var stableAt = first["result_stable_at"]!.GetValue<DateTime>();

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<DateTime>());
Assert.NotEqual(
first["results"]!.AsArray()[0]!["path"]!.GetValue<string>(),
second["results"]!.AsArray()[0]!["path"]!.GetValue<string>());
}

[Fact]
public void ToolsCall_Callers_TruncatedResponseIncludesNextOffsetAndPages()
{
Expand Down
Loading