diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 4aa1c0976d..2867a9cb49 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -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= 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 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`. + +`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= cid=]` で 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 ` を要求します。 + +`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 diff --git a/README.md b/README.md index 130c297a9f..e3311ad447 100644 --- a/README.md +++ b/README.md @@ -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 --project <name|path> filters for indexing and queries, plus `--solution ` 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 `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` 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`. | @@ -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` を使った --project <name|path> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution ` を指定します。 | -| 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 `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | | 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 を出力します。 | diff --git a/changelog.d/unreleased/1740.added.md b/changelog.d/unreleased/1740.added.md new file mode 100644 index 0000000000..251e2e6fd7 --- /dev/null +++ b/changelog.d/unreleased/1740.added.md @@ -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 を送信します。 diff --git a/changelog.d/unreleased/1791.added.md b/changelog.d/unreleased/1791.added.md new file mode 100644 index 0000000000..9796ee9c78 --- /dev/null +++ b/changelog.d/unreleased/1791.added.md @@ -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` で提供します。 diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 8c68b0cc76..8fad322822 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -75,6 +75,12 @@ internal HttpMcpTransport(string prefix, string host, int boundPort, string? bea internal Func? OutOfBandFrameHandler { get; set; } + internal Func? HealthJsonProvider { get; set; } + + internal TimeSpan? KeepAliveInterval { get; set; } + + internal Func? KeepAliveFrameProvider { get; set; } + /// /// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports /// (port `0`) are resolved up-front by binding a temporary so the @@ -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)) @@ -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; @@ -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); @@ -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. } } diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index c705c32fc7..6864093f9e 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -89,6 +89,12 @@ public partial class McpServer : IDisposable // (ファイルハンドル / rotation) は ProgramRunner 側で所有する。 private readonly AuditLogSink? _auditLog; private readonly TimeSpan _requestTimeout; + private readonly TimeSpan? _keepAliveInterval; + private readonly DateTimeOffset _startedAt = DateTimeOffset.UtcNow; + private DateTimeOffset _lastRequestAt = DateTimeOffset.UtcNow; + private DateTimeOffset? _lastDbCheckAt; + private bool? _lastDbCheckOk; + private string? _lastDbCheckError; private readonly SemaphoreSlim _textWriterGate = new(1, 1); // `initialize.clientInfo` echoed into every audit record so the trail can answer // "which client issued this call?" without a second log source. Updated on every @@ -149,6 +155,7 @@ public partial class McpServer : IDisposable internal const int MaxLineByteLength = 1_048_576; internal const int DefaultMaxResponseBytes = 10 * 1024 * 1024; private const string MaxResponseBytesEnvVar = "CDIDX_MCP_RESPONSE_MAX_BYTES"; + private const string KeepAliveIntervalEnvironmentVariable = "CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"; internal const int MaxJsonDepth = 32; internal const int MaxBatchRequestCount = 100; // Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query) @@ -247,6 +254,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func @@ -422,7 +430,12 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella Console.Error.WriteLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {_dbPath}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); if (transport is HttpMcpTransport httpTransport) + { httpTransport.OutOfBandFrameHandler = ProcessFrame; + httpTransport.HealthJsonProvider = BuildHealthJson; + httpTransport.KeepAliveInterval = _keepAliveInterval; + httpTransport.KeepAliveFrameProvider = BuildKeepAliveNotificationJson; + } try { @@ -503,7 +516,12 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella finally { if (transport is HttpMcpTransport httpTransportToClear) + { httpTransportToClear.OutOfBandFrameHandler = null; + httpTransportToClear.HealthJsonProvider = null; + httpTransportToClear.KeepAliveInterval = null; + httpTransportToClear.KeepAliveFrameProvider = null; + } } Console.Error.WriteLine("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); @@ -1051,6 +1069,8 @@ private static void AppendMinimalCorrelationData(StringBuilder builder) suggestion: "Send a JSON-RPC 2.0 object (e.g. {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}).", retrySafe: false); + _lastRequestAt = DateTimeOffset.UtcNow; + // Extract `method` defensively: a non-string `method` (e.g. `"method":42`) must not // throw before the auth gate runs, otherwise a token-protected server would surface // `-32603 "Internal error"` to an unauthenticated caller instead of `-32001 @@ -1151,7 +1171,7 @@ private static void AppendMinimalCorrelationData(StringBuilder builder) "prompts/list" => Task.FromResult(HandlePromptsList(id)), "prompts/get" => Task.FromResult(HandlePromptsGet(id, request["params"])), "logging/setLevel" => Task.FromResult(HandleLoggingSetLevel(id, request["params"])), - "ping" => Task.FromResult(CreateSuccessResponse(hasId, id, new JsonObject())), + "ping" => Task.FromResult(CreateSuccessResponse(hasId, id, BuildHealthResult())), _ => Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Method not found: {method}", category: McpErrorEnvelope.CategoryMethodNotFound, suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/shutdown.", @@ -1159,6 +1179,83 @@ private static void AppendMinimalCorrelationData(StringBuilder builder) }).ConfigureAwait(false); } + private string BuildHealthJson() + => BuildHealthResult().ToJsonString(_jsonOptions); + + private string BuildKeepAliveNotificationJson() + { + var now = DateTimeOffset.UtcNow; + var notification = new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/keep_alive", + ["params"] = new JsonObject + { + ["server_time"] = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["uptime_s"] = Math.Max(0, (long)Math.Floor((now - _startedAt).TotalSeconds)), + } + }; + return notification.ToJsonString(_jsonOptions); + } + + private static TimeSpan? ReadKeepAliveIntervalFromEnvironment() + { + var raw = Environment.GetEnvironmentVariable(KeepAliveIntervalEnvironmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + return null; + if (!double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) + || seconds <= 0) + return null; + return TimeSpan.FromSeconds(seconds); + } + + private JsonObject BuildHealthResult() + { + var now = DateTimeOffset.UtcNow; + var dbOpen = ProbeDbHealth(now, out var dbError); + var result = new JsonObject + { + ["status"] = dbOpen ? "ok" : "degraded", + ["uptime_s"] = Math.Max(0, (long)Math.Floor((now - _startedAt).TotalSeconds)), + ["last_request_at"] = _lastRequestAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["db_open"] = dbOpen, + ["last_db_check_at"] = _lastDbCheckAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["transport_ready"] = _running, + }; + if (!string.IsNullOrWhiteSpace(dbError)) + result["db_error"] = dbError; + return result; + } + + private bool ProbeDbHealth(DateTimeOffset now, out string? error) + { + try + { + var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder + { + DataSource = _dbPath, + Mode = Microsoft.Data.Sqlite.SqliteOpenMode.ReadOnly, + }; + using var connection = new Microsoft.Data.Sqlite.SqliteConnection(builder.ConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1;"; + _ = command.ExecuteScalar(); + _lastDbCheckAt = now; + _lastDbCheckOk = true; + _lastDbCheckError = null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException or InvalidOperationException) + { + _lastDbCheckAt = now; + _lastDbCheckOk = false; + _lastDbCheckError = ex.GetType().Name; + } + + error = _lastDbCheckError; + return _lastDbCheckOk == true; + } + private async Task HandleBatchMessageAsync(JsonArray batch, bool isolateRequestDb) { if (batch.Count == 0) diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 7e5419a071..97a36bd92f 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -109,6 +109,27 @@ public async Task HttpTransport_GetRequest_Returns405() Assert.Contains("POST", response.Content.Headers.Allow); } + [Fact] + public async Task HttpTransport_Healthz_ReturnsStructuredHealth() + { + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + + using var client = new HttpClient(); + using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "healthz")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); + var body = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + Assert.Equal("ok", root.GetProperty("status").GetString()); + Assert.True(root.GetProperty("uptime_s").GetInt64() >= 0); + Assert.True(root.GetProperty("db_open").GetBoolean()); + Assert.True(root.GetProperty("transport_ready").GetBoolean()); + Assert.True(DateTimeOffset.TryParse(root.GetProperty("last_request_at").GetString(), out _)); + Assert.True(DateTimeOffset.TryParse(root.GetProperty("last_db_check_at").GetString(), out _)); + } + [Fact] public async Task HttpTransport_RequestLogger_RecordsMethodStatusDurationAndAuthOutcome() { @@ -244,6 +265,26 @@ public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() Assert.Equal(11, doc.RootElement.GetProperty("id").GetInt32()); } + [Fact] + public async Task HttpTransport_EventsStream_EmitsOptInKeepAliveNotifications() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"); + env.Set("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", "0.05"); + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + + using var client = new HttpClient(); + using var events = await client.GetAsync(new Uri(new Uri(harness.Endpoint), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, events.StatusCode); + + await using var eventStream = await events.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(eventStream, Encoding.UTF8, leaveOpen: true); + + var frame = await ReadUntilAsync(reader, "notifications/keep_alive").WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Contains("\"method\":\"notifications/keep_alive\"", frame, StringComparison.Ordinal); + Assert.Contains("\"uptime_s\":", frame, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_IndexWithProgressToken_EmitsProgressOnEventsStreamAndReturnsResult() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1cfd82820d..7532f7b368 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -256,6 +256,23 @@ public async Task ProcessLineAsync_UsesLfTerminatorEvenWhenWriterNewLineIsCrLf() Assert.DoesNotContain("\r", response); } + [Fact] + public async Task ProcessLineAsync_PingReturnsStructuredHealth() + { + using var writer = new StringWriter(); + + await _server.ProcessLineAsync("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", writer); + + using var document = JsonDocument.Parse(writer.ToString()); + var result = document.RootElement.GetProperty("result"); + Assert.Equal("ok", result.GetProperty("status").GetString()); + Assert.True(result.GetProperty("uptime_s").GetInt64() >= 0); + Assert.True(result.GetProperty("db_open").GetBoolean()); + Assert.True(result.GetProperty("transport_ready").GetBoolean()); + Assert.True(DateTimeOffset.TryParse(result.GetProperty("last_request_at").GetString(), out _)); + Assert.True(DateTimeOffset.TryParse(result.GetProperty("last_db_check_at").GetString(), out _)); + } + [Fact] public async Task ProcessLineAsync_ToolCallEmitsInvocationTelemetry() {