diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 1bccffccc..24886f963 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2447,16 +2447,30 @@ 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. - The client then sends `notifications/initialized`; cdidx accepts that - client-to-server notification as a no-op and does not synthesize a - server-origin copy (#4433). HTTP sessions can receive opt-in keep-alive notifications + Its capability object contains only server-provided `tools`, `resources`, + `prompts`, and `logging`; client-provided `roots` and `sampling` are never + advertised as server capabilities. The client then sends + `notifications/initialized` to complete the handshake. When the negotiated + protocol supports roots, the client advertised `roots`, and the transport can + carry server-to-client requests, cdidx follows that notification with a + `roots/list` request; it sends no roots request when the capability is absent. + That handshake-triggered refresh is coalesced to one task per session. + Transport teardown drains it and retains the final stdio writer/disposal + barrier even when the bounded drain expires, so repeated initialized + notifications cannot accumulate detached client requests or race transport + resource disposal. + cdidx does not synthesize a + server-origin `notifications/initialized` copy (#4433). HTTP sessions can receive opt-in keep-alive notifications on `/events` when `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` is configured. 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. - Every request frame must carry an exact `"jsonrpc":"2.0"` member. Except for `initialize`, responded methods are rejected until initialization succeeds - (#4468). EOF, invalid UTF-8, and oversized stdio input share a bounded + (#4468). Exactly one `initialize` may be accepted per session: concurrent or + later attempts receive structured `-32600` / `duplicate_initialize` errors, + and responded methods are also rejected while the session is initializing, + shutting down, or closed (#4848). EOF, invalid UTF-8, and oversized stdio input share a bounded teardown policy: accepted requests receive a grace period, then cancellation, then a post-cancel deadline (#4543). Malformed-input protocol-error writes and asynchronous shutdown-cancellation callbacks are included in those same @@ -2483,8 +2497,9 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into independently (#4545). Base `IMcpTransport` loops do not reserve an outer frame slot, so a single request at `maxConcurrency: 1` acquires `_concurrencyGate` exactly once; single requests and batch items consume slots only at dispatch. -- The advertised capability surface includes `tools`, `resources`, and - `prompts`. `resources/templates/list` advertises +- The advertised capability surface includes only server-provided `tools`, + `resources`, `prompts`, and `logging`; client-provided `roots` and `sampling` + are deliberately omitted. `resources/templates/list` advertises `cdidx://file-path/{path}` for direct, percent-encoded resolution of a known exact repository-relative path; successful reads return the canonical `cdidx://file/` identity. `resources/list` pages indexed files as @@ -2548,16 +2563,23 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into `ProtocolVersion` aligned with its first entry. The lifecycle transport regression test sends the Codex `2025-06-18` handshake through `notifications/initialized` and `tools/list`, rather than testing version - echoing alone. Client identity, caller, - roots, and capabilities are parsed into a detached initialize draft and - committed only after negotiation and complete success-response serialization - finish (#4540); a rejected handshake, `CDIDX_MCP_RESPONSE_MAX_BYTES` - fallback, or serializer failure leaves all established session state - unchanged. A successful commit publishes initialization lifecycle, caller, - client info, capabilities, and roots through one immutable snapshot, so a - concurrent or draining request observes one complete generation. A - `roots/list` refresh publishes only if no newer initialize or roots-change - notification replaced the snapshot while the client response was in flight. + echoing alone. The session advances through explicit pre-initialize, + initializing, initialized, shutting-down, and closed phases (#4848). Client + identity, caller, roots, and capabilities are parsed into a detached + initialize draft and committed only after negotiation and complete + success-response serialization finish (#4540); a rejected handshake, + `CDIDX_MCP_RESPONSE_MAX_BYTES` fallback, or serializer failure rolls the + initializing claim back so one corrected retry can proceed without inheriting + failed metadata. Frame cleanup seals its deferred initialize drafts, so a + timeout-delayed worker that arrives after response serialization releases its + claim instead of leaving the session stuck in the initializing phase. After + the successful commit, every duplicate initialize is rejected and cannot + replace the established session. The commit publishes + initialization lifecycle, caller, client info, capabilities, and roots + through one immutable snapshot, so a concurrent or draining request observes + one complete generation. A `roots/list` refresh publishes only if no + roots-change notification invalidated the snapshot while the client response + was in flight. This guarantee covers the server-side JSON-RPC serialization boundary; HTTP delivery has a separate fail-closed boundary described below. - **Authentication middleware** (#1559). `McpServer` runs every parsed @@ -2580,8 +2602,10 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into body-token gate, failures uniformly return JSON-RPC `-32001 "Unauthorized"` (per #1530 sanitization — the wire never distinguishes missing-from-wrong), and `BuildAuthFailureLog` - emits the detailed reason to stderr. Side-effect-free notifications such as - `notifications/initialized` may short-circuit without authentication. + emits the detailed reason to stderr. The handshake-control + `notifications/initialized` may short-circuit without + authentication; any roots request it triggers is constrained by the capability + snapshot committed from the authenticated initialize request. State-changing notifications (`$/cancelRequest`, `notifications/cancelled`, `notifications/roots/list_changed`, `notifications/shutdown`, and `notifications/exit`) pass through the gate before mutating cancellation, @@ -5405,16 +5429,16 @@ sequenceDiagram ### フェーズ5 — MCP パス: `cdidx mcp` -`initialize` の client identity、caller、roots、capabilities は frame-local な draft に保持し、protocol 交渉と success response の serialization が完了した後だけ commit する(#4540)。拒否された handshake、`CDIDX_MCP_RESPONSE_MAX_BYTES` fallback、serializer failure は確立済み session state を変更せず、修正した retry に失敗 request の metadata を引き継がせない。成功した commit は initialization lifecycle、caller、client info、capabilities、roots を単一の immutable snapshot として公開するため、並行中または drain 中の request は完全な 1 世代だけを参照する。`roots/list` refresh は client response の待機中に新しい initialize または roots-change notification が snapshot を置き換えていない場合だけ公開される。この保証は server-side JSON-RPC serialization の境界に適用され、HTTP 配送には後述する別の fail-closed 境界がある。 +session は明示的な初期化前、初期化中、初期化済み、shutdown 中、closed の各 phase を進む(#4848)。`initialize` の client identity、caller、roots、capabilities は frame-local な draft に保持し、protocol 交渉と success response の serialization が完了した後だけ commit する(#4540)。拒否された handshake、`CDIDX_MCP_RESPONSE_MAX_BYTES` fallback、serializer failure は初期化の取得を取り消すため、失敗 request の metadata を引き継がない修正済み retry を行える。frame cleanup は deferred initialize draft を seal するため、response serialization 後に timeout で遅れて到着した worker も claim を解放し、session を初期化中 phase に残留させない。commit 成功後の重複 `initialize` はすべて拒否され、確立済み session を置き換えられない。成功した commit は initialization lifecycle、caller、client info、capabilities、roots を単一の immutable snapshot として公開するため、並行中または drain 中の request は完全な 1 世代だけを参照する。`roots/list` refresh は client response の待機中に roots-change notification が snapshot を無効化していない場合だけ公開される。この保証は server-side JSON-RPC serialization の境界に適用され、HTTP 配送には後述する別の fail-closed 境界がある。 `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` を `cdidx mcp` にパイプすると別のコードパスが走る: - `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` 文字列を返す。その後 client が `notifications/initialized` を送信し、cdidx は client-to-server 通知として no-op で受理するが、server 側から同じ通知を生成しない(#4433)。HTTP session は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` を設定した場合、opt-in の keep-alive notification を `/events` で受け取れる。HTTP transport では out-of-band 通知は接続済みの `/events` SSE stream にだけ配送され、POST のみのクライアントは initialize response だけを受け取り、別通知 frame は受け取らない。 -- すべての request frame は厳密な `"jsonrpc":"2.0"` member を持つ必要があり、`initialize` 以外の応答対象 method は初期化成功まで拒否される(#4468)。stdio の EOF、不正 UTF-8、oversized input は、grace period、cancellation、post-cancel deadline の共通 bounded teardown を使う(#4543)。不正入力の protocol-error write と非同期 shutdown-cancellation callback も同じ deadline に含めるため、writer、write gate、callback の停止で teardown が無期限に残らない。`notifications/shutdown` は read と request action を cancel するが、起点の transport completion(HTTP では `204 No Content`)は cancel しない。初回 drain snapshot 後に開始した callback と concurrent loop の全終了経路も bounded drain に含める。stdio input は速やかに close し、output dispose は response writer に到達し得る accepted task の完了まで defer する。最終 diagnostic には未完了カテゴリごとの状態を記録し、外部 transport または process cancellation はどちらの cleanup window も中断できる。 +- `initialize` レスポンスは `protocolVersion`、`capabilities`、`serverInfo.name`、`serverInfo.version`(`ConsoleUi.LoadVersion()` — `version.json` が源)、および AI クライアントにツール選択を案内する長い `instructions` 文字列を返す。capability object が公開するのは server 提供の `tools`、`resources`、`prompts`、`logging` だけで、client 提供の `roots` と `sampling` は server capability として公開しない。その後 client が handshake 完了を示す `notifications/initialized` を送信する。交渉済み protocol が roots に対応し、client が `roots` capability を提示し、transport が server-to-client request を配送できる場合、cdidx は `roots/list` request を送る。capability がなければ送信しない。この handshake 起点の refresh は session ごとに1つの task へ集約し、transport teardown で drain する。bounded drain が期限切れになっても最後の stdio writer / disposal barrier で保持するため、`notifications/initialized` が再送されても detached client request は増殖せず、transport resource の dispose と競合しない。server 側から `notifications/initialized` 自体は生成しない(#4433)。HTTP session は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` を設定した場合、opt-in の keep-alive notification を `/events` で受け取れる。HTTP transport では out-of-band 通知は接続済みの `/events` SSE stream にだけ配送され、POST のみのクライアントは initialize response だけを受け取り、別通知 frame は受け取らない。 +- すべての request frame は厳密な `"jsonrpc":"2.0"` member を持つ必要があり、`initialize` 以外の応答対象 method は初期化成功まで拒否される(#4468)。1 session で受理できる `initialize` は1回だけで、並行または後続の試行には構造化された `-32600` / `duplicate_initialize` error を返す。初期化中、shutdown 中、closed の phase でも応答対象 method を拒否する(#4848)。stdio の EOF、不正 UTF-8、oversized input は、grace period、cancellation、post-cancel deadline の共通 bounded teardown を使う(#4543)。不正入力の protocol-error write と非同期 shutdown-cancellation callback も同じ deadline に含めるため、writer、write gate、callback の停止で teardown が無期限に残らない。`notifications/shutdown` は read と request action を cancel するが、起点の transport completion(HTTP では `204 No Content`)は cancel しない。初回 drain snapshot 後に開始した callback と concurrent loop の全終了経路も bounded drain に含める。stdio input は速やかに close し、output dispose は response writer に到達し得る accepted task の完了まで defer する。最終 diagnostic には未完了カテゴリごとの状態を記録し、外部 transport または process cancellation はどちらの cleanup window も中断できる。 - 独立した stdio request と HTTP POST は、設定された MCP request 上限まで並行実行する(#4536)。実行 slot が全て使用中でも read loop は cancellation/client-response frame を受け続ける。accepted-frame backlog は execution 上限 + 64 に別途制限し、超過 request には retry-safe な `-32003` / `server_busy` を返す。request id は protocol/gate 待機前に登録し、execution timeout は slot 取得後に開始し、timeout 後も cancellation を無視して動く action は実際に drain するまで slot を保持する。initialize など session mutation の受信順は protocol barrier で維持し、可変な request state は `AsyncLocal` または request-scoped snapshot に置き、shared writer tool は直列化する。JSON-RPC batch の各 item も同じ global execution slot を個別に消費する(#4545)。基本 `IMcpTransport` loop は outer frame slot を確保しないため、`maxConcurrency: 1` の single request は `_concurrencyGate` を1回だけ取得し、single request と batch item は dispatch 時だけ slot を消費する。 -- advertised capability には `tools`、`resources`、`prompts`、`logging` が含まれる。`resources/list` はインデックス済みファイルを `cdidx://file/` URI としてページングし、世代対応の不透明 keyset cursor を返す。ページ間でインデックス済みファイルが変わった場合は、再開必須の stale-index error を明示的に返す。任意の `maxBytes`(4,096〜1,000,000、既定 1,000,000)で JSON-RPC envelope 全体を制限し、省略件数と継続理由を `_meta.response_controls` に有界な形で返す。`resources/read` は inclusive な `startLine` / `endLine` と UTF-8 本文の `maxBytes`(最小 4 byte、既定 64 KiB、最大 128 KiB)を任意指定として受け付ける。各ページは論理行 1,000 行でも上限化される。成功レスポンスは標準の `contents` item を維持し、`result._meta` に実効範囲、返却 byte 数、切り詰め理由、不透明な `nextCursor` を追加する。継続時は行境界を再送せず、その cursor と任意の新しい `maxBytes` を渡す。cursor は index 済みファイル版に結び付くため、resource 変更後は stale として失敗する。database reader は長い単一行を含め、managed response string を構築する前に incremental SQLite BLOB read で範囲と byte 上限を適用する。server は MCP レスポンス上限と active transport のレスポンス上限のうち小さい方から実効本文 budget を算出し、JSON-RPC envelope と最悪ケースの JSON escape に必要な領域を確保する。1 つの JSON-RPC batch に複数の `resources/read` call がある場合は aggregate frame 上限を共有し、各 item を frame の残り領域に合わせて budget 化する。page 化できない item が割当内に収まらない場合は、元の request ID を保持した構造化 `batch_response_budget_too_small` error に置換する。file metadata の取得、cursor 検証、chunk BLOB 読み取りは単一の deferred SQLite read snapshot 内で実行するため、並行 reindex によって異なる resource 版が混在しない。実際に空の index 済みファイルは空の成功レスポンスを返すが、非空 resource の content 欠落、chunk coverage の不足、安全上限を超える chunk topology は部分的または空の成功として返さず、構造化された `index_missing`、`index_stale`、`index_corrupted` error として失敗する。専用の range partial index がない read-only または immutable な legacy database では、既存の `idx_chunks_file` index を使い、SQLite VM-step budget 内で metadata-only の predecessor / candidate query を実行する。budget 超過時は無制限に scan せず、構造化された `resource_bounded_read_index_unavailable` を返す。stable reason には `resource_content_unavailable`、`resource_bounded_read_index_unavailable`、`resource_chunk_coverage_incomplete`、`chunk_limit_exceeded`、`chunk_candidate_scan_limit_exceeded`、`resource_file_metadata_inconsistent`、`resource_chunk_topology_invalid`、`scan_limit_exceeded` がある。`logging` は MCP `notifications/message` を示し、`logging/setLevel` は `debug`、`info`、`notice`、`warning`、`error`、`critical`、`alert`、`emergency` を受け付ける。 +- advertised capability には server 提供の `tools`、`resources`、`prompts`、`logging` だけが含まれ、client 提供の `roots` と `sampling` は含まれない。`resources/list` はインデックス済みファイルを `cdidx://file/` URI としてページングし、世代対応の不透明 keyset cursor を返す。ページ間でインデックス済みファイルが変わった場合は、再開必須の stale-index error を明示的に返す。任意の `maxBytes`(4,096〜1,000,000、既定 1,000,000)で JSON-RPC envelope 全体を制限し、省略件数と継続理由を `_meta.response_controls` に有界な形で返す。`resources/read` は inclusive な `startLine` / `endLine` と UTF-8 本文の `maxBytes`(最小 4 byte、既定 64 KiB、最大 128 KiB)を任意指定として受け付ける。各ページは論理行 1,000 行でも上限化される。成功レスポンスは標準の `contents` item を維持し、`result._meta` に実効範囲、返却 byte 数、切り詰め理由、不透明な `nextCursor` を追加する。継続時は行境界を再送せず、その cursor と任意の新しい `maxBytes` を渡す。cursor は index 済みファイル版に結び付くため、resource 変更後は stale として失敗する。database reader は長い単一行を含め、managed response string を構築する前に incremental SQLite BLOB read で範囲と byte 上限を適用する。server は MCP レスポンス上限と active transport のレスポンス上限のうち小さい方から実効本文 budget を算出し、JSON-RPC envelope と最悪ケースの JSON escape に必要な領域を確保する。1 つの JSON-RPC batch に複数の `resources/read` call がある場合は aggregate frame 上限を共有し、各 item を frame の残り領域に合わせて budget 化する。page 化できない item が割当内に収まらない場合は、元の request ID を保持した構造化 `batch_response_budget_too_small` error に置換する。file metadata の取得、cursor 検証、chunk BLOB 読み取りは単一の deferred SQLite read snapshot 内で実行するため、並行 reindex によって異なる resource 版が混在しない。実際に空の index 済みファイルは空の成功レスポンスを返すが、非空 resource の content 欠落、chunk coverage の不足、安全上限を超える chunk topology は部分的または空の成功として返さず、構造化された `index_missing`、`index_stale`、`index_corrupted` error として失敗する。専用の range partial index がない read-only または immutable な legacy database では、既存の `idx_chunks_file` index を使い、SQLite VM-step budget 内で metadata-only の predecessor / candidate query を実行する。budget 超過時は無制限に scan せず、構造化された `resource_bounded_read_index_unavailable` を返す。stable reason には `resource_content_unavailable`、`resource_bounded_read_index_unavailable`、`resource_chunk_coverage_incomplete`、`chunk_limit_exceeded`、`chunk_candidate_scan_limit_exceeded`、`resource_file_metadata_inconsistent`、`resource_chunk_topology_invalid`、`scan_limit_exceeded` がある。`logging` は MCP `notifications/message` を示し、`logging/setLevel` は `debug`、`info`、`notice`、`warning`、`error`、`critical`、`alert`、`emergency` を受け付ける。 - `resources/templates/list` は正確な既知 path の直接解決用に `cdidx://file-path/{path}` を公開し、成功した read は canonical な `cdidx://file/` identity を返す。`resources/list` の `path`、`lang`、`includeGenerated` filter は server-side で有界に適用され、継続 cursor は canonical filter と generation の両方に結び付く。generated file は既定で list と read から除外され、明示的な `includeGenerated: true` が必要になる。 - `initialize` の `instructions` はこれらの resource template / list control を直接案内し、各 `resources/list` response は accepted extension parameter と上限を `_meta.discovery_contract` に公開する。これにより AI client は標準外の protocol extension を推測する必要がない。 - `protocolVersion` は**ハードコードではなく交渉**で決まる(#1554)。サーバーは @@ -5431,11 +5455,12 @@ sequenceDiagram client identity、caller、roots、capabilities は切り離した initialize draft へ解析し、 protocol 交渉と success response の serialization が完了した後だけ commit する (#4540)。拒否された handshake、`CDIDX_MCP_RESPONSE_MAX_BYTES` fallback、 - serializer failure は確立済み session state を変更しない。成功時は lifecycle と - すべての metadata を単一の immutable snapshot として同時に公開し、進行中の古い - `roots/list` response は新しい initialize を上書きできない。この保証は server-side + serializer failure は初期化の取得を取り消し、確立済み session state を変更しない。 + 成功時は lifecycle とすべての metadata を単一の immutable snapshot として同時に + 公開する。以後の重複 initialize は拒否され、進行中の古い `roots/list` response は + roots-change notification が snapshot を無効化した場合に公開できない。この保証は server-side JSON-RPC serialization の境界に適用され、HTTP 配送には別の fail-closed 境界がある。 -- **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、stdio では `CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。未設定または空文字の token だけが permissive で、空白のみ・空白文字入り・制御文字入り・4096 文字超の token は設定値として拒否する。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。HTTP はこの body token ゲートを重ねず、`ProgramRunner` が `CDIDX_MCP_HTTP_TOKEN` を優先し、未設定なら `CDIDX_MCP_AUTH_TOKEN` を fallback として bearer secret に解決して、`Authorization: Bearer ...` の transport check に一本化する(#3156)。HTTP bearer 値は `Bearer ` の後ろを trim せず完全一致で扱い、空白文字・制御文字・4096 文字超は hash 前に拒否する。JSON-RPC body token ゲートの失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。副作用のない `notifications/initialized` は認証せず short-circuit できる。一方、state-changing notification(`$/cancelRequest`、`notifications/cancelled`、`notifications/roots/list_changed`、`notifications/shutdown`、`notifications/exit`)は cancellation / roots / lifecycle state を変更する前に認証する。認証失敗時も notification は応答を返さず、bounded な stderr 診断だけを残す(#4537)。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。 +- **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、stdio では `CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。未設定または空文字の token だけが permissive で、空白のみ・空白文字入り・制御文字入り・4096 文字超の token は設定値として拒否する。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。HTTP はこの body token ゲートを重ねず、`ProgramRunner` が `CDIDX_MCP_HTTP_TOKEN` を優先し、未設定なら `CDIDX_MCP_AUTH_TOKEN` を fallback として bearer secret に解決して、`Authorization: Bearer ...` の transport check に一本化する(#3156)。HTTP bearer 値は `Bearer ` の後ろを trim せず完全一致で扱い、空白文字・制御文字・4096 文字超は hash 前に拒否する。JSON-RPC body token ゲートの失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。handshake 制御の `notifications/initialized` は認証せず short-circuit できるが、それによって送る roots request は認証済み initialize で commit した capability snapshot に制限される。一方、state-changing notification(`$/cancelRequest`、`notifications/cancelled`、`notifications/roots/list_changed`、`notifications/shutdown`、`notifications/exit`)は cancellation / roots / lifecycle state を変更する前に認証する。認証失敗時も notification は応答を返さず、bounded な stderr 診断だけを残す(#4537)。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。 MCP は独立したシリアライズ戦略(オブジェクトを JSON などの転送形式に変換する方式のこと。CLI の `--json` 側は .NET 標準の `JsonSerializer` に任せる方式、MCP 側は `JsonObject` を手で組み立てる方式と、別の手段を採っている)を採るため、「そもそもバイナリは走るのか?」を確かめる最も頑健なスモークテスト(デプロイや起動直後に行う、基本動作だけを短時間で確認する簡易テストのこと。詳細な正しさではなく「煙が出ていないか=致命的に壊れていないか」を見るためこの名で呼ばれる)となる — .NET ホスト、`Program.Main`、CLI ルーティング、`ConsoleUi.LoadVersion()` に負荷をかけるが、SQLite には触れない(`search` など MCP の*ツール呼び出し*は SQLite に触れるが、`initialize` 単独では触れない)。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index c504088c9..d2aca4cc1 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -528,12 +528,12 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding MCP JSON-RPC behavior and tool outputs. Large server coverage is split into focused partial suites for tool calls, tool listing, protocol/session handling, and error handling while the root `McpServerTests` part keeps shared seeded fixture state. Request-timeout tests use signal-gated delay hooks instead of fixed sleeps: start the request, confirm the hook has begun, then await the timeout response with a bounded wait so they pay only the configured timeout while still proving in-flight actions drain after the timeout response. The single-request timeout-lease regression uses an ID-specific dispatch signal, a one-second execution timeout for scheduler headroom, typed response-node assertions, and `TestDeterminism.AssertTaskRemainsBlockedAsync` for the queued request. Keep those checks together so full-suite load produces an actionable assertion instead of a null dereference (#4807). The root seeded database/server fixture is initialized through a thread-safe `Lazy` only when a test accesses its default fixture path. Static helpers and tests that build their own server or transport must not pay schema creation and seed cost; concurrent fixture access must still publish exactly one database/server pair. - Protocol negotiation coverage keeps `2025-06-18`, `2025-03-26`, and `2024-11-05` in one shared version-echo fixture. The Codex compatibility regression separately uses the lifecycle-enforcing transport to send a `2025-06-18` initialize, `notifications/initialized`, and `tools/list`, because a direct handler assertion would not catch initialization-gate failures. + Protocol negotiation coverage keeps `2025-06-18`, `2025-03-26`, and `2024-11-05` in one shared version-echo fixture and asserts the exact server-side capability keys for every version. The Codex compatibility regression separately uses the lifecycle-enforcing transport to send a `2025-06-18` initialize, `notifications/initialized`, and `tools/list`, because a direct handler assertion would not catch initialization-gate failures. Transport transcripts must also prove that a second initialize receives `duplicate_initialize` without mutating the session, and that `notifications/initialized` triggers `roots/list` only when the client advertised roots support. Signal-gated coverage must repeat initialized while the first roots response is blocked, prove only one client request starts and teardown drains it, and force both bounded drain deadlines to expire while a late roots write remains blocked to prove stdio resource disposal stays deferred. Release a timeout-delayed initialize worker after its frame cleanup to prove a corrected retry is accepted. Request-id telemetry coverage uses credential-shaped and high-cardinality ids to prove raw values never reach stderr prefixes/events, Activity tags, MCP metrics, audit records, or timeout logs/status. Assert the fixed opaque token plus consistent type and decoded string-value UTF-16 code-unit length (`null` = `0`), different injected process salts, JSON type domain separation for equal textual values, and collapse to one overflow token after the distinct-id budget (including concurrent creation). Keep a CLI metrics negative case that omits all request-id fields. - Keep failed-then-success initialize isolation in the protocol/session suite: assert caller, client info, roots, capabilities, initialization lifecycle, and session ID after negotiation or success-response serialization failure and again after the corrected handshake, so failed metadata cannot poison the accepted session (#4540). Signal-gated reinitialize coverage must also hold a concurrent status reader on its captured snapshot and an in-flight `roots/list` response on its old generation, proving readers see one complete state and stale root refreshes cannot overwrite a newer handshake. + Keep failed-then-success initialize isolation in the protocol/session suite: assert caller, client info, roots, capabilities, initialization lifecycle, and session ID after negotiation or success-response serialization failure and again after the corrected handshake, so failed metadata cannot poison the accepted session (#4540). Signal-gated duplicate-initialize coverage must hold a concurrent status reader on its captured snapshot and an in-flight `roots/list` response from the accepted handshake, proving readers see one complete state and a rejected duplicate cannot replace caller metadata or roots. Stdio response-order tests use the same signal-gated pattern: make the synthetic transport signal the parse-error path instead of sleeping in the response serializer. Stdio request-concurrency tests also use signal gates: prove two requests overlap at `maxConcurrency: 2`, a third waits, and a cancellation frame still bypasses a saturated execution gate. Cover the separate accepted-frame cap, retry-safe overflow, more than 64 registered queued cancellations, and timeout leases that remain held until cancellation-insensitive actions drain. Run a non-concurrent base `IMcpTransport` at `maxConcurrency: 1` to prove frame handling does not double-acquire the execution gate. Initialization-order tests must hold the initialize dispatch explicitly and verify later requests wait for its protocol barrier. - JSON-RPC batch-concurrency tests use ID-aware signal gates: prove `maxConcurrency: 1` cannot deadlock on an outer frame slot, prove the global peak and stable input-order response independently of completion order, and hold initialize, duplicate-ID, cancellation targets, and timed-out live actions explicitly to verify their fence/isolation/lease semantics. Fill and age the general cancellation tombstone cache to prove a queued batch target remains cancellable, then prove a pre-dispatch return releases its queued registration before ID reuse. Do not use wall-clock sleeps. + JSON-RPC batch-concurrency tests use ID-aware signal gates: prove `maxConcurrency: 1` cannot deadlock on an outer frame slot, prove the global peak and stable input-order response independently of completion order, and hold initialize, duplicate initialize, duplicate-ID, cancellation targets, and timed-out live actions explicitly to verify their fence/isolation/lease semantics. A duplicate initialize must retain the accepted generation while preserving the adjacent-request fence. Fill and age the general cancellation tombstone cache to prove a queued batch target remains cancellable, then prove a pre-dispatch return releases its queued registration before ID reuse. Do not use wall-clock sleeps. Transport-teardown tests gate a deliberately cancellation-insensitive request, inject EOF, invalid UTF-8, or an oversized line, and set both drain windows to zero. They must prove malformed-input errors are attempted before shutdown and that every path returns with diagnostics for the actual unfinished request, terminal write, or shutdown callback. When an assertion pins an unfinished-request count, keep every counted request behind its own cancellation-insensitive gate until stderr capture and assertions finish; do not include a cancellation-responsive task whose completion depends on callback scheduling. Cover shutdown beginning after the initial EOF snapshot, base-transport callback and completion drains, external cancellation during an inline control write, the initiating HTTP shutdown POST completing with `204`, and stdio output disposal deferred behind a late writer even when input disposal fails. Include token-ignoring writers, write-gate contention, blocking callbacks, and throwing callbacks; use signal gates rather than wall-clock sleeps. Rate-limit-disabled coverage uses the lightweight `languages` tool for repeated successful calls; do not pay repeated `status` database aggregation cost when the assertion only concerns limiter bypass. `RateLimiterTests.cs` uses an injected deterministic clock for bucket-cap saturation and recovery. Assert that bucket count never exceeds the configured cap, a single-partition cap rejection reports the earliest idle expiry, and advancing exactly to the advertised retry boundary allows a legitimate new bucket. Layered coverage must also use burst 1 with a coarse refill slower than the earliest secondary-cap expiry, proving that a charged coarse token is included in the combined retry boundary. MCP integration coverage proves that one caller-wide coarse quota spans canonical names while known tools retain secondary per-tool partitions, drives missing/non-string/empty/oversized/case-variant/unknown names plus invalid arguments through pre-validation, and verifies that unknown `batch_query` slot names share one fixed bounded partition. @@ -1421,12 +1421,12 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" MCP の JSON-RPC 挙動とツール出力のテスト。大きな server coverage は tool call、tool listing、protocol/session handling、error handling ごとの focused partial suite に分割し、共有の seed 済み fixture 状態は root 側の `McpServerTests` に残します。request-timeout test は固定 sleep ではなく signal-gated delay hook を使います。request を開始し、hook が始まったことを確認してから timeout response を bounded wait で待つことで、timeout response 後に in-flight action が drain されることは保ったまま、設定した timeout 分だけを待つようにします。 single-request の timeout-lease 回帰テストでは、ID 別の dispatch signal、scheduler の余裕を確保する 1 秒の execution timeout、型付き response-node assertion、queue 待ち request に対する `TestDeterminism.AssertTaskRemainsBlockedAsync` を使います。full-suite 負荷でも null 参照ではなく対応可能な assertion を返すよう、これらの検証をまとめて維持してください(#4807)。 root の seed 済み database/server fixture は、test が既定 fixture path へアクセスした場合だけ thread-safe な `Lazy` で初期化します。static helper や独自 server / transport を構築する test は未使用 schema の作成・seed cost を支払わず、並行 fixture access でも database/server pair を必ず1組だけ公開してください。 - protocol negotiation coverage は `2025-06-18`、`2025-03-26`、`2024-11-05` を共通の version-echo fixture にまとめます。Codex 互換性の回帰テストでは別途 lifecycle を強制する transport を使い、`2025-06-18` の initialize、`notifications/initialized`、`tools/list` までを送ります。direct handler の assertion だけでは initialization gate の失敗を検出できないためです。 + protocol negotiation coverage は `2025-06-18`、`2025-03-26`、`2024-11-05` を共通の version-echo fixture にまとめ、全 version で server-side capability の正確な key を検証します。Codex 互換性の回帰テストでは別途 lifecycle を強制する transport を使い、`2025-06-18` の initialize、`notifications/initialized`、`tools/list` までを送ります。direct handler の assertion だけでは initialization gate の失敗を検出できないためです。transport transcript では、2回目の initialize が session を変更せず `duplicate_initialize` を返すことと、client が roots support を提示した場合だけ `notifications/initialized` の後に `roots/list` を送ることも検証してください。signal-gated coverage では最初の roots response を block したまま initialized を再送し、client request が1件だけ開始され teardown で drain されることを確認します。さらに、遅い roots write を block したまま両 bounded drain deadline を期限切れにし、stdio resource の dispose が引き続き defer されることを証明してください。また、frame cleanup 後に timeout で遅れた initialize worker を解放し、修正済み retry が受理されることを証明してください。 request-id telemetry coverage では credential 風および high-cardinality な id を使い、生値が stderr の prefix / event、Activity tag、MCP metrics、audit record、timeout log / status のどこにも出ないことを検証します。固定長 opaque token と、型および decode 後の string 値の UTF-16 code unit 数(`null` は `0`)が全 surface で一致することに加え、注入した process salt ごとの差、text が同じ JSON type 間の domain separation、concurrent creation を含む distinct-id budget 超過後の単一 overflow token への集約を確認してください。CLI metrics では request-id field をすべて省略する negative case を維持してください。 - failed-then-success initialize の分離は protocol/session suite に維持し、交渉失敗または success response の serialization 失敗直後と、修正した handshake 後の caller、client info、roots、capabilities、initialization lifecycle、session ID を検証して、失敗 metadata が受理済み session を汚染できないようにします(#4540)。signal-gated な reinitialize coverage では、並行 status reader を取得済み snapshot で保持し、進行中の `roots/list` response を古い世代で保持することで、reader が完全な 1 state だけを見ることと、stale な root refresh が新しい handshake を上書きできないことも検証します。 + failed-then-success initialize の分離は protocol/session suite に維持し、交渉失敗または success response の serialization 失敗直後と、修正した handshake 後の caller、client info、roots、capabilities、initialization lifecycle、session ID を検証して、失敗 metadata が受理済み session を汚染できないようにします(#4540)。signal-gated な duplicate-initialize coverage では、並行 status reader を取得済み snapshot で保持し、受理済み handshake の進行中 `roots/list` response を保持することで、reader が完全な 1 state だけを見ることと、拒否された重複 initialize が caller metadata や roots を置き換えられないことも検証します。 stdio response-order test も同じ signal-gated pattern を使い、response serializer で sleep する代わりに synthetic transport が parse-error path を signal するようにします。 stdio request-concurrency test も signal gate を使い、`maxConcurrency: 2` で 2 request が実際に overlap し、3 件目が待ち、実行 gate 飽和中も cancellation frame が bypass することを検証します。別枠の accepted-frame 上限、retry-safe な overflow、64 件を超える登録済み queued cancellation、cancellation を無視する action が drain するまで保持される timeout lease も検証してください。non-concurrent な base `IMcpTransport` を `maxConcurrency: 1` で実行し、frame handling が execution gate を二重取得しないことも確認します。initialization-order test は initialize dispatch を明示的に保持し、後続 request が protocol barrier を待つことを確認してください。 - JSON-RPC batch-concurrency test は ID-aware signal gate を使います。`maxConcurrency: 1` で outer frame slot による deadlock が起きないこと、完了順と独立した global peak と入力順 response、initialize・重複 ID・cancellation target・timeout 後も動く action の fence/isolation/lease semantics を、各処理を明示的に保持して検証してください。一般 cancellation tombstone cache を満杯にして TTL も経過させ、queue 待ち batch target が引き続き cancellation を受けることと、dispatch 前の return が queued registration を解放して ID を再利用できることも検証します。wall-clock sleep は使わないでください。 + JSON-RPC batch-concurrency test は ID-aware signal gate を使います。`maxConcurrency: 1` で outer frame slot による deadlock が起きないこと、完了順と独立した global peak と入力順 response、initialize・重複 initialize・重複 ID・cancellation target・timeout 後も動く action の fence/isolation/lease semantics を、各処理を明示的に保持して検証してください。重複 initialize は受理済み generation を保持しながら、隣接 request の fence を維持する必要があります。一般 cancellation tombstone cache を満杯にして TTL も経過させ、queue 待ち batch target が引き続き cancellation を受けることと、dispatch 前の return が queued registration を解放して ID を再利用できることも検証します。wall-clock sleep は使わないでください。 transport teardown test は cancellation を意図的に無視する request を signal gate で保持し、EOF、不正 UTF-8、oversized line を注入して両 drain window を 0 にします。不正入力 error を shutdown 前に試行し、実際に未完了な request、terminal write、shutdown callback ごとの diagnostic を残して全経路が戻ることを検証してください。未完了 request 数を固定する assertion では、stderr の capture と assertion が終わるまで、数に含める全 request をそれぞれ cancellation-insensitive な gate の後ろで保持してください。callback の scheduling によって完了時点が変わる cancellation-responsive な task をその件数に含めてはいけません。初回 EOF snapshot 後に始まる shutdown、base transport の callback/completion drain、inline control write 中の external cancellation、shutdown 起点 HTTP POST の `204` completion、input dispose 失敗時を含む late writer 後まで defer される stdio output dispose を含めます。token を無視する writer、write-gate contention、停止 callback、例外 callback を使い、wall-clock sleep ではなく signal gate で検証してください。 rate-limit-disabled coverage の繰り返し成功 call には軽量な `languages` tool を使い、limiter bypass だけの assertion で `status` の database aggregation cost を繰り返し支払わないでください。 `RateLimiterTests.cs` は bucket 上限の飽和と回復に注入した決定論的 clock を使います。bucket 数が設定上限を超えないこと、単一 partition の上限拒否が最も早い idle expiry を返すこと、通知された retry 境界まで正確に進めると正規の新規 bucket を作成できることを検証してください。layered coverage では burst 1 と、最短 secondary-cap expiry より遅い coarse refill を使い、消費済み coarse token が結合 retry 境界に含まれることも証明してください。MCP integration coverage では 1 つの caller-wide coarse quota が canonical 名をまたぐ一方で既知 tool が secondary per-tool partition を維持することを証明し、missing/non-string/empty/oversized/case-variant/unknown 名と invalid argument を pre-validation に通し、unknown な `batch_query` slot 名が 1 つの固定 bounded partition を共有することを検証してください。 diff --git a/changelog.d/unreleased/4848.fixed.md b/changelog.d/unreleased/4848.fixed.md new file mode 100644 index 000000000..dc5a48867 --- /dev/null +++ b/changelog.d/unreleased/4848.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 4848 +affected: + - src/CodeIndex/Mcp/McpServer.Initialization.cs + - src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs + - src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs + - src/CodeIndex/Mcp/McpServer.Transport.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpAuditLogTests.cs + - tests/CodeIndex.Tests/McpServerProtocolTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **MCP sessions now enforce one initialization and protocol-correct capability direction (#4848)** — The server rejects duplicate initialization without replacing established session state, advertises only server-provided capabilities, and requests client roots after the initialized notification only when roots support was negotiated. Repeated initialized notifications coalesce into one tracked roots refresh that participates in transport teardown. + +## 日本語 + +- **MCP session で初期化を1回に制限し、capability の方向を protocol に合わせました(#4848)** — 確立済み session state を置き換えずに重複初期化を拒否し、server 提供 capability だけを公開します。また、roots support を交渉した場合に限り、initialized notification 後に client roots を要求します。initialized notification が再送されても、追跡対象の roots refresh 1件に集約し、transport teardown で drain します。 diff --git a/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs b/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs index eb39ab006..d2d8d0beb 100644 --- a/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs +++ b/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs @@ -147,6 +147,7 @@ public void Dispose() if (_disposed) return; _disposed = true; + MarkSessionClosed(); CloseSharedDb(); var shutdownCancellationTask = RequestShutdownCancellation(); if (shutdownCancellationTask.IsCompleted) diff --git a/src/CodeIndex/Mcp/McpServer.Initialization.cs b/src/CodeIndex/Mcp/McpServer.Initialization.cs index 809c74874..6c0946fca 100644 --- a/src/CodeIndex/Mcp/McpServer.Initialization.cs +++ b/src/CodeIndex/Mcp/McpServer.Initialization.cs @@ -18,6 +18,14 @@ namespace CodeIndex.Mcp; public partial class McpServer : IDisposable { + private enum McpSessionPhase + { + PreInitialize, + Initializing, + Initialized, + ShuttingDown, + Closed, + } /// /// Handle the initialize handshake. @@ -28,33 +36,63 @@ private JsonNode HandleInitialize( JsonNode? _params, DeferredInitializeCommits? deferredInitializeCommits) { - var negotiated = NegotiateProtocolVersion(_params, out var requestedVersion); - if (negotiated == null) + if (!TryBeginInitialize(out var initializeAttemptId, out var currentPhase)) + return CreateDuplicateInitializeError(id, currentPhase); + + try { - // No overlap between the client's requested version and this server's supported - // set. Issue #1554: respond with structured `-32602` (invalid params) carrying the - // requested + supported versions in `error.data` so clients can branch on it - // instead of guessing why the handshake silently failed. Reject before committing - // any client/session snapshot so a failed re-initialize cannot corrupt the active - // session (#4536, #4540). - // クライアント要求バージョンとサーバー対応集合に重なりがない場合。Issue #1554: - // クライアントが分岐判定できるよう、`error.data` に要求バージョンと対応バージョン - // を入れた -32602 (invalid params) を返す。client/session snapshot の commit 前に - // 拒否し、失敗した re-initialize で有効 session を壊さない (#4536, #4540)。 - DeferFrameLog(BuildUnsupportedProtocolLog(requestedVersion)); - return CreateUnsupportedProtocolError(id, requestedVersion); + var negotiated = NegotiateProtocolVersion(_params, out var requestedVersion); + if (negotiated == null) + { + // No overlap between the client's requested version and this server's supported + // set. Release the initialization claim so a corrected handshake can retry. + // クライアント要求バージョンとサーバー対応集合に重なりがない場合。修正した + // handshake が再試行できるよう initialization claim を解放する。 + ReleaseInitializeAttempt(initializeAttemptId); + DeferFrameLog(BuildUnsupportedProtocolLog(requestedVersion)); + return CreateUnsupportedProtocolError(id, requestedVersion); + } + + // Parse caller-controlled identity, capability, and root metadata into a detached + // draft. None of it becomes observable session state until protocol negotiation and + // complete success-response serialization have both succeeded (#4540). + // caller が制御する identity / capability / root metadata は切り離した draft へ解析する。 + // protocol 交渉と success response の serialization 成功まで公開しない (#4540)。 + var initializeState = BuildInitializeState( + _params, + negotiated, + initializeAttemptId); + var result = new JsonObject + { + ["protocolVersion"] = negotiated, + ["capabilities"] = BuildServerCapabilities(negotiated), + ["serverInfo"] = new JsonObject + { + ["name"] = "cdidx", + ["version"] = _version + }, + // Server instructions — tool-selection guidance for AI clients + // サーバー指示 — AIクライアント向けツール選択ガイダンス + ["instructions"] = BuildInstructions() + }; + var response = CreateSuccessResponse(true, id, result); + if (deferredInitializeCommits is null) + CommitInitializeState(initializeState); + else if (!deferredInitializeCommits.TryRegister(response, initializeState)) + ReleaseInitializeAttempt(initializeAttemptId); + return response; } + catch + { + ReleaseInitializeAttempt(initializeAttemptId); + throw; + } + } - // Parse caller-controlled identity, capability, and root metadata into a detached - // draft. None of it becomes observable session state until protocol negotiation and - // complete success-response serialization have both succeeded (#4540). - // caller が制御する identity / capability / root metadata は切り離した draft へ解析する。 - // protocol 交渉と success response の serialization が完了するまで公開しない (#4540)。 - var initializeState = BuildInitializeState(_params); - var result = new JsonObject + private static JsonObject BuildServerCapabilities(string negotiatedProtocolVersion) + => negotiatedProtocolVersion switch { - ["protocolVersion"] = negotiated, - ["capabilities"] = new JsonObject + "2025-06-18" or "2025-03-26" or "2024-11-05" => new JsonObject { ["tools"] = new JsonObject { @@ -70,28 +108,13 @@ private JsonNode HandleInitialize( ["listChanged"] = false }, ["logging"] = new JsonObject(), - ["roots"] = new JsonObject - { - ["listChanged"] = true - }, - ["sampling"] = new JsonObject() }, - ["serverInfo"] = new JsonObject - { - ["name"] = "cdidx", - ["version"] = _version - }, - // Server instructions — tool-selection guidance for AI clients - // サーバー指示 — AIクライアント向けツール選択ガイダンス - ["instructions"] = BuildInstructions() + _ => throw new InvalidOperationException( + $"Cannot build MCP server capabilities for unsupported protocol version '{negotiatedProtocolVersion}'."), }; - var response = CreateSuccessResponse(true, id, result); - if (deferredInitializeCommits is null) - CommitInitializeState(initializeState); - else - deferredInitializeCommits.Register(response, initializeState); - return response; - } + + private static bool SupportsClientRootsNegotiation(string? negotiatedProtocolVersion) + => negotiatedProtocolVersion is "2025-06-18" or "2025-03-26" or "2024-11-05"; /// /// Build a detached snapshot of caller-controlled initialize metadata. The caller must @@ -99,7 +122,10 @@ private JsonNode HandleInitialize( /// caller が制御する initialize metadata の切り離した snapshot を構築する。呼び出し元は /// protocol 交渉と success response の serialization 成功後に限って commit すること。 /// - private PendingInitializeState BuildInitializeState(JsonNode? initializeParams) + private PendingInitializeState BuildInitializeState( + JsonNode? initializeParams, + string negotiatedProtocolVersion, + long initializeAttemptId) { BoundedMcpText? clientNameDisplay = null; BoundedMcpText? clientVersionDisplay = null; @@ -184,6 +210,8 @@ void AddRoot(string uri) } return new PendingInitializeState( + initializeAttemptId, + negotiatedProtocolVersion, ResolveCallerIdentity(initializeParams), markClientRootsStale, clientNameDisplay, @@ -202,10 +230,14 @@ private void CommitInitializeState(PendingInitializeState state) { lock (_initializeStateGate) { - var committed = BuildCommittedInitializeState( - PublishedInitializeState, - state, - logCallerSwap: true); + var current = PublishedInitializeState; + if (current.Phase != McpSessionPhase.Initializing + || current.InitializeAttemptId != state.InitializeAttemptId) + { + return; + } + + var committed = BuildCommittedInitializeState(state); // One release publication makes lifecycle and all negotiated metadata visible // together; no reader can observe initialized=true with a partial state (#4540). @@ -215,29 +247,118 @@ private void CommitInitializeState(PendingInitializeState state) } } - private InitializeSessionState BuildCommittedInitializeState( - InitializeSessionState previous, - PendingInitializeState state, - bool logCallerSwap) + private bool TryBeginInitialize( + out long initializeAttemptId, + out McpSessionPhase currentPhase) { - var caller = previous.Caller; + lock (_initializeStateGate) + { + var current = PublishedInitializeState; + currentPhase = current.Phase; + if (current.Phase != McpSessionPhase.PreInitialize) + { + initializeAttemptId = 0; + return false; + } - // Caller stickiness: allow upgrading from the default "unknown" bucket to a named - // identity, but reject successful re-initialize attempts that swap named identities. - // caller の sticky 制御: "unknown" から名前付き ID への昇格だけを許可し、成功した - // re-initialize による名前付き ID 同士のスワップは拒否する。 - if (caller == "unknown") + initializeAttemptId = ++_nextInitializeAttemptId; + Volatile.Write( + ref _initializeState, + current with + { + Phase = McpSessionPhase.Initializing, + InitializeAttemptId = initializeAttemptId, + }); + return true; + } + } + + private void ReleaseInitializeAttempt(long initializeAttemptId) + { + lock (_initializeStateGate) { - caller = state.ResolvedCaller; + var current = PublishedInitializeState; + if (current.Phase != McpSessionPhase.Initializing + || current.InitializeAttemptId != initializeAttemptId) + { + return; + } + + Volatile.Write(ref _initializeState, InitializeSessionState.Empty); } - else if (state.ResolvedCaller != caller && state.ResolvedCaller != "unknown" && logCallerSwap) + } + + private JsonObject CreateDuplicateInitializeError( + JsonNode? id, + McpSessionPhase currentPhase) + => CreateErrorResponse( + hasId: true, + id, + code: -32600, + message: currentPhase == McpSessionPhase.Initializing + ? "Invalid request: initialize is already in progress for this session." + : "Invalid request: initialize has already been completed for this session.", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Reuse the negotiated MCP session, or create a new transport session before sending initialize again.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "duplicate_initialize", + ["session_phase"] = FormatSessionPhase(currentPhase), + }); + + private static string FormatSessionPhase(McpSessionPhase phase) + => phase switch { - DeferFrameLog(BuildCallerSwapRejectionLog(caller, state.ResolvedCaller)); + McpSessionPhase.PreInitialize => "pre_initialize", + McpSessionPhase.Initializing => "initializing", + McpSessionPhase.Initialized => "initialized", + McpSessionPhase.ShuttingDown => "shutting_down", + McpSessionPhase.Closed => "closed", + _ => "unknown", + }; + + private void MarkSessionShuttingDown() + { + lock (_initializeStateGate) + { + var current = PublishedInitializeState; + if (current.Phase is McpSessionPhase.ShuttingDown or McpSessionPhase.Closed) + return; + + Volatile.Write( + ref _initializeState, + current with + { + Phase = McpSessionPhase.ShuttingDown, + InitializeAttemptId = 0, + }); } + } + private void MarkSessionClosed() + { + lock (_initializeStateGate) + { + var current = PublishedInitializeState; + Volatile.Write( + ref _initializeState, + current with + { + Phase = McpSessionPhase.Closed, + InitializeAttemptId = 0, + }); + } + } + + private static InitializeSessionState BuildCommittedInitializeState( + PendingInitializeState state) + { return new InitializeSessionState( - true, - caller, + McpSessionPhase.Initialized, + 0, + state.NegotiatedProtocolVersion, + state.ResolvedCaller, state.ClientNameDisplay, state.ClientVersionDisplay, state.ClientCapabilities, @@ -248,11 +369,13 @@ private InitializeSessionState BuildCommittedInitializeState( state.ClientRoots.ToArray(), state.ClientRootDiagnostics.ToArray(), state.ClientRootsTruncated, - state.MarkClientRootsStale || previous.ClientRootsStale); + state.MarkClientRootsStale); } private sealed record InitializeSessionState( - bool Initialized, + McpSessionPhase Phase, + long InitializeAttemptId, + string? NegotiatedProtocolVersion, string Caller, BoundedMcpText? ClientNameDisplay, BoundedMcpText? ClientVersionDisplay, @@ -267,7 +390,9 @@ private sealed record InitializeSessionState( bool ClientRootsStale) { internal static InitializeSessionState Empty { get; } = new( - false, + McpSessionPhase.PreInitialize, + 0, + null, "unknown", null, null, @@ -281,12 +406,15 @@ private sealed record InitializeSessionState( false, true); + internal bool Initialized => Phase == McpSessionPhase.Initialized; internal string? ClientName => ClientNameDisplay?.Text; internal string? ClientVersion => ClientVersionDisplay?.Text; internal int ClientRootCount => ClientRoots.Length; } private sealed record PendingInitializeState( + long InitializeAttemptId, + string NegotiatedProtocolVersion, string ResolvedCaller, bool MarkClientRootsStale, BoundedMcpText? ClientNameDisplay, @@ -374,11 +502,18 @@ private sealed class DeferredInitializeCommits { private readonly object _gate = new(); private readonly List _entries = []; + private bool _sealed; - internal void Register(JsonNode response, PendingInitializeState state) + internal bool TryRegister(JsonNode response, PendingInitializeState state) { lock (_gate) + { + if (_sealed) + return false; + _entries.Add(new Entry(response, state)); + return true; + } } internal bool TryGetRegisteredState(JsonNode response, out PendingInitializeState state) @@ -410,6 +545,15 @@ internal PendingInitializeState[] GetIncludedStates(JsonNode serializedResponse) } } + internal PendingInitializeState[] SealAndGetRegisteredStates() + { + lock (_gate) + { + _sealed = true; + return _entries.Select(static entry => entry.State).ToArray(); + } + } + private static bool IsIncludedResponse(JsonNode serializedResponse, JsonNode candidate) { if (ReferenceEquals(serializedResponse, candidate)) diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs index 0ae5d8bc7..25fc5bf79 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs @@ -409,7 +409,7 @@ private void ApplyBatchFenceState( && deferredInitializeCommits?.TryGetRegisteredState(fenceResponse, out var initializeState) == true) { _frameInitializeState.Value = new FrameInitializeState( - BuildCommittedInitializeState(CurrentInitializeState, initializeState, logCallerSwap: false), + BuildCommittedInitializeState(initializeState), isProvisionalGeneration: true); } else if (_frameInitializeState.Value is { } currentFrameState diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs index 2375d27db..5c406e552 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs @@ -214,11 +214,21 @@ private async Task TryHandleNotificationAsync( } if (method == "notifications/initialized") + { + if (CurrentInitializeState.Phase == McpSessionPhase.Initialized) + StartClientRootsRefreshAfterHandshake(); return true; + } if (method == "notifications/roots/list_changed") { - MarkClientRootsStale(); - _frameInitializeState.Value?.MarkRootsChangeAccepted(); + var state = CurrentInitializeState; + if (state.Phase == McpSessionPhase.Initialized + && state.ClientSupportsRoots + && SupportsClientRootsNegotiation(state.NegotiatedProtocolVersion)) + { + MarkClientRootsStale(); + _frameInitializeState.Value?.MarkRootsChangeAccepted(); + } return true; } @@ -226,6 +236,7 @@ private async Task TryHandleNotificationAsync( || string.Equals(method, "notifications/exit", StringComparison.Ordinal)) { WriteMcpLogLine($"[cdidx-mcp] Received {method}; draining in-flight work and shutting down."); + MarkSessionShuttingDown(); _running = false; _ = RequestShutdownCancellation(); return true; @@ -281,7 +292,29 @@ private Task DispatchRequestMethodAsync( DeferredInitializeCommits? deferredInitializeCommits) { var method = request.Method!; - if (_enforceInitializationLifecycle && !CurrentInitializeState.Initialized && method != "initialize") + var phase = CurrentInitializeState.Phase; + if (phase is McpSessionPhase.ShuttingDown or McpSessionPhase.Closed) + { + return Task.FromResult(CreateErrorResponse( + hasId: true, + id: request.Id, + code: -32002, + message: "Server is shutting down", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Create a new MCP transport session and initialize it before retrying.", + retrySafe: true, + extraData: new JsonObject + { + ["reason"] = phase == McpSessionPhase.Closed + ? "session_closed" + : "session_shutting_down", + ["session_phase"] = FormatSessionPhase(phase), + })); + } + + if (_enforceInitializationLifecycle + && phase is McpSessionPhase.PreInitialize or McpSessionPhase.Initializing + && method != "initialize") { return Task.FromResult(CreateErrorResponse( hasId: true, diff --git a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs index c6024d3fc..250392d8f 100644 --- a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs @@ -804,9 +804,6 @@ private static string BuildClientResponseTooLargeMessage(int bytesWritten) => internal static string BuildRateLimitedLog(string toolName, string caller, long retryAfterMs) => $"[cdidx-mcp] Rate limit exceeded: tool='{BoundToolNameForDisplay(toolName).Text}', caller='{BoundClientIdentityForDisplay(caller).Text}', retry_after_ms={retryAfterMs}. Increase {RateLimiterOptions.RpsEnvVar} / {RateLimiterOptions.BurstEnvVar} on the server, or back off and retry."; - internal static string BuildCallerSwapRejectionLog(string current, string attempted) => - $"[cdidx-mcp] Ignoring re-initialize with new clientInfo identity '{BoundClientIdentityForDisplay(attempted).Text}': retaining original caller '{BoundClientIdentityForDisplay(current).Text}' so rate-limit buckets cannot be reset mid-session."; - internal static string BuildUnknownNotificationLog(string method) => $"[cdidx-mcp] Ignoring unknown notification: {method}"; diff --git a/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs b/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs index a2b5917af..ac0666c3e 100644 --- a/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs +++ b/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs @@ -154,7 +154,12 @@ await _server.DrainInFlightTasksAsync( // bounded EOF drain は late request task を残すことがある。finally が走るまで gate や // stdio writer を使い得るため、drain 自体が異常終了しても aggregate を公開し、全 // accepted task 完了後に gate を dispose する (#3999, #4543)。 - var transportWork = BuildDrainOperationsTask(Tasks, TerminalTransportWriteTask); + // Retain a roots refresh that can be published after the bounded drain snapshot. + // The refresh can still own the stdio writer gate after accepted frame tasks finish. + // bounded drain snapshot 後に公開され得る roots refresh も保持する。accepted frame + // task 完了後も stdio writer gate を所有し得るため、最終 dispose barrier に含める。 + var transportWork = _server.IncludeClientRootsHandshakeRefreshAsync( + BuildDrainOperationsTask(Tasks, TerminalTransportWriteTask)); if (Transport is StdioMcpTransport stdioTransport) stdioTransport.DeferDisposalUntil(transportWork); _ = DisposeConcurrentLoopGatesAfterAsync(transportWork, WriteGate, AdmissionGate); diff --git a/src/CodeIndex/Mcp/McpServer.Transport.cs b/src/CodeIndex/Mcp/McpServer.Transport.cs index 98c5ccc5a..63f43cb4e 100644 --- a/src/CodeIndex/Mcp/McpServer.Transport.cs +++ b/src/CodeIndex/Mcp/McpServer.Transport.cs @@ -475,7 +475,11 @@ internal async Task DrainInFlightTasksAsync( { PruneCompletedRequestTasks(tasks); var shutdownCancellationTask = GetShutdownCancellationTask(); - var drainOperations = BuildDrainOperationsTask(tasks, terminalTransportWriteTask); + var rootsRefreshTask = GetClientRootsHandshakeRefreshTask(); + if (rootsRefreshTask is not null && !tasks.Contains(rootsRefreshTask)) + tasks.Add(rootsRefreshTask); + var drainOperations = IncludeClientRootsHandshakeRefreshAsync( + BuildDrainOperationsTask(tasks, terminalTransportWriteTask)); // A shutdown notification may already have started cancellation before EOF reached this // method. In that case the post-cancel deadline begins immediately and includes callback @@ -586,6 +590,24 @@ private static Task BuildDrainOperationsTask(IReadOnlyCollection tasks, Ta return Task.WhenAll(operations); } + private async Task IncludeClientRootsHandshakeRefreshAsync(Task transportOperations) + { + try + { + await transportOperations.ConfigureAwait(false); + } + finally + { + // A notification task can publish the refresh after the first drain snapshot. Re-read + // it only after all accepted frames settle so teardown always observes that late task. + // notification task は最初の drain snapshot 後に refresh を公開し得る。accepted frame + // 完了後に再取得し、遅れて公開された task も teardown で必ず観測する。 + var rootsRefreshTask = GetClientRootsHandshakeRefreshTask(); + if (rootsRefreshTask is not null) + await rootsRefreshTask.ConfigureAwait(false); + } + } + private async Task AwaitPostCancellationDrainAsync( List tasks, Task drainOperations, @@ -925,6 +947,13 @@ private string BuildOversizedLineErrorResponse(int charactersRead, int utf8Bytes } finally { + // Seal before taking the cleanup snapshot. A timed-out isolated initialize worker + // that registers later is rejected by TryRegister and releases its own claim, so it + // cannot strand the session in Initializing (#4848). + // cleanup snapshot の取得前に seal する。timeout 後に遅れて到着した initialize worker は + // TryRegister で拒否され、自身の claim を解放するため Initializing に残留しない (#4848)。 + foreach (var state in deferredInitializeCommits.SealAndGetRegisteredStates()) + ReleaseInitializeAttempt(state.InitializeAttemptId); frameCorrelationScope?.Dispose(); } } diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 6c4a84841..f4ab99431 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -81,6 +81,14 @@ public partial class McpServer : IDisposable // 処理されることがある。短命の tombstone を置き、登録時に cancel を消費する (#1418)。 private readonly ConcurrentDictionary _pendingRequestCancellations = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _pendingClientRequests = new(StringComparer.Ordinal); + // The initialized notification is a one-shot protocol barrier. Keep its roots refresh + // coalesced and observable by transport teardown so repeated notifications cannot create + // unbounded detached client requests (#4848). + // initialized notification は一度限りの protocol barrier として扱う。roots refresh を集約し、 + // transport teardown から追跡して、通知の再送で detached request が増殖しないようにする (#4848)。 + private readonly object _clientRootsHandshakeRefreshGate = new(); + private Task? _clientRootsHandshakeRefreshTask; + private bool _clientRootsHandshakeRefreshStarted; private readonly object _requestTimeoutDiagnosticsGate = new(); private readonly object _healthStateGate = new(); // Shared writer operations must not use the session DbContext concurrently. This gate is @@ -121,6 +129,7 @@ public partial class McpServer : IDisposable private readonly AsyncLocal _frameInitializeState = new(); private static readonly AsyncLocal CurrentCorrelationContext = new(); private readonly object _initializeStateGate = new(); + private long _nextInitializeAttemptId; private volatile bool _running = true; private volatile bool _enforceInitializationLifecycle; // Zero outside a transport loop. HTTP publishes its configured body cap here so handlers @@ -152,18 +161,18 @@ public partial class McpServer : IDisposable private DateTimeOffset _lastRequestAt; 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 - // `initialize` so a single-session reconnection picks up the new caller identity. + // "which client issued this call?" without a second log source. Captured by the one + // accepted `initialize`; reconnecting clients establish a new server session. // `initialize.clientInfo` を audit に転写し、別ログを引かなくても呼び出し元を辿れるよう - // にする。`initialize` 毎に上書きすることで再接続時に caller identity が追随する。 + // にする。受理される唯一の `initialize` で確定し、再接続時は新しい server session を作る。 // The same snapshot carries the sticky caller used by the per-(tool, caller) limiter. // 同じ snapshot に (tool, caller) 単位の limiter が使う sticky caller も保持する。 // Publish negotiated initialize metadata through one immutable reference. Writers are // serialized by `_initializeStateGate`; readers capture this reference once so a draining - // request cannot combine fields from before and after a successful re-initialize (#4540). + // request cannot observe a partially committed initialization snapshot (#4540, #4848). // initialize で交渉した metadata は単一の immutable reference として公開する。writer は // `_initializeStateGate` で直列化し、reader は reference を一度だけ取得することで、drain 中の - // request が成功した re-initialize の前後の field を混在させない (#4540)。 + // request が部分的に commit された initialization state を観測しない (#4540, #4848)。 private InitializeSessionState _initializeState = InitializeSessionState.Empty; private string _mcpLogLevel = "info"; // Opaque per-server-instance session id copied into suggestion attribution records (#1873). diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 0bfbe7b51..60aca61bb 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1416,7 +1416,10 @@ private static JsonObject BuildMcpIndexOptionsPayload( private async Task RefreshClientRootsIfNeededAsync() { var expectedState = CurrentInitializeState; - if (!expectedState.ClientRootsStale || !HasClientCapability(expectedState, "roots")) + if (expectedState.Phase != McpSessionPhase.Initialized + || !expectedState.ClientRootsStale + || !SupportsClientRootsNegotiation(expectedState.NegotiatedProtocolVersion) + || !HasClientCapability(expectedState, "roots")) return; var result = await SendClientRequestAsync("roots/list", null, _currentRequestToken.Value).ConfigureAwait(false); @@ -1458,6 +1461,43 @@ current with } } + private void StartClientRootsRefreshAfterHandshake() + { + lock (_clientRootsHandshakeRefreshGate) + { + if (_clientRootsHandshakeRefreshStarted) + return; + + _clientRootsHandshakeRefreshStarted = true; + _clientRootsHandshakeRefreshTask = RefreshClientRootsAfterHandshakeAsync(); + } + } + + private Task? GetClientRootsHandshakeRefreshTask() + { + lock (_clientRootsHandshakeRefreshGate) + return _clientRootsHandshakeRefreshTask; + } + + private async Task RefreshClientRootsAfterHandshakeAsync() + { + // Yield before client I/O so the task is published under the coalescing gate before a + // synchronous test/client adapter can block. The execution context retains the active + // out-of-band writer and cancellation token. + // client I/O の前に yield し、同期 adapter が block しても task を coalescing gate 配下へ + // 先に公開する。execution context は out-of-band writer と cancellation token を保持する。 + await Task.Yield(); + try + { + await RefreshClientRootsIfNeededAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or JsonException) + { + WriteMcpLogLine( + $"[cdidx-mcp] Client roots negotiation failed ({ex.GetType().Name}); retaining the stale roots boundary."); + } + } + private bool IsPathWithinClientRoots(string path) { var state = CurrentInitializeState; diff --git a/tests/CodeIndex.Tests/McpAuditLogTests.cs b/tests/CodeIndex.Tests/McpAuditLogTests.cs index bd7c70e04..0b35356a7 100644 --- a/tests/CodeIndex.Tests/McpAuditLogTests.cs +++ b/tests/CodeIndex.Tests/McpAuditLogTests.cs @@ -220,13 +220,12 @@ public void ToolsCall_DisabledTool_EmitsAuditRecordWithToolDisabledError() } [Fact] - public void Initialize_WithoutClientInfo_ClearsStaleCallerFields() + public void Initialize_DuplicateWithoutClientInfoRetainsAcceptedAuditCaller_Issue4848() { - // Regression for #1562 codex review: a reconnect that omits clientInfo must not - // inherit the previous client's name/version on subsequent audit records, since - // that mis-attributes activity to the wrong caller. - // #1562 codex レビュー回帰: clientInfo を省略した再 initialize は、以後の - // audit レコードを直前のクライアント名で記録してはならない。 + // A duplicate initialize is rejected, so subsequent audit records retain the + // caller established by the one accepted handshake (#4848). + // 重複 initialize は拒否されるため、以後の audit record は受理済み handshake + // で確立した caller を保持する(#4848)。 using var sink = new AuditLogSink(_auditPath, AuditLogSink.DefaultMaxBytes, includeValues: false); using var server = CreateServer(sink); @@ -234,16 +233,16 @@ public void Initialize_WithoutClientInfo_ClearsStaleCallerFields() _ = server.HandleMessage(init1); var init2 = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}""")!; - _ = server.HandleMessage(init2); + var duplicate = server.HandleMessage(init2)!; + Assert.Equal(-32600, duplicate["error"]!["code"]!.GetValue()); + Assert.Equal("duplicate_initialize", duplicate["error"]!["data"]!["reason"]!.GetValue()); var ping = JsonNode.Parse("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ping","arguments":{}}}""")!; _ = server.HandleMessage(ping); var record = ReadOnlyRecord(); - Assert.False(record.TryGetProperty("caller", out _), - "caller must be cleared when subsequent initialize omits clientInfo"); - Assert.False(record.TryGetProperty("caller_version", out _), - "caller_version must be cleared when subsequent initialize omits clientInfo"); + Assert.Equal("claude-code", record.GetProperty("caller").GetString()); + Assert.Equal("1.0.0", record.GetProperty("caller_version").GetString()); } [Fact] diff --git a/tests/CodeIndex.Tests/McpServerErrorHandlingTests.cs b/tests/CodeIndex.Tests/McpServerErrorHandlingTests.cs index ff7239c7b..b97c28382 100644 --- a/tests/CodeIndex.Tests/McpServerErrorHandlingTests.cs +++ b/tests/CodeIndex.Tests/McpServerErrorHandlingTests.cs @@ -462,31 +462,6 @@ public void BuildMcpIndexExceptionDiagnostic_RedactsSkippedSizingPathsAndMessage Assert.Contains("", message, StringComparison.Ordinal); } - [Fact] - public void BuildCallerSwapRejectionLog_IsActionable() - { - var log = McpServer.BuildCallerSwapRejectionLog("first-client", "second-client"); - Assert.Contains("Ignoring re-initialize", log); - Assert.Contains("first-client", log); - Assert.Contains("second-client", log); - } - - [Fact] - public void BuildCallerSwapRejectionLog_TruncatesClientInfoIdentities_Issue3120() - { - var current = new string('c', McpBoundedText.MaxClientIdentityChars + 25); - var attempted = new string('a', McpBoundedText.MaxClientIdentityChars + 25); - var currentDisplay = McpBoundedText.ForDisplay(current, McpBoundedText.MaxClientIdentityChars); - var attemptedDisplay = McpBoundedText.ForDisplay(attempted, McpBoundedText.MaxClientIdentityChars); - - var log = McpServer.BuildCallerSwapRejectionLog(current, attempted); - - Assert.DoesNotContain(current, log, StringComparison.Ordinal); - Assert.DoesNotContain(attempted, log, StringComparison.Ordinal); - Assert.Contains(currentDisplay.Text, log); - Assert.Contains(attemptedDisplay.Text, log); - } - [Fact] public void BuildRateLimitedLog_IsActionable() { diff --git a/tests/CodeIndex.Tests/McpServerProtocolTests.cs b/tests/CodeIndex.Tests/McpServerProtocolTests.cs index d832ed84a..10f5b94b1 100644 --- a/tests/CodeIndex.Tests/McpServerProtocolTests.cs +++ b/tests/CodeIndex.Tests/McpServerProtocolTests.cs @@ -110,6 +110,18 @@ public void Initialize_ReturnsSupportedProtocolVersion(string protocolVersion) Assert.Equal(protocolVersion, response["result"]!["protocolVersion"]!.GetValue()); Assert.Equal("cdidx", response["result"]!["serverInfo"]!["name"]!.GetValue()); Assert.Equal(ConsoleUi.LoadVersion(), response["result"]!["serverInfo"]!["version"]!.GetValue()); + + var capabilities = response["result"]!["capabilities"]!.AsObject(); + Assert.Equal( + ["logging", "prompts", "resources", "tools"], + capabilities.Select(static capability => capability.Key).Order(StringComparer.Ordinal).ToArray()); + Assert.False(capabilities["tools"]!["listChanged"]!.GetValue()); + Assert.False(capabilities["resources"]!["subscribe"]!.GetValue()); + Assert.False(capabilities["resources"]!["listChanged"]!.GetValue()); + Assert.False(capabilities["prompts"]!["listChanged"]!.GetValue()); + Assert.NotNull(capabilities["logging"]); + Assert.False(capabilities.ContainsKey("roots")); + Assert.False(capabilities.ContainsKey("sampling")); } [Fact] @@ -139,19 +151,217 @@ public async Task Initialize_CodexProtocolVersion_AllowsToolsList() } [Fact] - public void Initialize_AdvertisesResourcesAndPrompts() + public async Task Initialize_SecondInitializeIsRejectedWithoutMutatingSession_Issue4848() { - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""")!; - var response = _server.HandleMessage(request)!; + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()); + var transport = new QueueMcpTransport( + prependInitialize: false, + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"first-client","version":"1.0"},"capabilities":{}}}""", + """{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"clientInfo":{"name":"second-client","version":"2.0"},"capabilities":{"roots":{}}}}""" + ); - var capabilities = response["result"]!["capabilities"]!; - Assert.False(capabilities["tools"]!["listChanged"]!.GetValue()); - Assert.False(capabilities["resources"]!["subscribe"]!.GetValue()); - Assert.False(capabilities["resources"]!["listChanged"]!.GetValue()); - Assert.False(capabilities["prompts"]!["listChanged"]!.GetValue()); - Assert.NotNull(capabilities["logging"]); - Assert.True(capabilities["roots"]!["listChanged"]!.GetValue()); - Assert.NotNull(capabilities["sampling"]); + await server.RunAsync(transport, CancellationToken.None); + + Assert.Equal(2, transport.WrittenFrames.Count); + Assert.NotNull(JsonNode.Parse(transport.WrittenFrames[0])!["result"]); + var duplicate = JsonNode.Parse(transport.WrittenFrames[1])!; + Assert.Equal(-32600, duplicate["error"]!["code"]!.GetValue()); + Assert.Equal("invalid_request", duplicate["error"]!["data"]!["category"]!.GetValue()); + Assert.Equal("duplicate_initialize", duplicate["error"]!["data"]!["reason"]!.GetValue()); + Assert.Equal("initialized", duplicate["error"]!["data"]!["session_phase"]!.GetValue()); + Assert.Equal("first-client/1.0", server.CurrentCaller); + Assert.False(server.ClientSupportsRootsForTests); + } + + [Fact] + public async Task Initialize_LateTimedOutWorkerReleasesClaimForRetry_Issue4848() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()) + { + RequestTimeout = TimeSpan.FromMilliseconds(20), + }; + var delayStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseDelay = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.RequestDelayForTests = _ => + { + delayStarted.TrySetResult(); + return releaseDelay.Task; + }; + + var timedOutResponseTask = server.ProcessFrameAsync( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"late-client","version":"1.0"},"capabilities":{}}}"""); + try + { + await delayStarted.Task.WaitAsync(TestDeterminism.DefaultTimeout); + var timedOutResponse = JsonNode.Parse( + await timedOutResponseTask.WaitAsync(TestDeterminism.DefaultTimeout) ?? string.Empty)!; + Assert.Equal("timeout", timedOutResponse["error"]!["data"]!["reason"]!.GetValue()); + } + finally + { + server.RequestDelayForTests = null; + releaseDelay.TrySetResult(); + } + + await TestDeterminism.WaitUntilAsync( + () => server.BuildRequestTimeoutDiagnosticsStatus()["isolated_action_draining_count"]!.GetValue() == 0, + "the late initialize worker to drain and release its sealed-frame claim"); + + var retryResponse = JsonNode.Parse( + await server.ProcessFrameAsync( + """{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"clientInfo":{"name":"retry-client","version":"2.0"},"capabilities":{}}}""") + ?? string.Empty)!; + Assert.NotNull(retryResponse["result"]); + Assert.Equal("retry-client/2.0", server.CurrentCaller); + } + + [Fact] + public async Task InitializedNotification_RequestsRootsOnlyWhenClientAdvertisesSupport_Issue4848() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()); + var transport = new RootsNegotiationTranscriptTransport(advertiseRoots: true); + + var runTask = server.RunAsync(transport, CancellationToken.None); + await transport.ReadyToCompleteInput.WaitAsync(TestDeterminism.DefaultTimeout); + await TestDeterminism.WaitUntilAsync( + () => server.ClientRootsForTests.SequenceEqual(["file:///negotiated-workspace"], StringComparer.Ordinal), + "the roots/list response to commit the negotiated client roots"); + transport.CompleteInput(); + await runTask; + + var rootsRequest = JsonNode.Parse(Assert.Single(transport.OutOfBandFrames))!; + Assert.Equal("roots/list", rootsRequest["method"]!.GetValue()); + Assert.Equal(["file:///negotiated-workspace"], server.ClientRootsForTests); + Assert.False(server.ClientRootsStaleForTests); + + var transcript = transport.Transcript; + var initializeResponseIndex = Array.FindIndex( + transcript, + static entry => entry.StartsWith("server:", StringComparison.Ordinal) + && entry.Contains("\"protocolVersion\":\"2025-06-18\"", StringComparison.Ordinal)); + var initializedNotificationIndex = Array.FindIndex( + transcript, + static entry => entry.StartsWith("client:", StringComparison.Ordinal) + && entry.Contains("\"method\":\"notifications/initialized\"", StringComparison.Ordinal)); + var rootsRequestIndex = Array.FindIndex( + transcript, + static entry => entry.StartsWith("server-oob:", StringComparison.Ordinal) + && entry.Contains("\"method\":\"roots/list\"", StringComparison.Ordinal)); + Assert.True(initializeResponseIndex >= 0); + Assert.True(initializedNotificationIndex > initializeResponseIndex); + Assert.True(rootsRequestIndex > initializedNotificationIndex); + } + + [Fact] + public async Task InitializedNotification_DoesNotRequestRootsWithoutClientCapability_Issue4848() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()); + var transport = new RootsNegotiationTranscriptTransport(advertiseRoots: false); + + var runTask = server.RunAsync(transport, CancellationToken.None); + await transport.ReadyToCompleteInput.WaitAsync(TestDeterminism.DefaultTimeout); + Assert.Empty(transport.OutOfBandFrames); + transport.CompleteInput(); + await runTask; + + Assert.Empty(transport.OutOfBandFrames); + Assert.Empty(server.ClientRootsForTests); + } + + [Fact] + public async Task InitializedNotification_CoalescesAndDrainsRootsRefresh_Issue4848() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()); + Assert.NotNull(server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"roots":{}}}}""")!)?["result"]); + + var rootsRequestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var releaseRootsResponse = new ManualResetEventSlim(false); + var rootsRequestCount = 0; + string? requestedMethod = null; + server.ClientRequestHandlerForTests = (method, _) => + { + requestedMethod = method; + Interlocked.Increment(ref rootsRequestCount); + rootsRequestStarted.TrySetResult(); + if (!releaseRootsResponse.Wait(TestDeterminism.DefaultTimeout)) + throw new TimeoutException("Timed out waiting to release the coalesced roots response."); + return new JsonObject + { + ["roots"] = new JsonArray(new JsonObject { ["uri"] = "file:///coalesced-workspace" }), + }; + }; + + Task? drainTask = null; + try + { + Assert.Null(server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""")!)); + await rootsRequestStarted.Task.WaitAsync(TestDeterminism.DefaultTimeout); + for (var index = 0; index < 32; index++) + { + Assert.Null(server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""")!)); + } + + Assert.Equal(1, Volatile.Read(ref rootsRequestCount)); + drainTask = server.DrainInFlightTasksAsync( + [], + TestDeterminism.DefaultTimeout, + TimeSpan.Zero); + Assert.False(drainTask.IsCompleted); + } + finally + { + releaseRootsResponse.Set(); + } + + Assert.NotNull(drainTask); + await drainTask!.WaitAsync(TestDeterminism.DefaultTimeout); + Assert.Equal("roots/list", requestedMethod); + Assert.Equal(["file:///coalesced-workspace"], server.ClientRootsForTests); + Assert.False(server.ClientRootsStaleForTests); + } + + [Fact] + public async Task InitializedNotification_LateRootsRefreshRetainsStdioResourcesAfterBoundedDrain_Issue4848() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()) + { + InFlightDrainGracePeriod = TimeSpan.Zero, + InFlightPostCancelGracePeriod = TimeSpan.Zero, + }; + var inputPayload = Encoding.UTF8.GetBytes( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"roots":{}}}}""" + + "\n" + + """{"jsonrpc":"2.0","method":"notifications/initialized"}""" + + "\n"); + var allowEof = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var input = new GatedEofReadStream(inputPayload, allowEof.Task); + var output = new BlockingSecondWriteStream(); + var transport = new StdioMcpTransport(input, output, bufferSize: 1024 * 1024); + + try + { + var runTask = server.RunAsync(transport, CancellationToken.None); + await output.SecondWriteStarted.WaitAsync(TestDeterminism.DefaultTimeout); + allowEof.TrySetResult(); + await runTask.WaitAsync(TestDeterminism.DefaultTimeout); + + await transport.DisposeAsync(); + Assert.False(output.IsDisposed); + } + finally + { + allowEof.TrySetResult(); + output.ReleaseSecondWrite(); + await transport.DisposeAsync(); + } + + await TestDeterminism.WaitUntilAsync( + () => output.IsDisposed, + "stdio output disposal after the late roots refresh released its writer", + TimeSpan.FromSeconds(15)); } [Fact] @@ -690,7 +900,7 @@ public async Task Initialize_ResponseByteLimitFailureLeavesStateUnchangedThenSuc } [Fact] - public async Task Initialize_ReinitializePublishesCoherentSnapshotToConcurrentStatus_Issue4540() + public async Task Initialize_DuplicateDoesNotReplaceConcurrentSessionSnapshot_Issue4848() { using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()); var initialResponse = server.HandleMessage(JsonNode.Parse(""" @@ -714,11 +924,11 @@ public async Task Initialize_ReinitializePublishesCoherentSnapshotToConcurrentSt var statusTask = Task.Run(() => server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)); - JsonNode reinitializeResponse; + JsonNode duplicateInitializeResponse; try { Assert.True(snapshotCaptured.Wait(TimeSpan.FromSeconds(10)), "Status did not capture its MCP session snapshot."); - reinitializeResponse = server.HandleMessage(JsonNode.Parse(""" + duplicateInitializeResponse = server.HandleMessage(JsonNode.Parse(""" {"jsonrpc":"2.0","id":3,"method":"initialize","params":{ "protocolVersion":"2025-03-26", "clientInfo":{"name":"snapshot-new","version":"2.0"}, @@ -733,7 +943,10 @@ public async Task Initialize_ReinitializePublishesCoherentSnapshotToConcurrentSt releaseSnapshotReader.Set(); } - Assert.NotNull(reinitializeResponse["result"]); + Assert.Equal(-32600, duplicateInitializeResponse["error"]!["code"]!.GetValue()); + Assert.Equal( + "duplicate_initialize", + duplicateInitializeResponse["error"]!["data"]!["reason"]!.GetValue()); var concurrentStatus = await statusTask.WaitAsync(TimeSpan.FromSeconds(10)); var oldSession = concurrentStatus!["result"]!["structuredContent"]!["mcp_session"]!; Assert.Equal("snapshot-old", oldSession["client_info"]!["name"]!.GetValue()); @@ -744,16 +957,16 @@ public async Task Initialize_ReinitializePublishesCoherentSnapshotToConcurrentSt var laterStatus = server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; - var newSession = laterStatus["result"]!["structuredContent"]!["mcp_session"]!; - Assert.Equal("snapshot-new", newSession["client_info"]!["name"]!.GetValue()); - Assert.Equal("2.0", newSession["client_info"]!["version"]!.GetValue()); - Assert.Equal("file:///snapshot-new", Assert.Single(newSession["roots"]!.AsArray())!.GetValue()); - Assert.True(newSession["client_capabilities"]!["experimental"]!["new"]!.GetValue()); - Assert.DoesNotContain("snapshot-old", laterStatus.ToJsonString(), StringComparison.Ordinal); + var retainedSession = laterStatus["result"]!["structuredContent"]!["mcp_session"]!; + Assert.Equal("snapshot-old", retainedSession["client_info"]!["name"]!.GetValue()); + Assert.Equal("1.0", retainedSession["client_info"]!["version"]!.GetValue()); + Assert.Equal("file:///snapshot-old", Assert.Single(retainedSession["roots"]!.AsArray())!.GetValue()); + Assert.True(retainedSession["client_capabilities"]!["experimental"]!["old"]!.GetValue()); + Assert.DoesNotContain("snapshot-new", laterStatus.ToJsonString(), StringComparison.Ordinal); } [Fact] - public async Task Initialize_ReinitializeInvalidatesInFlightRootRefresh_Issue4540() + public async Task Initialize_DuplicateDoesNotReplaceInFlightRootNegotiation_Issue4848() { using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion()); server.HandleMessage(JsonNode.Parse(""" @@ -779,12 +992,12 @@ public async Task Initialize_ReinitializeInvalidatesInFlightRootRefresh_Issue454 }; }; - var indexTask = Task.Run(() => server.HandleMessage(JsonNode.Parse( - """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index","arguments":{"path":"."}}}""")!)); + var rootsNegotiationTask = Task.Run(() => server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""")!)); try { Assert.True(rootsRequestStarted.Wait(TimeSpan.FromSeconds(10)), "The roots/list request did not start."); - var reinitializeResponse = server.HandleMessage(JsonNode.Parse(""" + var duplicateInitializeResponse = server.HandleMessage(JsonNode.Parse(""" {"jsonrpc":"2.0","id":3,"method":"initialize","params":{ "protocolVersion":"2025-03-26", "clientInfo":{"name":"root-refresh-new","version":"2.0"}, @@ -792,17 +1005,22 @@ public async Task Initialize_ReinitializeInvalidatesInFlightRootRefresh_Issue454 "rootUri":"file:///root-refresh-new" }} """)!)!; - Assert.NotNull(reinitializeResponse["result"]); + Assert.Equal(-32600, duplicateInitializeResponse["error"]!["code"]!.GetValue()); + Assert.Equal( + "duplicate_initialize", + duplicateInitializeResponse["error"]!["data"]!["reason"]!.GetValue()); } finally { releaseRootsResponse.Set(); } - var indexResponse = await indexTask.WaitAsync(TimeSpan.FromSeconds(10)); - Assert.True(indexResponse!["result"]!["isError"]!.GetValue()); - Assert.Equal(["file:///root-refresh-new"], server.ClientRootsForTests); - Assert.DoesNotContain("stale-root-refresh", indexResponse.ToJsonString(), StringComparison.Ordinal); + Assert.Null(await rootsNegotiationTask.WaitAsync(TimeSpan.FromSeconds(10))); + await TestDeterminism.WaitUntilAsync( + () => server.ClientRootsForTests.SequenceEqual(["file:///stale-root-refresh"], StringComparer.Ordinal), + "the accepted handshake roots response to update the retained session"); + Assert.Equal(["file:///stale-root-refresh"], server.ClientRootsForTests); + Assert.Equal("root-refresh-old/1.0", server.CurrentCaller); } [Fact] @@ -1110,27 +1328,19 @@ public void Initialize_UpgradesFromUnknownToNamedCaller() } [Fact] - public void Initialize_NamedCallerIsSticky_RejectsReIdentifySwap() + public void Initialize_DuplicateKeepsOriginalCallerIdentity_Issue4848() { - // Once a named caller has been captured, re-initialize() under a *different* name - // is ignored so a networked MCP session cannot reset its rate-limit bucket simply - // by re-initializing under a fresh identity. The retained name continues to key - // all subsequent (tool, caller) buckets (#1560 DoS vector). - // 名前付き caller の取得後は、別名での再 initialize() を無視し、レート制限バケットを - // リセットする経路を塞ぐ(#1560 DoS)。 + // A second initialize is a protocol error and cannot reset the rate-limit identity. + // 2 回目の initialize は protocol error となり、rate-limit identity を変更できない。 _server.HandleMessage(JsonNode.Parse( """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"first-client"}}}""")!); Assert.Equal("first-client", _server.CurrentCaller); - // Re-init under a different name is ignored / 別名は無視 - _server.HandleMessage(JsonNode.Parse( - """{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"clientInfo":{"name":"second-client"}}}""")!); - Assert.Equal("first-client", _server.CurrentCaller); + var duplicate = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"clientInfo":{"name":"second-client"}}}""")!)!; - // Re-init with empty clientInfo (resolves to "unknown") also cannot downgrade / - // 空の clientInfo("unknown" に解決)でも降格しない。 - _server.HandleMessage(JsonNode.Parse( - """{"jsonrpc":"2.0","id":3,"method":"initialize","params":{}}""")!); + Assert.Equal(-32600, duplicate["error"]!["code"]!.GetValue()); + Assert.Equal("duplicate_initialize", duplicate["error"]!["data"]!["reason"]!.GetValue()); Assert.Equal("first-client", _server.CurrentCaller); } @@ -1312,4 +1522,153 @@ public void ProcessFrame_InvalidTopLevelRequestWithoutRecoverableId_ReturnsInval Assert.Equal("invalid_request", response["error"]!["data"]!["category"]!.GetValue()); AssertJsonNullId(response); } + + private sealed class RootsNegotiationTranscriptTransport : IMcpTransport, IOutOfBandMcpTransport + { + private readonly bool _advertiseRoots; + private readonly object _transcriptGate = new(); + private readonly List _transcript = []; + private readonly TaskCompletionSource _rootsRequest = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _readyToCompleteInput = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _completeInput = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _readCount; + + internal RootsNegotiationTranscriptTransport(bool advertiseRoots) + { + _advertiseRoots = advertiseRoots; + } + + public string Name => "roots-transcript"; + public string Endpoint => "memory://roots-transcript"; + internal List OutOfBandFrames { get; } = []; + internal Task ReadyToCompleteInput => _readyToCompleteInput.Task; + + internal string[] Transcript + { + get + { + lock (_transcriptGate) + return _transcript.ToArray(); + } + } + + public async Task ReadFrameAsync(CancellationToken cancellationToken) + { + string? frame; + switch (Interlocked.Increment(ref _readCount)) + { + case 1: + frame = _advertiseRoots + ? """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}}""" + : """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"""; + break; + case 2: + frame = """{"jsonrpc":"2.0","method":"notifications/initialized"}"""; + break; + case 3 when _advertiseRoots: + var rootsRequest = await _rootsRequest.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + frame = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = rootsRequest["id"]!.DeepClone(), + ["result"] = new JsonObject + { + ["roots"] = new JsonArray( + new JsonObject { ["uri"] = "file:///negotiated-workspace" }), + }, + }.ToJsonString(); + break; + case 3: + case 4: + _readyToCompleteInput.TrySetResult(); + await _completeInput.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + frame = null; + break; + default: + frame = null; + break; + } + + if (frame is not null) + Record("client:" + frame); + return frame; + } + + public Task WriteFrameAsync(string? frame, CancellationToken cancellationToken) + { + if (frame is not null) + Record("server:" + frame); + return Task.CompletedTask; + } + + public Task WriteOutOfBandFrameAsync(string frame, CancellationToken cancellationToken) + { + lock (_transcriptGate) + OutOfBandFrames.Add(frame); + Record("server-oob:" + frame); + _rootsRequest.TrySetResult(JsonNode.Parse(frame)!); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + internal void CompleteInput() => _completeInput.TrySetResult(); + + private void Record(string entry) + { + lock (_transcriptGate) + _transcript.Add(entry); + } + } + + private sealed class GatedEofReadStream(byte[] payload, Task allowEof) : MemoryStream(payload) + { + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + var bytesRead = await base.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (bytesRead != 0) + return bytesRead; + + await allowEof.WaitAsync(cancellationToken).ConfigureAwait(false); + return 0; + } + } + + private sealed class BlockingSecondWriteStream : MemoryStream + { + private readonly TaskCompletionSource _secondWriteStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseSecondWrite = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _writeCount; + + internal Task SecondWriteStarted => _secondWriteStarted.Task; + internal bool IsDisposed { get; private set; } + + internal void ReleaseSecondWrite() => _releaseSecondWrite.TrySetResult(); + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _writeCount) == 2) + { + _secondWriteStarted.TrySetResult(); + await _releaseSecondWrite.Task.ConfigureAwait(false); + } + + await base.WriteAsync(buffer, CancellationToken.None).ConfigureAwait(false); + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 37f1276a2..b864b88ac 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5831,7 +5831,7 @@ public async Task ProcessFrameAsync_BatchItemsOverlapWithinLimitAndResponsesPres } [Fact] - public async Task RunAsync_StdioBatchInitializeFencesAdjacentRequests_Issue4545() + public async Task RunAsync_StdioBatchDuplicateInitializeFencesAdjacentRequests_Issue4545_Issue4848() { using var server = new McpServer( _dbPath, @@ -5899,11 +5899,14 @@ await Task.WhenAll(precedingPingStarted.Task, transport.EndOfInputRead) var responseText = Assert.Single(transport.WrittenFrames, static frame => frame is not null); var responses = Assert.IsType(JsonNode.Parse(responseText!)); Assert.Equal([454520, 454521, 454522], responses.Select(static response => response!["id"]!.GetValue()).ToArray()); - Assert.All(responses, static response => Assert.NotNull(response!["result"])); + Assert.NotNull(responses[0]!["result"]); + Assert.Equal(-32600, responses[1]!["error"]!["code"]!.GetValue()); Assert.Equal( - "batch-fence-client", - responses[2]!["result"]!["structuredContent"]!["mcp_session"]!["client_info"]!["name"]!.GetValue()); - Assert.Equal("batch-fence-client", server.CurrentCaller); + "duplicate_initialize", + responses[1]!["error"]!["data"]!["reason"]!.GetValue()); + Assert.NotNull(responses[2]!["result"]); + Assert.Null(responses[2]!["result"]!["structuredContent"]!["mcp_session"]!["client_info"]); + Assert.Equal("unknown", server.CurrentCaller); } [Fact] @@ -7160,7 +7163,7 @@ public async Task ProcessFrameAsync_BatchTimedOutActionRetainsConcurrencyLeaseUn } [Fact] - public async Task ProcessFrameAsync_BatchInitializeDoesNotFlowBackIntoTimedOutPriorGeneration_Issue4540_Issue4545() + public async Task ProcessFrameAsync_BatchDuplicateInitializeDoesNotReplaceTimedOutPriorGeneration_Issue4540_Issue4545_Issue4848() { using var server = new McpServer( _dbPath, @@ -7215,9 +7218,12 @@ public async Task ProcessFrameAsync_BatchInitializeDoesNotFlowBackIntoTimedOutPr var responseText = await batchTask.WaitAsync(TestDeterminism.DefaultTimeout); var responses = Assert.IsType(JsonNode.Parse(responseText!)); Assert.Equal("Request timed out", responses[0]!["error"]!["message"]!.GetValue()); - Assert.NotNull(responses[1]!["result"]); + Assert.Equal(-32600, responses[1]!["error"]!["code"]!.GetValue()); + Assert.Equal( + "duplicate_initialize", + responses[1]!["error"]!["data"]!["reason"]!.GetValue()); Assert.Equal("ok", responses[2]!["result"]!["status"]!.GetValue()); - Assert.Equal("later-generation", server.CurrentCaller); + Assert.Equal("unknown", server.CurrentCaller); } finally {