diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 3da9313f86..1253f6eafa 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -138,7 +138,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces `.cdidx` data-directory permissions via `data_dir_mode` on POSIX filesystems. New `.cdidx` data directories are forced to `0700`; the field is omitted on Windows, URI DBs, or when the directory mode cannot be inspected. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. It is omitted on non-Linux hosts, unconstrained processes, or unreadable proc attributes (#1768). -- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, captured `roots`, and optional `client_capabilities`. +- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, captured `roots`, optional `client_info`, and optional `client_capabilities`. - Keep `README.md`, `DEVELOPER_GUIDE.md`, and this file synchronized if this contract changes. ## Reference Extraction diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2e68c63cfa..e31bef8ade 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -794,7 +794,7 @@ Adding `--json-envelope` to a query command (`search`, `definition`, `references Every top-level CLI/MCP JSON DTO (`StatusResult`, `RepoMapResult`, `SymbolAnalysisResult`, `ImpactAnalysisResult`, `OutlineResult`, `FileExcerptResult`, `CompactSearchResult`, `SymbolResult`, `DefinitionResult`, `UnusedSymbolResult`, `ReferenceResult`, `CallerResult`, `CalleeResult`, `FileResult`, `FileFindResult`) carries an `api_version` string field stamped from `JsonOutputContract.ApiVersion`. The same value is mirrored on the `--json-envelope` `metadata` block. This describes the JSON output contract, not the cdidx binary version (which is still surfaced via `version.json` and `cdidx --version`). Bump `JsonOutputContract.ApiVersion` only on **breaking** shape changes — renames, removals, or type changes of an existing field. Additive changes (new optional fields, new readiness flags, new enum values) keep the version stable so older consumers continue to parse the payload. Strict downstream consumers should pin against the major value and degrade gracefully when it changes. Issue #1555. -The documented `status --json` trust contract spans `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `stale_after_seconds`, `index_age_seconds`, plus the fold-only and C# metadata-target-only remediation fields `degraded_reason`, `recommended_action`, `alternative_action`, and MCP-only `mcp_session`. MCP `mcp_session` is session-scoped diagnostics, not persisted DB state, and contains `log_level`, `roots`, and optional `client_capabilities`. Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. +The documented `status --json` trust contract spans `fold_ready`, `fold_ready_reason`, `graph_table_available`, `issues_table_available`, `sql_graph_contract_ready`, `sql_graph_contract_degraded_reason`, `hotspot_family_ready`, `hotspot_family_degraded_reason`, `csharp_symbol_name_ready`, `csharp_metadata_target_ready`, `csharp_metadata_target_degraded_reason`, `indexed_head_commit`, `worktree_head_changed`, `indexed_head_sha`, `indexed_head_branch`, `indexed_head_timestamp`, `commits_ahead_of_indexed_head`, `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`, `unknown_extension_file_count`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `stale_after_seconds`, `index_age_seconds`, plus the fold-only and C# metadata-target-only remediation fields `degraded_reason`, `recommended_action`, `alternative_action`, and MCP-only `mcp_session`. MCP `mcp_session` is session-scoped diagnostics, not persisted DB state, and contains `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. `references` already prefixes each human-readable row with `reference_kind`, and `callers` does the same for its grouped caller rows. When one grouped container mixes kinds (for example `call` and `subscribe` on the same event member), the human-readable label joins the distinct kinds with `+` (for example `call+subscribe`) instead of collapsing to a single preferred label, and the reference-kind column widens dynamically to fit the longest label in the batch so mixed rows do not overrun the neighbouring column. JSON output for `callers` and `callees` keeps the scalar `reference_kind` for back-compat (it reports the preferred summary kind `instantiate` > `subscribe` > `MIN(call)`) and adds a sorted `reference_kinds` array plus a `has_mixed_reference_kinds` bool so consumers can detect mixed containers without trusting a single collapsed label. This lets terminal users distinguish `call` / `instantiate` / `subscribe` / mixed without re-running the command with `--json` and lets AI clients answer mixed-kind questions without chasing a second `--exact` query. @@ -1512,12 +1512,23 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into `serverInfo.name`, `serverInfo.version` (read via `ConsoleUi.LoadVersion()` — the same `version.json` source), and the long `instructions` string that guides AI clients on tool selection. + After that response is written, the server emits one compatibility + `notifications/initialized` ready signal per session so clients that wait for + a server-side ready signal can proceed without optimistic polling (#1780). + MCP also defines `notifications/initialized` as a client-to-server + notification; cdidx still accepts that direction as a no-op, and the + server-origin emission is intentionally limited to this compatibility signal. + On HTTP transport, out-of-band notifications are delivered only to connected + `/events` SSE streams; POST-only clients receive the initialize response but + no separate notification frame. - The advertised capability surface includes `tools`, `resources`, and `prompts`. `resources/list` pages indexed files as `cdidx://file/` URIs and `resources/read` reconstructs file text from indexed chunks. `prompts/list` exposes the built-in `summarize_file`, `find_unused`, and `impact_of_changing` prompts; `prompts/get` returns a user-message template - that directs clients toward the matching cdidx tools. + that directs clients toward the matching cdidx tools. `logging` advertises + MCP `notifications/message`; `logging/setLevel` accepts `debug`, `info`, + `notice`, `warning`, `error`, `critical`, `alert`, and `emergency`. - `protocolVersion` is **negotiated**, not hardcoded (#1554). The server maintains `McpServer.SupportedProtocolVersions` (newest first: `2025-03-26`, `2024-11-05`), reads the client's requested @@ -2851,7 +2862,8 @@ sequenceDiagram - `McpServer` が stdin/stdout を持ち、JSON-RPC 2.0 フレームを解析する。 - レスポンス構築は `JsonSerializer.Serialize(...)` ではなく、`System.Text.Json.Nodes.JsonObject` / `JsonArray` を**手組み**する。これが、トリミング済みバイナリでリフレクションベースのシリアライズが無効でも MCP パスが動き続ける理由。 -- `initialize` レスポンスは `protocolVersion`、`capabilities`、`serverInfo.name`、`serverInfo.version`(`ConsoleUi.LoadVersion()` — `version.json` が源)、および AI クライアントにツール選択を案内する長い `instructions` 文字列を返す。 +- `initialize` レスポンスは `protocolVersion`、`capabilities`、`serverInfo.name`、`serverInfo.version`(`ConsoleUi.LoadVersion()` — `version.json` が源)、および AI クライアントにツール選択を案内する長い `instructions` 文字列を返す。レスポンスを書き終えた後、サーバーはセッションごとに 1 回だけ互換性用の `notifications/initialized` ready signal を送るため、サーバー側の ready signal を待つクライアントも optimistic polling なしで進める(#1780)。MCP は `notifications/initialized` を client-to-server 通知としても定義しており、cdidx はその方向も no-op として受理する。server-origin emission はこの互換性 signal に限定する。HTTP transport では out-of-band 通知は接続済みの `/events` SSE stream にだけ配送され、POST のみのクライアントは initialize response だけを受け取り、別通知 frame は受け取らない。 +- advertised capability には `tools`、`resources`、`prompts`、`logging` が含まれる。`logging` は MCP `notifications/message` を示し、`logging/setLevel` は `debug`、`info`、`notice`、`warning`、`error`、`critical`、`alert`、`emergency` を受け付ける。 - `protocolVersion` は**ハードコードではなく交渉**で決まる(#1554)。サーバーは `McpServer.SupportedProtocolVersions`(新しい順: `2025-03-26`, `2024-11-05`)を保持し、`initialize` パラメータからクライアント要求バージョンを読み取って、対応集合にあればそれを返し(合意)、未指定/非文字列なら既定の最新バージョンに fallback し、対応外なら `error.data` に `requestedVersion` と `supportedVersions` を入れた JSON-RPC `-32602` で拒否する。これにより将来 MCP 仕様が改訂されても、wire format が黙ってずれるのではなく actionable な handshake 失敗として表面化する。配列を新バージョンで更新する際は `ProtocolVersion` を先頭エントリと揃えて意図的に bump する。 - **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、`CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。通知(`notifications/initialized`、`notifications/cancelled`)は応答もエラーコードも持たないため、ゲート *より前* で short-circuit する。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。 diff --git a/README.md b/README.md index 707e11ee07..fdb362a64b 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ file completion. | 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, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. | +| 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`. | | 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`. | @@ -149,7 +149,7 @@ The documented `status --json` trust contract covers these fields: -For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, and optional `client_capabilities`. +For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. `hotspot_family_degraded_reason` uses these values: @@ -312,7 +312,7 @@ cdidx mcp | 検索面 | 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`、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 | +| 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:` 説明を提供します。 | | 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 を出力します。 | @@ -339,7 +339,7 @@ cdidx mcp -MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_capabilities` を含みます。 +MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。 `hotspot_family_degraded_reason` は次の値を使います。 diff --git a/changelog.d/unreleased/1688.fixed.md b/changelog.d/unreleased/1688.fixed.md new file mode 100644 index 0000000000..33522351d4 --- /dev/null +++ b/changelog.d/unreleased/1688.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1688 +affected: + - DEVELOPER_GUIDE.md + - README.md +--- + +## English + +- **MCP logging capability is documented (#1688)** — the developer contract now records that the MCP server advertises `logging`, supports `logging/setLevel`, and can emit `notifications/message`. + +## 日本語 + +- **MCP logging capability を文書化しました (#1688)** — MCP サーバーが `logging` を advertise し、`logging/setLevel` と `notifications/message` に対応することを開発者向け契約に明記しました。 diff --git a/changelog.d/unreleased/1739.fixed.md b/changelog.d/unreleased/1739.fixed.md new file mode 100644 index 0000000000..5115cb6ea4 --- /dev/null +++ b/changelog.d/unreleased/1739.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1739 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md + - AGENT_GUIDE.md + - README.md +--- + +## English + +- **MCP status now surfaces initialize client identity (#1739)** — `status` tool responses include optional `mcp_session.client_info` with the captured `clientInfo.name` and `clientInfo.version` for session diagnostics. + +## 日本語 + +- **MCP status に initialize のクライアント識別情報を出すようになりました (#1739)** — `status` ツール応答の `mcp_session.client_info` に、取得済みの `clientInfo.name` と `clientInfo.version` を診断情報として任意で含めます。 diff --git a/changelog.d/unreleased/1780.fixed.md b/changelog.d/unreleased/1780.fixed.md new file mode 100644 index 0000000000..1f8da0a59f --- /dev/null +++ b/changelog.d/unreleased/1780.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1780 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs + - DEVELOPER_GUIDE.md + - README.md +--- + +## English + +- **MCP initialize now emits the ready notification (#1780)** — after the `initialize` response is written, the server sends one compatibility `notifications/initialized` ready signal per session for clients that wait for a server-side ready signal; HTTP clients receive it through a connected `/events` stream. + +## 日本語 + +- **MCP initialize が ready 通知を送るようになりました (#1780)** — `initialize` レスポンスを書き終えた後、サーバー側の ready signal を待つクライアント向けに、セッションごとに 1 回だけ互換性用の `notifications/initialized` ready signal を送信します。HTTP クライアントは接続済みの `/events` stream から受信します。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 80d92868f9..f1fedbf032 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -65,6 +65,8 @@ public partial class McpServer : IDisposable private readonly AsyncLocal?> _deferredFrameLogs = new(); private static readonly AsyncLocal CurrentCorrelationContext = new(); private volatile bool _running = true; + private bool _initializedNotificationPending; + private bool _initializedNotificationSent; // Per-session DbContext reused across MCP tool calls. Holding the connection open // avoids reopening SQLite, reapplying pragmas, and re-registering every SQL function // on each invocation (issue #1494). @@ -475,6 +477,7 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella } await WriteFrameSafelyAsync(transport, response, loopToken).ConfigureAwait(false); + await EmitInitializedNotificationIfPendingAsync(transport, loopToken).ConfigureAwait(false); FlushDeferredFrameLogs(); // `notifications/shutdown` flips `_running` inside `HandleMessage`; exit the loop @@ -595,6 +598,7 @@ private async Task RunConcurrentFrameLoopAsync(IMcpTransport transport, Cancella try { await WriteFrameSafelyAsync(transport, response, loopToken).ConfigureAwait(false); + await EmitInitializedNotificationIfPendingAsync(transport, loopToken).ConfigureAwait(false); FlushDeferredFrameLogs(); } finally @@ -683,6 +687,7 @@ internal async Task ProcessLineAsync(string line, TextWriter writer) try { await WriteJsonLineAsync(writer, response).ConfigureAwait(false); + await EmitInitializedNotificationIfPendingAsync(writer).ConfigureAwait(false); FlushDeferredFrameLogs(); } finally @@ -721,6 +726,44 @@ private static async Task WriteFrameSafelyAsync(IMcpTransport transport, string? } } + private async Task EmitInitializedNotificationIfPendingAsync(IMcpTransport transport, CancellationToken cancellationToken) + { + var notification = ConsumeInitializedNotification(); + if (notification is null) + return; + if (transport is IOutOfBandMcpTransport outOfBandTransport) + { + await outOfBandTransport.WriteOutOfBandFrameAsync(notification, cancellationToken).ConfigureAwait(false); + return; + } + await WriteFrameSafelyAsync(transport, notification, cancellationToken).ConfigureAwait(false); + } + + private async Task EmitInitializedNotificationIfPendingAsync(TextWriter writer) + { + var notification = ConsumeInitializedNotification(); + if (notification is null) + return; + await WriteJsonLineAsync(writer, notification).ConfigureAwait(false); + } + + private string? ConsumeInitializedNotification() + { + if (!_initializedNotificationPending) + return null; + _initializedNotificationPending = false; + if (_initializedNotificationSent) + return null; + _initializedNotificationSent = true; + var notification = new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/initialized", + ["params"] = new JsonObject() + }; + return notification.ToJsonString(_jsonOptions); + } + private string BuildInvalidUtf8ParseErrorResponse(DecoderFallbackException ex) { DeferFrameLog(BuildInvalidUtf8ErrorLog(ex.Message)); @@ -1431,6 +1474,8 @@ private JsonNode HandleInitialize(JsonNode? id, JsonNode? _params) // サーバー指示 — AIクライアント向けツール選択ガイダンス ["instructions"] = BuildInstructions() }; + if (!_initializedNotificationSent) + _initializedNotificationPending = true; return CreateSuccessResponse(true, id, result); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 7f41bcd97f..d63345c965 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1541,6 +1541,15 @@ private JsonObject BuildMcpSessionStatus() ["log_level"] = _mcpLogLevel, ["roots"] = roots, }; + if (_clientName is not null || _clientVersion is not null) + { + var clientInfo = new JsonObject(); + if (_clientName is not null) + clientInfo["name"] = _clientName; + if (_clientVersion is not null) + clientInfo["version"] = _clientVersion; + session["client_info"] = clientInfo; + } if (_clientCapabilities is not null) session["client_capabilities"] = _clientCapabilities.DeepClone(); return session; diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index a3679ba946..7e5419a071 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -50,6 +50,40 @@ public async Task HttpTransport_PostInitialize_ReturnsHandshakeResult() Assert.Equal("2025-03-26", root.GetProperty("result").GetProperty("protocolVersion").GetString()); } + [Fact] + public async Task HttpTransport_PostInitializeWithoutEventsStream_ReturnsOnlyHandshakeResult() + { + await using var harness = await McpHttpHarness.StartAsync(_dbPath); + + var response = await harness.PostJsonAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("notifications/initialized", body, StringComparison.Ordinal); + using var doc = JsonDocument.Parse(body); + Assert.Equal(1, doc.RootElement.GetProperty("id").GetInt32()); + } + + [Fact] + public async Task HttpTransport_PostInitializeWithEventsStream_EmitsInitializedNotification() + { + 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 initializedTask = ReadUntilAsync(reader, "notifications/initialized"); + + var response = await harness.PostJsonAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var initializedFrame = await initializedTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains("\"method\":\"notifications/initialized\"", initializedFrame, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_PostNotification_Returns204NoContent() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 3860dcd0da..79286769e6 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -285,6 +285,21 @@ public void Initialize_ReturnsProtocolVersion() Assert.Equal(ConsoleUi.LoadVersion(), response["result"]!["serverInfo"]!["version"]!.GetValue()); } + [Fact] + public async Task RunAsync_InitializeEmitsInitializedNotificationAfterResponseOnlyOnce() + { + var transport = new QueueMcpTransport( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"test-client","version":"1.0"}}}""", + """{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"clientInfo":{"name":"test-client","version":"1.0"}}}"""); + + await _server.RunAsync(transport, CancellationToken.None); + + Assert.Equal(3, transport.WrittenFrames.Count); + Assert.Equal(1, JsonNode.Parse(transport.WrittenFrames[0])!["id"]!.GetValue()); + Assert.Equal("notifications/initialized", JsonNode.Parse(transport.WrittenFrames[1])!["method"]!.GetValue()); + Assert.Equal(2, JsonNode.Parse(transport.WrittenFrames[2])!["id"]!.GetValue()); + } + [Fact] public void Initialize_AdvertisesResourcesAndPrompts() { @@ -303,7 +318,7 @@ public void Initialize_AdvertisesResourcesAndPrompts() [Fact] public void Initialize_CapturesClientCapabilitiesAndRootsForSessionStatus() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"experimental":{"progress":true}},"rootUri":"file:///workspace","roots":[{"uri":"file:///workspace/src"}]}}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"codex","version":"5.0"},"capabilities":{"experimental":{"progress":true}},"rootUri":"file:///workspace","roots":[{"uri":"file:///workspace/src"}]}}""")!; _server.HandleMessage(request); Assert.True(_server.ClientCapabilitiesForTests!["experimental"]!["progress"]!.GetValue()); @@ -314,6 +329,8 @@ public void Initialize_CapturesClientCapabilitiesAndRootsForSessionStatus() var session = response["result"]!["structuredContent"]!["mcp_session"]!; Assert.True(session["client_capabilities"]!["experimental"]!["progress"]!.GetValue()); + Assert.Equal("codex", session["client_info"]!["name"]!.GetValue()); + Assert.Equal("5.0", session["client_info"]!["version"]!.GetValue()); Assert.Equal("file:///workspace", session["roots"]!.AsArray()[0]!.GetValue()); Assert.Equal("info", session["log_level"]!.GetValue()); } @@ -9829,6 +9846,38 @@ public ValueTask DisposeAsync() } } + private sealed class QueueMcpTransport : IMcpTransport, IOutOfBandMcpTransport + { + private readonly Queue _frames; + + public QueueMcpTransport(params string[] frames) + { + _frames = new Queue(frames); + } + + public string Name => "memory"; + public string Endpoint => "memory://test"; + public List WrittenFrames { get; } = []; + + public Task ReadFrameAsync(CancellationToken cancellationToken) + => Task.FromResult(_frames.Count == 0 ? null : _frames.Dequeue()); + + public Task WriteFrameAsync(string? frame, CancellationToken cancellationToken) + { + if (frame is not null) + WrittenFrames.Add(frame); + return Task.CompletedTask; + } + + public Task WriteOutOfBandFrameAsync(string frame, CancellationToken cancellationToken) + { + WrittenFrames.Add(frame); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + // The shutdown helper is the heart of the #1573 fix: cancelling the CTS through Console.CancelKeyPress // (and PosixSignal.SIGTERM on Unix) must trip the loop. This test exercises the cross-platform // Ctrl+C path by raising the .NET CancelKeyPress event directly via reflection — the test cannot