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
25 changes: 25 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,37 @@ 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 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`.

`db_open` is a lightweight `SELECT 1` probe against the configured SQLite DB. A failed probe reports `status: "degraded"` and includes a sanitized `db_error` exception type instead of raw filesystem or SQLite details.

### MCP keep-alive notifications

HTTP MCP `/events` streams can emit opt-in server-initiated `notifications/keep_alive` JSON-RPC notifications. Set `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` to a positive number of seconds to enable them; unset or non-positive values keep the default off behavior. Stdio sessions do not emit keep-alive notifications by default because the parent process owns liveness for that transport.

Each keep-alive notification includes `server_time` and `uptime_s` under `params`. The notification is best-effort: disconnected SSE clients are removed from the stream registry, and keep-alive write failures must not terminate the MCP server.

### MCP リクエスト相関

各 JSON-RPC MCP リクエストには、クライアント制御の JSON-RPC `id` とは別に、サーバー生成の `correlation_id` が割り当てられます。成功レスポンスでは `result._meta.correlation_id`、エラーレスポンスでは `error.data.correlation_id` またはツールエラーの `result.structuredContent.correlation_id` に含まれます。JSON-RPC id がある場合は、同じメタデータにシリアライズ済みの値を `request_id` として入れます。`batch_query` は親の値に `.1`、`.2` のような suffix を付けた子 correlation ID を各スロットに割り当てます。

MCP stderr 診断は、リクエストコンテキストがある場合に `[rid=<json-rpc-id> cid=<correlation-id>]` で prefix されます。各 `tools/call` はさらに `event: "mcp.tool.invocation"` の構造化 JSON 行を 1 行出力し、tool 名、経過ミリ秒、status、取得できる場合の result count、エラーメタデータ、引数キー、引数長を含めます。この telemetry 行には引数値を記録しません。

### MCP ヘルスプローブ

MCP JSON-RPC の `ping` method は、`status`、`uptime_s`、`last_request_at`、`db_open`、`last_db_check_at`、`transport_ready` を含む構造化 health object を返します。HTTP MCP transport では、既存 listener の `GET /healthz` で同じ object を返します。HTTP transport が bearer token で保護されている場合、`/healthz` も POST と `/events` と同じ `Authorization: Bearer <token>` を要求します。

`db_open` は設定された SQLite DB に対する軽量な `SELECT 1` probe です。probe が失敗した場合は `status: "degraded"` を返し、生の filesystem / SQLite 詳細ではなくサニタイズした `db_error` 例外型だけを含めます。

### MCP keep-alive notification

HTTP MCP の `/events` stream は、opt-in の server-initiated `notifications/keep_alive` JSON-RPC notification を送信できます。`CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` に正の秒数を設定すると有効になり、未設定または非正値では既定どおり無効です。stdio session は親プロセスが liveness を管理する transport なので、既定では keep-alive notification を出しません。

各 keep-alive notification は `params` に `server_time` と `uptime_s` を含めます。この notification は best-effort であり、切断された SSE client は stream registry から除外され、keep-alive 書き込み失敗で MCP server 自体を終了させてはいけません。


## Database schema

Persisted SHA-256 hashes are lowercase hexadecimal strings. New hash emitters
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ downgrading `cdidx`.
| Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. |
| Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. Query defaults can be adjusted with `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH`; explicit CLI flags still win. |
| Project scoping | `.sln` / `.csproj`-aware <code>--project &lt;name&#124;path&gt;</code> filters for indexing and queries, plus `--solution <path>` when a workspace has multiple solution files. |
| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. Tool schemas reject unknown arguments with `-32602`, advertise `x-stability`, and use snake_case structured JSON keys to match the CLI JSON contract. |
| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a structured `ping` health result, HTTP `GET /healthz`, opt-in HTTP `/events` keep-alive notifications, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. Tool schemas reject unknown arguments with `-32602`, advertise `x-stability`, and use snake_case structured JSON keys to match the CLI JSON contract. |
| Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. |
| Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir <dir>`, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db <path>` still wins. |
| DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. |
Expand Down Expand Up @@ -356,7 +356,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し
| 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。 |
| 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。query 既定値は `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で調整でき、明示 CLI flag が常に優先されます。 |
| project scope | `.sln` / `.csproj` を使った <code>--project &lt;name&#124;path&gt;</code> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution <path>` を指定します。 |
| MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 |
| MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、構造化された `ping` health result、HTTP `GET /healthz`、opt-in の HTTP `/events` keep-alive notification、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。Tool schema は未知の引数を `-32602` で拒否し、`x-stability` を公開し、CLI JSON contract と一致する snake_case の structured JSON key を使います。 |
| freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 |
| storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir <dir>`、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db <path>` は引き続き最優先です。 |
| DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 |
Expand Down
19 changes: 19 additions & 0 deletions changelog.d/unreleased/1740.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: added
issues:
- 1740
affected:
- src/CodeIndex/Mcp/McpServer.cs
- src/CodeIndex/Mcp/HttpMcpTransport.cs
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
- README.md
- DEVELOPER_GUIDE.md
---

