Skip to content

Commit 11d6192

Browse files
authored
Merge pull request #2723 from Widthdom/fix-issue1740-1791
Add MCP health and keep-alive probes
2 parents 4df3cc1 + ad2e546 commit 11d6192

8 files changed

Lines changed: 294 additions & 9 deletions

File tree

DEVELOPER_GUIDE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,12 +354,37 @@ Each JSON-RPC MCP request gets a server-generated `correlation_id` in addition t
354354

355355
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.
356356

357+
### MCP health probes
358+
359+
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`.
360+
361+
`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.
362+
363+
### MCP keep-alive notifications
364+
365+
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.
366+
367+
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.
368+
357369
### MCP リクエスト相関
358370

359371
各 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 を各スロットに割り当てます。
360372

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

375+
### MCP ヘルスプローブ
376+
377+
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>` を要求します。
378+
379+
`db_open` は設定された SQLite DB に対する軽量な `SELECT 1` probe です。probe が失敗した場合は `status: "degraded"` を返し、生の filesystem / SQLite 詳細ではなくサニタイズした `db_error` 例外型だけを含めます。
380+
381+
### MCP keep-alive notification
382+
383+
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 を出しません。
384+
385+
各 keep-alive notification は `params``server_time``uptime_s` を含めます。この notification は best-effort であり、切断された SSE client は stream registry から除外され、keep-alive 書き込み失敗で MCP server 自体を終了させてはいけません。
386+
387+
363388
## Database schema
364389

365390
Persisted SHA-256 hashes are lowercase hexadecimal strings. New hash emitters

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ downgrading `cdidx`.
135135
| Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. |
136136
| 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. |
137137
| 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. |
138-
| 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. |
138+
| 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. |
139139
| 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`. |
140140
| 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. |
141141
| 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`. |
@@ -368,7 +368,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し
368368
| 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。 |
369369
| 順位と 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 が常に優先されます。 |
370370
| project scope | `.sln` / `.csproj` を使った <code>--project &lt;name&#124;path&gt;</code> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution <path>` を指定します。 |
371-
| 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:` 説明を提供します。 |
371+
| 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 を使います|
372372
| freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 |
373373
| 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>` は引き続き最優先です。 |
374374
| 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 を出力します。 |
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
category: added
3+
issues:
4+
- 1740
5+
affected:
6+
- src/CodeIndex/Mcp/McpServer.cs
7+
- src/CodeIndex/Mcp/HttpMcpTransport.cs
8+
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
9+
- README.md
10+
- DEVELOPER_GUIDE.md
11+
---
12+
13+
## English
14+
15+
- **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`.
16+
17+
## 日本語
18+
19+
- **HTTP MCP event stream が opt-in の keep-alive notification を送れるようになりました (#1740)**`CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` を設定すると `/events``notifications/keep_alive` frame を送信します。
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
category: added
3+
issues:
4+
- 1791
5+
affected:
6+
- src/CodeIndex/Mcp/McpServer.cs
7+
- src/CodeIndex/Mcp/HttpMcpTransport.cs
8+
- tests/CodeIndex.Tests/McpServerTests.cs
9+
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
10+
- README.md
11+
- DEVELOPER_GUIDE.md
12+
---
13+
14+
## English
15+
16+
- **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.
17+
18+
## 日本語
19+
20+
- **MCP health probe が構造化された liveness 情報を返すようになりました (#1791)**`ping` は health field を返し、HTTP MCP は orchestrator probe 向けに同じ JSON を `GET /healthz` で提供します。

src/CodeIndex/Mcp/HttpMcpTransport.cs

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ internal HttpMcpTransport(string prefix, string host, int boundPort, string? bea
7575

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

78+
internal Func<string>? HealthJsonProvider { get; set; }
79+
80+
internal TimeSpan? KeepAliveInterval { get; set; }
81+
82+
internal Func<string>? KeepAliveFrameProvider { get; set; }
83+
7884
/// <summary>
7985
/// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports
8086
/// (port `0`) are resolved up-front by binding a temporary <see cref="TcpListener"/> so the
@@ -221,6 +227,22 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT
221227
if (!await TryAuthorizeAsync(request).ConfigureAwait(false))
222228
return;
223229

230+
if (IsHealthPath(context.Request.Url?.AbsolutePath))
231+
{
232+
if (!string.Equals(context.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
233+
{
234+
context.Response.AddHeader("Allow", "GET");
235+
await RespondAsync(context, (int)HttpStatusCode.MethodNotAllowed, "MCP health endpoint only accepts GET.\n").ConfigureAwait(false);
236+
LogRequest(request, (int)HttpStatusCode.MethodNotAllowed);
237+
return;
238+
}
239+
240+
var healthJson = HealthJsonProvider?.Invoke() ?? """{"status":"starting","db_open":false}""";
241+
await RespondJsonAsync(context, (int)HttpStatusCode.OK, healthJson).ConfigureAwait(false);
242+
LogRequest(request, (int)HttpStatusCode.OK);
243+
return;
244+
}
245+
224246
if (IsEventsPath(context.Request.Url?.AbsolutePath))
225247
{
226248
if (!string.Equals(context.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
@@ -432,9 +454,29 @@ private static async Task RespondAsync(HttpListenerContext context, int statusCo
432454
}
433455
}
434456

457+
private static async Task RespondJsonAsync(HttpListenerContext context, int statusCode, string body)
458+
{
459+
try
460+
{
461+
context.Response.StatusCode = statusCode;
462+
context.Response.ContentType = "application/json; charset=utf-8";
463+
var bytes = Encoding.UTF8.GetBytes(body);
464+
context.Response.ContentLength64 = bytes.LongLength;
465+
await context.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false);
466+
context.Response.OutputStream.Close();
467+
}
468+
catch
469+
{
470+
try { context.Response.Abort(); } catch { /* ignore */ }
471+
}
472+
}
473+
435474
private static bool IsEventsPath(string? path)
436475
=> string.Equals(path, "/events", StringComparison.Ordinal);
437476

477+
private static bool IsHealthPath(string? path)
478+
=> string.Equals(path, "/healthz", StringComparison.Ordinal);
479+
438480
private async Task RunEventStreamAsync(PendingRequest request, CancellationToken cancellationToken)
439481
{
440482
var context = request.Context;
@@ -453,6 +495,25 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken
453495
await context.Response.OutputStream.WriteAsync(prelude.AsMemory(), cancellationToken).ConfigureAwait(false);
454496
await context.Response.OutputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
455497

498+
await RunKeepAliveLoopAsync(stream, cancellationToken).ConfigureAwait(false);
499+
}
500+
catch
501+
{
502+
// Client disconnects are expected for long-lived SSE streams.
503+
}
504+
finally
505+
{
506+
_eventStreams.TryRemove(streamId, out _);
507+
LogRequest(request, (int)HttpStatusCode.OK);
508+
try { context.Response.Close(); } catch { /* ignore */ }
509+
}
510+
}
511+
512+
private async Task RunKeepAliveLoopAsync(EventStream stream, CancellationToken cancellationToken)
513+
{
514+
var interval = KeepAliveInterval;
515+
if (interval is null || interval.Value <= TimeSpan.Zero || KeepAliveFrameProvider is null)
516+
{
456517
try
457518
{
458519
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
@@ -461,16 +522,21 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken
461522
{
462523
// Normal server shutdown.
463524
}
525+
return;
464526
}
465-
catch
527+
528+
try
466529
{
467-
// Client disconnects are expected for long-lived SSE streams.
530+
while (!cancellationToken.IsCancellationRequested)
531+
{
532+
await Task.Delay(interval.Value, cancellationToken).ConfigureAwait(false);
533+
var frame = KeepAliveFrameProvider();
534+
await stream.WriteJsonRpcEventAsync(frame, cancellationToken).ConfigureAwait(false);
535+
}
468536
}
469-
finally
537+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
470538
{
471-
_eventStreams.TryRemove(streamId, out _);
472-
LogRequest(request, (int)HttpStatusCode.OK);
473-
try { context.Response.Close(); } catch { /* ignore */ }
539+
// Normal server shutdown.
474540
}
475541
}
476542

0 commit comments

Comments
 (0)