## English

- **HTTP MCP event streams can now emit opt-in keep-alive notifications (#1740)** — set `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` to send `notifications/keep_alive` frames on `/events`.

## 日本語

- **HTTP MCP event stream が opt-in の keep-alive notification を送れるようになりました (#1740)** — `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` を設定すると `/events` に `notifications/keep_alive` frame を送信します。
20 changes: 20 additions & 0 deletions changelog.d/unreleased/1791.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
category: added
issues:
- 1791
affected:
- src/CodeIndex/Mcp/McpServer.cs
- src/CodeIndex/Mcp/HttpMcpTransport.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
- README.md
- DEVELOPER_GUIDE.md
---

## English

- **MCP health probes now expose structured liveness data (#1791)** — `ping` returns health fields and HTTP MCP serves the same JSON at `GET /healthz` for orchestrator probes.

## 日本語

- **MCP health probe が構造化された liveness 情報を返すようになりました (#1791)** — `ping` は health field を返し、HTTP MCP は orchestrator probe 向けに同じ JSON を `GET /healthz` で提供します。
78 changes: 72 additions & 6 deletions src/CodeIndex/Mcp/HttpMcpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ internal HttpMcpTransport(string prefix, string host, int boundPort, string? bea

internal Func<string, string?>? OutOfBandFrameHandler { get; set; }

internal Func<string>? HealthJsonProvider { get; set; }

internal TimeSpan? KeepAliveInterval { get; set; }

internal Func<string>? KeepAliveFrameProvider { get; set; }

/// <summary>
/// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports
/// (port `0`) are resolved up-front by binding a temporary <see cref="TcpListener"/> so the
Expand Down Expand Up @@ -221,6 +227,22 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT
if (!await TryAuthorizeAsync(request).ConfigureAwait(false))
return;

if (IsHealthPath(context.Request.Url?.AbsolutePath))
{
if (!string.Equals(context.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
{
context.Response.AddHeader("Allow", "GET");
await RespondAsync(context, (int)HttpStatusCode.MethodNotAllowed, "MCP health endpoint only accepts GET.\n").ConfigureAwait(false);
LogRequest(request, (int)HttpStatusCode.MethodNotAllowed);
return;
}

var healthJson = HealthJsonProvider?.Invoke() ?? """{"status":"starting","db_open":false}""";
await RespondJsonAsync(context, (int)HttpStatusCode.OK, healthJson).ConfigureAwait(false);
LogRequest(request, (int)HttpStatusCode.OK);
return;
}

if (IsEventsPath(context.Request.Url?.AbsolutePath))
{
if (!string.Equals(context.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
Expand Down Expand Up @@ -432,9 +454,29 @@ private static async Task RespondAsync(HttpListenerContext context, int statusCo
}
}

private static async Task RespondJsonAsync(HttpListenerContext context, int statusCode, string body)
{
try
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
var bytes = Encoding.UTF8.GetBytes(body);
context.Response.ContentLength64 = bytes.LongLength;
await context.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
context.Response.OutputStream.Close();
}
catch
{
try { context.Response.Abort(); } catch { /* ignore */ }
}
}

private static bool IsEventsPath(string? path)
=> string.Equals(path, "/events", StringComparison.Ordinal);

private static bool IsHealthPath(string? path)
=> string.Equals(path, "/healthz", StringComparison.Ordinal);

private async Task RunEventStreamAsync(PendingRequest request, CancellationToken cancellationToken)
{
var context = request.Context;
Expand All @@ -453,6 +495,25 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken
await context.Response.OutputStream.WriteAsync(prelude.AsMemory(), cancellationToken).ConfigureAwait(false);
await context.Response.OutputStream.FlushAsync(cancellationToken).ConfigureAwait(false);

await RunKeepAliveLoopAsync(stream, cancellationToken).ConfigureAwait(false);
}
catch
{
// Client disconnects are expected for long-lived SSE streams.
}
finally
{
_eventStreams.TryRemove(streamId, out _);
LogRequest(request, (int)HttpStatusCode.OK);
try { context.Response.Close(); } catch { /* ignore */ }
}
}

private async Task RunKeepAliveLoopAsync(EventStream stream, CancellationToken cancellationToken)
{
var interval = KeepAliveInterval;
if (interval is null || interval.Value <= TimeSpan.Zero || KeepAliveFrameProvider is null)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
Expand All @@ -461,16 +522,21 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken
{
// Normal server shutdown.
}
return;
}
catch

try
{
// Client disconnects are expected for long-lived SSE streams.
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(interval.Value, cancellationToken).ConfigureAwait(false);
var frame = KeepAliveFrameProvider();
await stream.WriteJsonRpcEventAsync(frame, cancellationToken).ConfigureAwait(false);
}
}
finally
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_eventStreams.TryRemove(streamId, out _);
LogRequest(request, (int)HttpStatusCode.OK);
try { context.Response.Close(); } catch { /* ignore */ }
// Normal server shutdown.
}
}

Expand Down
Loading
Loading