diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 65f601b680..6fc4a68c97 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1820,11 +1820,18 @@ return `-32600`. before they are fully buffered. The pending request queue is bounded by `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` (default: 64, maximum: 1,024); full queues return `429 Too Many Requests` - with `Retry-After: 1` instead of retaining unbounded work. Non-positive or - non-numeric environment values fall back to defaults, while values above - the maximum are rejected before listener startup. -- SSE stream lifetime is represented by the active stream registry only; - completed stream tasks are not retained after that registry entry is removed. + with `Retry-After: 1` instead of retaining unbounded work. Accepted context + handler tasks are bounded by `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS` + (default: 64, maximum: 1,024), and concurrent `/events` streams are bounded + by `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS` (default: 16, maximum: 1,024); + saturated limits return `429 Too Many Requests` with `Retry-After: 1`. + Non-positive or non-numeric environment values fall back to defaults, while + values above the maximum are rejected before listener startup. +- SSE stream lifetime is represented by the active stream registry and a + bounded active-stream counter only. Idle streams receive minimal SSE comment + heartbeats so disconnected clients are detected and stream slots are + released; completed stream tasks are not retained after the registry entry + is removed. - `ResolveListenSpec("host:port")` resolves the prefix up-front so the CLI can log the bound port to stderr (`Listening on http://...`). Port `0` is resolved by probing a temporary `TcpListener`; the @@ -1837,13 +1844,15 @@ return `-32600`. HTTP falls back to `CDIDX_MCP_AUTH_TOKEN` as the bearer secret; when both are set, `CDIDX_MCP_HTTP_TOKEN` wins. HTTP clients never need to also send `params.auth.token`. The CLI refuses to bind to a non-loopback host without - either token to keep the MCP catalog off the LAN by default. + either token to keep the MCP catalog off the LAN by default. Configured and + supplied tokens over 4096 characters are rejected before hashing. - Optional request-loop logging: `ProgramRunner` connects `HttpMcpTransport` to `GlobalToolLog`, so persistent logging records one `mcp_http_request` line per HTTP request when the lifecycle log is enabled. The record includes method, path, status, duration, auth outcome, remote peer, correlation id, - and JSON-RPC request id when available; it never includes request or response - bodies. + and JSON-RPC request id when available; caller-controlled method, path, + remote peer, and request id values are capped at 256 characters with a + `...` marker, and it never includes request or response bodies. - Cancellation hooks the `CancellationToken` into `_listener.Stop()` so `GetContextAsync()` unblocks on shutdown; `HttpListenerException` / `ObjectDisposedException` are treated as @@ -3487,11 +3496,11 @@ MCP は独立したシリアライズ戦略(オブジェクトを JSON など `HttpMcpTransport`(同じく #1558)は `System.Net.HttpListener` をラップする: -- HTTP POST 1 件 = JSON-RPC フレーム 1 件で、対応する応答は HTTP レスポンスのボディ(`200 OK` / `application/json; charset=utf-8`)に乗る。通知は `204 No Content`。`GET /events` は将来のサーバー→クライアント frame 用に独立した `text/event-stream` subscription を開く。サーバーは `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification が opt-in された場合を除き、自発的な frame を送信しない。長寿命の event stream は通常の POST リクエストを塞がない。`/` への POST 以外は `405 Method Not Allowed`。空 / 空白のみのボディは stdio の空行と同じ扱いで `204 No Content` を返し、ループは殺さない — クライアントの誤動作で junk フレームに引っかからないため。リクエスト本文は `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`(既定: 1,000,000 bytes、最大: 16,777,216 bytes)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64、最大: 1,024)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。正でない値や数値でない環境変数値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否する。 -- SSE stream lifetime は active stream registry だけで表現し、その registry entry が削除された後に完了済み stream task を保持しない。 +- HTTP POST 1 件 = JSON-RPC フレーム 1 件で、対応する応答は HTTP レスポンスのボディ(`200 OK` / `application/json; charset=utf-8`)に乗る。通知は `204 No Content`。`GET /events` は将来のサーバー→クライアント frame 用に独立した `text/event-stream` subscription を開く。サーバーは `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification が opt-in された場合を除き、自発的な frame を送信しない。長寿命の event stream は通常の POST リクエストを塞がない。`/` への POST 以外は `405 Method Not Allowed`。空 / 空白のみのボディは stdio の空行と同じ扱いで `204 No Content` を返し、ループは殺さない — クライアントの誤動作で junk フレームに引っかからないため。リクエスト本文は `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`(既定: 1,000,000 bytes、最大: 16,777,216 bytes)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64、最大: 1,024)、受理済み context handler task は `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`(既定: 64、最大: 1,024)、同時 `/events` stream は `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS`(既定: 16、最大: 1,024)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。正でない値や数値でない環境変数値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否する。 +- SSE stream lifetime は active stream registry と上限付き active-stream counter だけで表現する。idle stream には最小限の SSE comment heartbeat を送り、切断済み client を検出して stream slot を解放する。その registry entry が削除された後に完了済み stream task を保持しない。 - `ResolveListenSpec("host:port")` は prefix を事前に解決するため、CLI が stderr に `Listening on http://...` を出せる。ポート `0` は一時 `TcpListener` を probe して空きポートを取得する。probe から `HttpListener.Start()` までの TOCTOU window は、本トランスポートが local-only / single-tenant 想定であるため許容する。ワイルドカードホスト `+` / `*` はパース時点で拒否する。 -- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer ` を要求し、定数時間で比較する。`CDIDX_MCP_HTTP_TOKEN` が未設定なら HTTP は `CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` を優先する。HTTP クライアントが `params.auth.token` も送る必要はない。どちらのトークンも未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。 -- 任意のリクエストループログ: `ProgramRunner` は `HttpMcpTransport` を `GlobalToolLog` に接続するため、lifecycle log が有効な場合は HTTP リクエストごとに `mcp_http_request` 行を 1 件記録する。記録内容は method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id で、リクエスト/レスポンス本文は含めない。 +- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer ` を要求し、定数時間で比較する。`CDIDX_MCP_HTTP_TOKEN` が未設定なら HTTP は `CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` を優先する。HTTP クライアントが `params.auth.token` も送る必要はない。どちらのトークンも未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。設定 token と受信 token は 4096 文字を超える場合、hash 前に拒否する。 +- 任意のリクエストループログ: `ProgramRunner` は `HttpMcpTransport` を `GlobalToolLog` に接続するため、lifecycle log が有効な場合は HTTP リクエストごとに `mcp_http_request` 行を 1 件記録する。記録内容は method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id で、caller-controlled な method、path、remote peer、request id は 256 文字を上限に `...` marker 付きで切り詰める。リクエスト/レスポンス本文は含めない。 - キャンセルは `_listener.Stop()` に接続するため、シャットダウン時に `GetContextAsync()` が unblock する。`HttpListenerException` / `ObjectDisposedException` は EOS と同じ扱いで MCP ループを stdin クローズと同じ経路で終了させる。 ワイヤー選択は `ProgramRunner.RunMcp` で行う。`--transport stdio|http` と `--http-listen ` は下流の引数解析より前に取り除かれ、HTTP bearer token 解決は `CDIDX_MCP_HTTP_TOKEN` を先に見て、未設定なら `CDIDX_MCP_AUTH_TOKEN` に fallback する。ディスパッチは旧来の stdio 経路または `RunMcpHttp` に着地する。プラガブルなシームは JSON-RPC 順序不変条件を両トランスポートで同一に保つので、既存の McpServer テスト群(`ProcessLineAsync` を叩く)は引き続きメソッド単位の挙動をカバーし、新トランスポートのワイヤーレベル契約は `HttpMcpTransportTests` がカバーする。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4bdfa0cca0..9db659774a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2108,13 +2108,13 @@ For HTTP, `CDIDX_MCP_HTTP_TOKEN` is the preferred bearer secret. If it is unset, HTTP falls back to `CDIDX_MCP_AUTH_TOKEN` as the bearer secret, and clients still authenticate with `Authorization: Bearer `. -Each HTTP `POST /` carries one JSON-RPC frame in the request body, the matching response is returned in the same HTTP body (`200 OK`, `application/json`), and notifications return `204 No Content`. `GET /events` opens a `text/event-stream` channel for server-to-client frames; the server emits no unsolicited frames unless keep-alive notifications are opted in with `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S`. Accepted keep-alive values are finite seconds from `1` to `300`; invalid or out-of-range values leave keep-alive disabled with a `stderr` warning. The stream is independent and does not block normal POST requests. Non-POST verbs on `/` return `405 Method Not Allowed` with `Allow: POST`. Request bodies are capped at 1,000,000 bytes by default and oversized requests return `413 Payload Too Large`; the pending POST queue is capped at 64 requests by default and full queues return `429 Too Many Requests` with `Retry-After: 1`. Tune those positive-integer limits with `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` and `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`; accepted ranges are `1..16777216` bytes and `1..1024` queued requests. Invalid non-positive or non-numeric values fall back to the defaults, while values above those maximums are rejected before the listener starts. When the persistent lifecycle log is enabled, HTTP mode also writes one `mcp_http_request` record per request with method, path, status, duration, auth outcome, remote peer, correlation id, and JSON-RPC request id when available. Request and response bodies are not logged. +Each HTTP `POST /` carries one JSON-RPC frame in the request body, the matching response is returned in the same HTTP body (`200 OK`, `application/json`), and notifications return `204 No Content`. `GET /events` opens a `text/event-stream` channel for server-to-client frames; the server emits no unsolicited JSON-RPC frames unless keep-alive notifications are opted in with `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S`. Accepted keep-alive values are finite seconds from `1` to `300`; invalid or out-of-range values leave keep-alive disabled with a `stderr` warning. The stream is independent and does not block normal POST requests. Non-POST verbs on `/` return `405 Method Not Allowed` with `Allow: POST`. Request bodies are capped at 1,000,000 bytes by default and oversized requests return `413 Payload Too Large`; the pending POST queue and accepted handler tasks are capped at 64 by default, and concurrent `/events` streams are capped at 16. Full queues, handler pools, or stream slots return `429 Too Many Requests` with `Retry-After: 1`. Tune those positive-integer limits with `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`, `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`, `CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`, and `CDIDX_MCP_HTTP_MAX_EVENT_STREAMS`; accepted ranges are `1..16777216` bytes and `1..1024` for each count limit. Invalid non-positive or non-numeric values fall back to the defaults, while values above those maximums are rejected before the listener starts. Idle event streams receive minimal SSE comment heartbeats so disconnected clients release their stream slots. When the persistent lifecycle log is enabled, HTTP mode also writes one `mcp_http_request` record per request with method, path, status, duration, auth outcome, remote peer, correlation id, and JSON-RPC request id when available. Method, path, remote peer, and request id fields are capped at 256 characters with a `...` marker. Request and response bodies are not logged. Security defaults: - The listener binds to a loopback address (`127.0.0.1`) by default, and the wildcard hosts `+` / `*` are rejected outright. - Binding to a non-loopback host (e.g. `0.0.0.0:9000`) is refused unless you set `CDIDX_MCP_HTTP_TOKEN` or `CDIDX_MCP_AUTH_TOKEN` to a shared secret. `CDIDX_MCP_HTTP_TOKEN` wins when both are set. When an HTTP bearer secret is configured, every request must carry `Authorization: Bearer ` or the listener returns `401 Unauthorized` with `WWW-Authenticate: Bearer realm="cdidx-mcp"`; HTTP clients do not also need `params.auth.token`. -- The configured token's SHA-256 digest is precomputed at start-up; per-request authentication only hashes the supplied input and compares against the stored digest in constant time, so neither the configured token's length nor its bytes leak through timing. +- The configured token's SHA-256 digest is precomputed at start-up; per-request authentication only hashes the supplied input and compares against the stored digest in constant time, so neither the configured token's length nor its bytes leak through timing. Configured and supplied tokens longer than 4096 characters are rejected before hashing. The stdio transport stays byte-for-byte unchanged, so existing client configs keep working without modification. @@ -4320,13 +4320,13 @@ HTTP では `CDIDX_MCP_HTTP_TOKEN` が優先の bearer secret です。未設定 `CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、クライアントは引き続き `Authorization: Bearer ` で認証します。 -HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。server-initiated frame は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification を opt-in した場合だけ送信されます。受理される値は有限な `1`〜`300` 秒で、不正値や範囲外の値では `stderr` に警告を出して keep-alive を無効のままにします。この stream は独立しており通常の POST リクエストを塞ぎません。`/` への POST 以外は `405 Method Not Allowed`(`Allow: POST` 付き)です。リクエスト本文は既定で 1,000,000 bytes までに制限され、超過時は `413 Payload Too Large` を返します。保留中 POST queue は既定で 64 件までに制限され、満杯時は `Retry-After: 1` 付きの `429 Too Many Requests` を返します。正の整数の `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` と `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` で調整でき、受理範囲は本文が `1..16777216` bytes、queue が `1..1024` 件です。正でない値や数値でない値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否されます。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。リクエスト/レスポンス本文は記録しません。 +HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。server-initiated JSON-RPC frame は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification を opt-in した場合だけ送信されます。受理される値は有限な `1`〜`300` 秒で、不正値や範囲外の値では `stderr` に警告を出して keep-alive を無効のままにします。この stream は独立しており通常の POST リクエストを塞ぎません。`/` への POST 以外は `405 Method Not Allowed`(`Allow: POST` 付き)です。リクエスト本文は既定で 1,000,000 bytes までに制限され、超過時は `413 Payload Too Large` を返します。保留中 POST queue と受理済み handler task は既定で 64 件まで、同時 `/events` stream は既定で 16 件までに制限されます。queue、handler pool、stream slot が満杯の場合は `Retry-After: 1` 付きの `429 Too Many Requests` を返します。正の整数の `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`、`CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`、`CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS`、`CDIDX_MCP_HTTP_MAX_EVENT_STREAMS` で調整でき、受理範囲は本文が `1..16777216` bytes、各件数 limit が `1..1024` 件です。正でない値や数値でない値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否されます。idle event stream には最小限の SSE comment heartbeat を送り、切断済み client の stream slot を解放します。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。method、path、remote peer、request id は 256 文字を上限に `...` marker 付きで切り詰めます。リクエスト/レスポンス本文は記録しません。 セキュリティ既定: - listener は既定で loopback アドレス(`127.0.0.1`)のみに bind し、ワイルドカード `+` / `*` は最初から拒否します。 - 非 loopback ホスト(例: `0.0.0.0:9000`)に bind するには `CDIDX_MCP_HTTP_TOKEN` または `CDIDX_MCP_AUTH_TOKEN` で共有秘密を指定する必要があります。両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` が優先されます。HTTP bearer secret が設定されている場合、すべてのリクエストに `Authorization: Bearer ` ヘッダーが必要で、欠落・不一致は `401 Unauthorized`(`WWW-Authenticate: Bearer realm="cdidx-mcp"` 付き)です。HTTP クライアントは `params.auth.token` も送る必要はありません。 -- 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。 +- 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。設定 token と受信 token は 4096 文字を超える場合、hash 前に拒否します。 stdio トランスポートはバイト単位で挙動が変わらないため、既存クライアント設定はそのまま動作します。 diff --git a/changelog.d/unreleased/3003.security.md b/changelog.d/unreleased/3003.security.md new file mode 100644 index 0000000000..3b43fa248e --- /dev/null +++ b/changelog.d/unreleased/3003.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3003 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP now bounds concurrent request handlers (#3003)** — accepted HTTP contexts are gated by a configurable handler semaphore, and extra requests receive 429/Retry-After instead of creating unbounded handler tasks. + +## 日本語 + +- **HTTP MCP が同時 request handler 数を制限するようになりました (#3003)** — accepted HTTP context を設定可能な handler semaphore で制御し、上限超過時は無制限に handler task を作らず 429/Retry-After を返します。 diff --git a/changelog.d/unreleased/3096.security.md b/changelog.d/unreleased/3096.security.md new file mode 100644 index 0000000000..d225a2cd9e --- /dev/null +++ b/changelog.d/unreleased/3096.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 3096 +affected: + - src/CodeIndex/Mcp/McpAuthentication.cs + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **MCP token authentication now rejects oversized tokens before hashing (#3096)** — stdio auth tokens and HTTP bearer tokens now share a fixed length cap so hostile clients cannot force unbounded token hashing work. + +## 日本語 + +- **MCP token 認証が oversized token を hash 前に拒否するようになりました (#3096)** — stdio auth token と HTTP bearer token に共通の長さ上限を設け、悪意あるクライアントが無制限の token hash 処理を強制できないようにしました。 diff --git a/changelog.d/unreleased/3114.security.md b/changelog.d/unreleased/3114.security.md new file mode 100644 index 0000000000..bcb6c633ff --- /dev/null +++ b/changelog.d/unreleased/3114.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3114 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP request logging now caps extracted JSON-RPC ids (#3114)** — request ids are truncated with a marker before they are stored in request log records, preventing oversized ids from inflating persistent logs. + +## 日本語 + +- **HTTP MCP request logging が抽出した JSON-RPC id を上限付きで保持するようになりました (#3114)** — request id を request log record に保存する前に marker 付きで切り詰め、巨大な id が永続 log を肥大化させないようにしました。 diff --git a/changelog.d/unreleased/3158.security.md b/changelog.d/unreleased/3158.security.md new file mode 100644 index 0000000000..2fffb810f8 --- /dev/null +++ b/changelog.d/unreleased/3158.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3158 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP `/events` streams now have a concurrent stream cap (#3158)** — extra SSE clients are rejected with 429/Retry-After once the configured stream limit is reached. + +## 日本語 + +- **HTTP MCP `/events` stream に同時接続上限を追加しました (#3158)** — 設定された stream 上限に達した後の追加 SSE client は 429/Retry-After で拒否されます。 diff --git a/changelog.d/unreleased/3178.security.md b/changelog.d/unreleased/3178.security.md new file mode 100644 index 0000000000..401ba4fac1 --- /dev/null +++ b/changelog.d/unreleased/3178.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3178 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP request logs now cap request metadata fields (#3178)** — method, path, and remote peer values are truncated with a marker before persistent request logging so oversized client metadata cannot inflate tool logs. + +## 日本語 + +- **HTTP MCP request log が request metadata field を上限付きで記録するようになりました (#3178)** — method、path、remote peer を永続 request log に書く前に marker 付きで切り詰め、巨大な client metadata が tool log を肥大化させないようにしました。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index abd3947539..845c67534b 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2568,10 +2568,11 @@ private static void LogHttpMcpRequest(HttpMcpTransport.HttpRequestLogRecord reco private static string FormatLogValue(string? value) { - if (string.IsNullOrEmpty(value)) + var limited = HttpMcpTransport.LimitRequestLogField(value); + if (string.IsNullOrEmpty(limited)) return "-"; - return value + return limited .Replace('\\', '/') .Replace('\r', '_') .Replace('\n', '_') @@ -2583,7 +2584,7 @@ private static void PrintMcpUsage() { Console.Error.WriteLine("Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--suggestion-dedup-threshold <0..1>]"); Console.Error.WriteLine("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); - Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)})."); + Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (1..{HttpMcpTransport.MaxConfiguredConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxEventStreamsEnvVar}= (1..{HttpMcpTransport.MaxConfiguredEventStreams.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxEventStreams.ToString(CultureInfo.InvariantCulture)})."); } internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string error) diff --git a/src/CodeIndex/Mcp/HttpMcpTransport.cs b/src/CodeIndex/Mcp/HttpMcpTransport.cs index 2bbe82ef4a..d6796d5e64 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -31,8 +31,18 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport internal const int MaxConfiguredRequestBodyBytes = 16 * 1024 * 1024; internal const int DefaultMaxQueuedRequests = 64; internal const int MaxConfiguredQueuedRequests = 1024; + internal const int DefaultMaxConcurrentHandlers = 64; + internal const int MaxConfiguredConcurrentHandlers = 1024; + internal const int DefaultMaxEventStreams = 16; + internal const int MaxConfiguredEventStreams = 1024; + internal const int MaxRequestLogFieldCharacters = 256; + internal const string RequestLogTruncationMarker = "..."; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; + internal const string MaxConcurrentHandlersEnvVar = "CDIDX_MCP_HTTP_MAX_CONCURRENT_HANDLERS"; + internal const string MaxEventStreamsEnvVar = "CDIDX_MCP_HTTP_MAX_EVENT_STREAMS"; + private const string BearerPrefix = "Bearer "; + private static readonly TimeSpan EventStreamDisconnectProbeInterval = TimeSpan.FromSeconds(1); private static readonly JsonDocumentOptions HttpProbeJsonDocumentOptions = new() { @@ -46,8 +56,11 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport private readonly ConcurrentDictionary _eventStreams = new(); private readonly CancellationTokenSource _acceptCts = new(); private readonly Channel _requestQueue; + private readonly SemaphoreSlim _handlerSemaphore; private readonly int _maxRequestBodyBytes; private readonly int _maxQueuedRequests; + private readonly int _maxConcurrentHandlers; + private readonly int _maxEventStreams; private readonly Task _acceptLoop; // The configured bearer token's SHA-256 digest, precomputed once at construction so the // per-request auth path never hashes the secret. Storing the digest (not the token) keeps the @@ -58,6 +71,7 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport private readonly byte[]? _bearerTokenHash; private PendingRequest? _pendingRequest; private int _queuedRequestCount; + private int _eventStreamCount; private bool _disposed; /// @@ -76,7 +90,9 @@ internal HttpMcpTransport( string? bearerToken, Action? requestLogger = null, int? maxRequestBodyBytes = null, - int? maxQueuedRequests = null) + int? maxQueuedRequests = null, + int? maxConcurrentHandlers = null, + int? maxEventStreams = null) { _maxRequestBodyBytes = ResolvePositiveIntOption( maxRequestBodyBytes, @@ -92,6 +108,20 @@ internal HttpMcpTransport( DefaultMaxQueuedRequests, MaxConfiguredQueuedRequests, "HTTP MCP request queue depth"); + _maxConcurrentHandlers = ResolvePositiveIntOption( + maxConcurrentHandlers, + nameof(maxConcurrentHandlers), + MaxConcurrentHandlersEnvVar, + DefaultMaxConcurrentHandlers, + MaxConfiguredConcurrentHandlers, + "HTTP MCP concurrent handler limit"); + _maxEventStreams = ResolvePositiveIntOption( + maxEventStreams, + nameof(maxEventStreams), + MaxEventStreamsEnvVar, + DefaultMaxEventStreams, + MaxConfiguredEventStreams, + "HTTP MCP event stream limit"); _requestQueue = Channel.CreateBounded(new BoundedChannelOptions(_maxQueuedRequests) { SingleReader = true, @@ -99,6 +129,9 @@ internal HttpMcpTransport( FullMode = BoundedChannelFullMode.Wait, AllowSynchronousContinuations = false, }); + if (McpAuthenticationLimits.IsTokenOversized(bearerToken)) + throw new ArgumentException($"Token must not exceed {McpAuthenticationLimits.MaxTokenCharacters.ToString(CultureInfo.InvariantCulture)} characters.", nameof(bearerToken)); + _handlerSemaphore = new SemaphoreSlim(_maxConcurrentHandlers, _maxConcurrentHandlers); _listener = new HttpListener(); _listener.Prefixes.Add(prefix); _listener.Start(); @@ -124,14 +157,20 @@ internal HttpMcpTransport( internal Func? KeepAliveFrameProvider { get; set; } - internal bool HasEventStreams => !_eventStreams.IsEmpty; + internal bool HasEventStreams => EventStreamCount > 0; internal int MaxRequestBodyBytes => _maxRequestBodyBytes; internal int MaxQueuedRequests => _maxQueuedRequests; + internal int MaxConcurrentHandlers => _maxConcurrentHandlers; + + internal int MaxEventStreams => _maxEventStreams; + internal int QueuedRequestCount => Volatile.Read(ref _queuedRequestCount); + internal int EventStreamCount => Volatile.Read(ref _eventStreamCount); + /// /// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports /// (port `0`) are resolved up-front by binding a temporary so the @@ -297,7 +336,13 @@ private async Task AcceptLoopAsync(CancellationToken cancellationToken) break; } - _ = Task.Run(() => HandleContextAsync(context, cancellationToken), CancellationToken.None); + if (!_handlerSemaphore.Wait(0)) + { + await RejectHandlerLimitAsync(context).ConfigureAwait(false); + continue; + } + + _ = Task.Run(() => RunHandlerAsync(context, cancellationToken), CancellationToken.None); } } finally @@ -306,6 +351,27 @@ private async Task AcceptLoopAsync(CancellationToken cancellationToken) } } + private async Task RunHandlerAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + await HandleContextAsync(context, cancellationToken).ConfigureAwait(false); + } + finally + { + _handlerSemaphore.Release(); + } + } + + private async Task RejectHandlerLimitAsync(HttpListenerContext context) + { + var request = BeginRequest(context); + request.AuthOutcome = "not-checked"; + context.Response.AddHeader("Retry-After", "1"); + await RespondAsync(context, (int)HttpStatusCode.TooManyRequests, "MCP HTTP concurrent handler limit is full.\n").ConfigureAwait(false); + LogRequest(request, (int)HttpStatusCode.TooManyRequests); + } + private async Task HandleContextAsync(HttpListenerContext context, CancellationToken cancellationToken) { var request = BeginRequest(context); @@ -339,7 +405,7 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT return; } - _ = Task.Run(() => RunEventStreamAsync(request, cancellationToken), CancellationToken.None); + await RunEventStreamAsync(request, cancellationToken).ConfigureAwait(false); return; } @@ -532,8 +598,7 @@ public async Task WriteOutOfBandFrameAsync(string frame, CancellationToken cance } catch { - _eventStreams.TryRemove(id, out _); - try { stream.Response.Abort(); } catch { /* ignore */ } + RemoveEventStream(id, stream); } } } @@ -556,10 +621,9 @@ private async Task TryAuthorizeAsync(PendingRequest request) { request.AuthOutcome = "missing"; } - else if (header.Length >= "Bearer ".Length && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + else if (TryExtractBearerToken(header, out var provided)) { - var provided = header.Substring("Bearer ".Length).Trim(); - if (HashEqualsConfiguredToken(provided)) + if (provided is not null && HashEqualsConfiguredToken(provided)) { request.AuthOutcome = "ok"; return true; @@ -583,6 +647,20 @@ private async Task TryAuthorizeAsync(PendingRequest request) return false; } + private static bool TryExtractBearerToken(string header, out string? token) + { + token = null; + if (header.Length < BearerPrefix.Length || !header.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + return false; + + var candidate = header.AsSpan(BearerPrefix.Length).Trim(); + if (candidate.Length > McpAuthenticationLimits.MaxTokenCharacters) + return true; + + token = candidate.ToString(); + return true; + } + private static async Task RespondAsync(HttpListenerContext context, int statusCode, string body) { try @@ -626,6 +704,15 @@ private static bool IsHealthPath(string? path) private async Task RunEventStreamAsync(PendingRequest request, CancellationToken cancellationToken) { var context = request.Context; + if (Interlocked.Increment(ref _eventStreamCount) > _maxEventStreams) + { + Interlocked.Decrement(ref _eventStreamCount); + context.Response.AddHeader("Retry-After", "1"); + await RespondAsync(context, (int)HttpStatusCode.TooManyRequests, "MCP HTTP event stream limit is full.\n").ConfigureAwait(false); + LogRequest(request, (int)HttpStatusCode.TooManyRequests); + return; + } + var streamId = Guid.NewGuid(); var stream = new EventStream(context.Response); try @@ -649,25 +736,26 @@ private async Task RunEventStreamAsync(PendingRequest request, CancellationToken } finally { - _eventStreams.TryRemove(streamId, out _); + RemoveEventStream(streamId, stream); LogRequest(request, (int)HttpStatusCode.OK); try { context.Response.Close(); } catch { /* ignore */ } } } + private void RemoveEventStream(Guid streamId, EventStream stream) + { + _eventStreams.TryRemove(streamId, out _); + if (stream.TryReleaseSlot()) + Interlocked.Decrement(ref _eventStreamCount); + try { stream.Response.Abort(); } catch { /* ignore */ } + } + private async Task RunKeepAliveLoopAsync(EventStream stream, CancellationToken cancellationToken) { var interval = KeepAliveInterval; if (interval is null || interval.Value <= TimeSpan.Zero || KeepAliveFrameProvider is null) { - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Normal server shutdown. - } + await RunEventStreamDisconnectProbeLoopAsync(stream, cancellationToken).ConfigureAwait(false); return; } @@ -686,9 +774,26 @@ private async Task RunKeepAliveLoopAsync(EventStream stream, CancellationToken c } } + private static async Task RunEventStreamDisconnectProbeLoopAsync(EventStream stream, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(EventStreamDisconnectProbeInterval, cancellationToken).ConfigureAwait(false); + await stream.WriteCommentAsync("cdidx mcp event stream heartbeat", cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Normal server shutdown. + } + } + private sealed class EventStream(HttpListenerResponse response) { private readonly SemaphoreSlim _writeGate = new(1, 1); + private int _released; public HttpListenerResponse Response { get; } = response; @@ -701,6 +806,20 @@ public async Task WriteJsonRpcEventAsync(string frame, CancellationToken cancell builder.Append('\n'); var bytes = Encoding.UTF8.GetBytes(builder.ToString()); + await WriteSseBytesAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + public Task WriteCommentAsync(string comment, CancellationToken cancellationToken) + { + var payload = ": " + comment.Replace("\r\n", "\n").Replace('\r', '\n').Replace('\n', ' ') + "\n\n"; + return WriteSseBytesAsync(Encoding.UTF8.GetBytes(payload), cancellationToken); + } + + public bool TryReleaseSlot() + => Interlocked.Exchange(ref _released, 1) == 0; + + private async Task WriteSseBytesAsync(byte[] bytes, CancellationToken cancellationToken) + { await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { @@ -776,9 +895,18 @@ private PendingRequest BeginRequest(HttpListenerContext context) return new PendingRequest( context, Guid.NewGuid().ToString("N"), - remotePeer, - context.Request.HttpMethod, - context.Request.Url?.AbsolutePath ?? "/"); + LimitRequestLogField(remotePeer) ?? "", + LimitRequestLogField(context.Request.HttpMethod) ?? string.Empty, + LimitRequestLogField(context.Request.Url?.AbsolutePath ?? "/") ?? "/"); + } + + internal static string? LimitRequestLogField(string? value) + { + if (value is null || value.Length <= MaxRequestLogFieldCharacters) + return value; + + return value.Substring(0, MaxRequestLogFieldCharacters - RequestLogTruncationMarker.Length) + + RequestLogTruncationMarker; } private void LogRequest(PendingRequest request, int statusCode) @@ -817,12 +945,13 @@ private void LogRequest(PendingRequest request, int statusCode) if (!doc.RootElement.TryGetProperty("id", out var id)) return null; - return id.ValueKind switch + var requestId = id.ValueKind switch { JsonValueKind.String => id.GetString(), JsonValueKind.Number => id.GetRawText(), _ => id.GetRawText(), }; + return LimitRequestLogField(requestId); } catch (JsonException) { diff --git a/src/CodeIndex/Mcp/McpAuthentication.cs b/src/CodeIndex/Mcp/McpAuthentication.cs index 1c55d57003..4fc8bcedbd 100644 --- a/src/CodeIndex/Mcp/McpAuthentication.cs +++ b/src/CodeIndex/Mcp/McpAuthentication.cs @@ -44,6 +44,15 @@ public sealed record McpAuthenticationResult(McpCallerIdentity? Identity, string public static McpAuthenticationResult Deny(string reason) => new(null, reason); } +internal static class McpAuthenticationLimits +{ + internal const int MaxTokenCharacters = 4096; + internal const string OversizedTokenFailureReason = "auth token mismatch"; + + internal static bool IsTokenOversized(string? token) + => token is { Length: > MaxTokenCharacters }; +} + /// /// Per-request authentication strategy for the MCP server. Implementations look at the /// incoming JSON-RPC request envelope and produce an identity or a failure reason. The @@ -97,19 +106,22 @@ public McpAuthenticationResult Authenticate(JsonNode request) /// ; /// equal-length inputs keep the compare on its constant-time path and the hash step erases /// the length-leak that the raw-byte form would otherwise have (FixedTimeEquals short-circuits -/// on length mismatch). The hash-and-compare runs unconditionally — even on a missing token — -/// so callers cannot distinguish "missing", "wrong length", and "wrong value" by timing. -/// Identity is stdio-token / token. Notifications skip the check upstream because -/// they produce no response and cannot be acted on. +/// on length mismatch). Tokens over the shared cap are denied before hashing so hostile +/// clients cannot force unbounded hashing work. For in-cap inputs, the hash-and-compare runs +/// unconditionally — even on a missing token — so callers cannot distinguish "missing", +/// "wrong length", and "wrong value" by timing. Identity is stdio-token / +/// token. Notifications skip the check upstream because they produce no response and +/// cannot be acted on. /// トークン認証 authenticator。各 JSON-RPC リクエストは params.auth.token に /// 一致するトークンを含む必要がある。期待トークンは固定長 (SHA-256 32 バイト) のダイジェスト /// として保持し、提示トークンも同じ長さにハッシュしてから /// /// で比較する。これにより比較は常に等長で定数時間パスに留まり、生バイト比較なら漏れる長さ情報も -/// ハッシュ段階で隠れる (FixedTimeEquals は長さ不一致で即 return する)。ハッシュ+比較は -/// トークン未提示でも必ず走らせるため、呼び出し元は「未提示」「長さ違い」「値違い」を時間差で -/// 区別できない。アイデンティティは stdio-token / token。通知は応答が -/// 無く副作用も持たないので呼び出し側でチェック前にスキップされる。 +/// ハッシュ段階で隠れる (FixedTimeEquals は長さ不一致で即 return する)。共通上限を超える token は +/// hash 前に拒否し、悪意あるクライアントが無制限の hash 処理を強制できないようにする。上限内の +/// 入力では、トークン未提示でも必ずハッシュ+比較を走らせるため、呼び出し元は「未提示」 +/// 「長さ違い」「値違い」を時間差で区別できない。アイデンティティは stdio-token / +/// token。通知は応答が無く副作用も持たないので呼び出し側でチェック前にスキップされる。 /// public sealed class TokenMcpAuthenticator : IMcpAuthenticator { @@ -124,6 +136,8 @@ public TokenMcpAuthenticator(string expectedToken) { if (string.IsNullOrEmpty(expectedToken)) throw new ArgumentException("Token must not be empty", nameof(expectedToken)); + if (McpAuthenticationLimits.IsTokenOversized(expectedToken)) + throw new ArgumentException($"Token must not exceed {McpAuthenticationLimits.MaxTokenCharacters} characters.", nameof(expectedToken)); _expectedTokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(expectedToken)); } @@ -147,12 +161,15 @@ public McpAuthenticationResult Authenticate(JsonNode request) presented = null; } - // Always hash and constant-time compare — even when no token was presented — so the - // missing / wrong-length / wrong-value branches all take the same path. Choosing the - // stderr `Deny` reason after the compare runs keeps the timing uniform; the wire - // response is "Unauthorized" either way. - // トークン未提示でも必ずハッシュ+定数時間比較を走らせる。Deny 理由の選択は比較後に - // 行い、stderr 用文字列だけ分岐させる(ワイヤ応答は常に "Unauthorized")。 + if (McpAuthenticationLimits.IsTokenOversized(presented)) + return McpAuthenticationResult.Deny(McpAuthenticationLimits.OversizedTokenFailureReason); + + // For in-cap inputs, always hash and constant-time compare — even when no token was + // presented — so the missing / wrong-length / wrong-value branches all take the same + // path. Choosing the stderr `Deny` reason after the compare runs keeps the timing + // uniform; the wire response is "Unauthorized" either way. + // 上限内の入力では、トークン未提示でも必ずハッシュ+定数時間比較を走らせる。Deny 理由の + // 選択は比較後に行い、stderr 用文字列だけ分岐させる(ワイヤ応答は常に "Unauthorized")。 var presentedHash = SHA256.HashData(Encoding.UTF8.GetBytes(presented ?? string.Empty)); var matches = CryptographicOperations.FixedTimeEquals(presentedHash, _expectedTokenHash); diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 8e207af3f5..6e5193cbde 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -192,6 +192,53 @@ public async Task HttpTransport_RequestLogger_RecordsMethodStatusDurationAndAuth Assert.Equal((int)HttpStatusCode.OK, okPost.StatusCode); } + [Fact] + public void HttpTransport_RequestLogFieldLimiter_CapsMetadataFields() + { + var longValue = new string('x', HttpMcpTransport.MaxRequestLogFieldCharacters + 100); + + var limited = HttpMcpTransport.LimitRequestLogField(longValue); + + Assert.NotNull(limited); + Assert.Equal(HttpMcpTransport.MaxRequestLogFieldCharacters, limited.Length); + Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, limited, StringComparison.Ordinal); + } + + [Fact] + public async Task HttpTransport_RequestLogger_CapsLongPathBeforeLogging() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, requestLogger: records.Enqueue); + + using var client = new HttpClient(); + var longPath = string.Join('/', Enumerable.Repeat("segment", 50)); + using var response = await client.GetAsync(new Uri(new Uri(harness.Endpoint), longPath)); + + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.Equal(HttpMcpTransport.MaxRequestLogFieldCharacters, record.Path.Length); + Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, record.Path, StringComparison.Ordinal); + } + + [Fact] + public async Task HttpTransport_RequestLogger_CapsLongJsonRpcIdBeforeLogging() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, requestLogger: records.Enqueue); + var oversizedId = new string('i', HttpMcpTransport.MaxRequestLogFieldCharacters + 100); + var body = """{"jsonrpc":"2.0","id":""" + + JsonSerializer.Serialize(oversizedId) + + ""","method":"ping"}"""; + + using var response = await harness.PostJsonAsync(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.NotNull(record.RequestId); + Assert.Equal(HttpMcpTransport.MaxRequestLogFieldCharacters, record.RequestId.Length); + Assert.EndsWith(HttpMcpTransport.RequestLogTruncationMarker, record.RequestId, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_RequestLogger_TooDeepJsonRpcIdReturnsNull_Issue3014() { @@ -361,8 +408,12 @@ public async Task HttpTransport_DefaultLimitOptions_UseBoundedDefaults() Assert.Equal(HttpMcpTransport.DefaultMaxRequestBodyBytes, transport.MaxRequestBodyBytes); Assert.Equal(HttpMcpTransport.DefaultMaxQueuedRequests, transport.MaxQueuedRequests); + Assert.Equal(HttpMcpTransport.DefaultMaxConcurrentHandlers, transport.MaxConcurrentHandlers); + Assert.Equal(HttpMcpTransport.DefaultMaxEventStreams, transport.MaxEventStreams); Assert.InRange(transport.MaxRequestBodyBytes, 1, HttpMcpTransport.MaxConfiguredRequestBodyBytes); Assert.InRange(transport.MaxQueuedRequests, 1, HttpMcpTransport.MaxConfiguredQueuedRequests); + Assert.InRange(transport.MaxConcurrentHandlers, 1, HttpMcpTransport.MaxConfiguredConcurrentHandlers); + Assert.InRange(transport.MaxEventStreams, 1, HttpMcpTransport.MaxConfiguredEventStreams); } [Fact] @@ -370,9 +421,13 @@ public async Task HttpTransport_ValidEnvironmentLimitOptions_AreApplied() { using var env = EnvironmentVariableScope.Capture( HttpMcpTransport.MaxRequestBodyBytesEnvVar, - HttpMcpTransport.MaxQueueDepthEnvVar); + HttpMcpTransport.MaxQueueDepthEnvVar, + HttpMcpTransport.MaxConcurrentHandlersEnvVar, + HttpMcpTransport.MaxEventStreamsEnvVar); env.Set(HttpMcpTransport.MaxRequestBodyBytesEnvVar, (2 * 1024 * 1024).ToString(CultureInfo.InvariantCulture)); env.Set(HttpMcpTransport.MaxQueueDepthEnvVar, "128"); + env.Set(HttpMcpTransport.MaxConcurrentHandlersEnvVar, "32"); + env.Set(HttpMcpTransport.MaxEventStreamsEnvVar, "8"); var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); await using var transport = new HttpMcpTransport( @@ -383,6 +438,8 @@ public async Task HttpTransport_ValidEnvironmentLimitOptions_AreApplied() Assert.Equal(2 * 1024 * 1024, transport.MaxRequestBodyBytes); Assert.Equal(128, transport.MaxQueuedRequests); + Assert.Equal(32, transport.MaxConcurrentHandlers); + Assert.Equal(8, transport.MaxEventStreams); } [Fact] @@ -469,6 +526,50 @@ public void HttpTransport_PositiveOverflowQueueDepthEnvironment_ThrowsWithRange( StringComparison.Ordinal); } + [Fact] + public void HttpTransport_OversizedConcurrentHandlersEnvironment_ThrowsWithRange() + { + using var env = EnvironmentVariableScope.Capture(HttpMcpTransport.MaxConcurrentHandlersEnvVar); + env.Set( + HttpMcpTransport.MaxConcurrentHandlersEnvVar, + (HttpMcpTransport.MaxConfiguredConcurrentHandlers + 1).ToString(CultureInfo.InvariantCulture)); + + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + var ex = Assert.Throws(() => new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null)); + + Assert.Contains(HttpMcpTransport.MaxConcurrentHandlersEnvVar, ex.Message, StringComparison.Ordinal); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public void HttpTransport_OversizedEventStreamsEnvironment_ThrowsWithRange() + { + using var env = EnvironmentVariableScope.Capture(HttpMcpTransport.MaxEventStreamsEnvVar); + env.Set( + HttpMcpTransport.MaxEventStreamsEnvVar, + (HttpMcpTransport.MaxConfiguredEventStreams + 1).ToString(CultureInfo.InvariantCulture)); + + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + var ex = Assert.Throws(() => new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null)); + + Assert.Contains(HttpMcpTransport.MaxEventStreamsEnvVar, ex.Message, StringComparison.Ordinal); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredEventStreams.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + [Fact] public void HttpTransport_OversizedExplicitLimitOption_ThrowsWithRange() { @@ -518,6 +619,30 @@ public async Task HttpTransport_RequestQueueFull_Returns429() Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); } + [Fact] + public async Task HttpTransport_ConcurrentHandlerLimit_Returns429() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + await using var transport = new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null, + maxConcurrentHandlers: 1); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var events = await client.GetAsync(new Uri(new Uri(listen.Prefix), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, events.StatusCode); + await WaitUntilAsync(() => transport.HasEventStreams, "the first event stream to occupy the only handler slot"); + + using var second = await client.PostAsync( + listen.Prefix, + new StringContent("""{"jsonrpc":"2.0","id":2,"method":"ping"}""", Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode); + Assert.Contains(second.Headers, header => header.Key == "Retry-After"); + } + [Fact] public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() { @@ -537,6 +662,28 @@ public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() Assert.Equal(11, doc.RootElement.GetProperty("id").GetInt32()); } + [Fact] + public async Task HttpTransport_EventsStreamLimit_Returns429() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + await using var transport = new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null, + maxEventStreams: 1); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var first = await client.GetAsync(new Uri(new Uri(listen.Prefix), "events"), HttpCompletionOption.ResponseHeadersRead); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + await WaitUntilAsync(() => transport.EventStreamCount == 1, "the first event stream to fill the stream limit"); + + using var second = await client.GetAsync(new Uri(new Uri(listen.Prefix), "events"), HttpCompletionOption.ResponseHeadersRead); + + Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode); + Assert.Contains(second.Headers, header => header.Key == "Retry-After"); + } + [Fact] public async Task HttpTransport_EventsStream_EmitsOptInKeepAliveNotifications() { @@ -560,8 +707,6 @@ public async Task HttpTransport_EventsStream_EmitsOptInKeepAliveNotifications() [Fact] public async Task HttpTransport_EventsStream_RemovesDisconnectedStreams() { - using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"); - env.Set("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", "1"); await using var harness = await McpHttpHarness.StartAsync(_dbPath); using var client = new HttpClient(); @@ -715,6 +860,28 @@ public async Task HttpTransport_BearerToken_RejectsWrongToken() Assert.Contains(response.Headers.WwwAuthenticate, h => h.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task HttpTransport_BearerToken_RejectsOversizedHeaderBeforeHashing() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, bearerToken: "token", requestLogger: records.Enqueue); + + using var client = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, harness.Endpoint) + { + Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", Encoding.UTF8, "application/json"), + }; + request.Headers.TryAddWithoutValidation( + "Authorization", + "Bearer " + new string('x', McpAuthenticationLimits.MaxTokenCharacters + 1)); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var record = Assert.Single(await WaitForRequestLogRecordsAsync(records, 1)); + Assert.Equal("wrong-token", record.AuthOutcome); + } + [Fact] public async Task HttpTransport_BearerToken_RejectsSameLengthWrongToken() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e3dcad5db1..0815461053 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -1661,6 +1661,14 @@ public void TokenAuthenticator_EmptyTokenInCtor_Rejected() Assert.Throws(() => new TokenMcpAuthenticator(string.Empty)); } + [Fact] + public void TokenAuthenticator_OversizedTokenInCtor_RejectedBeforeHashing() + { + var oversized = new string('x', McpAuthenticationLimits.MaxTokenCharacters + 1); + + Assert.Throws(() => new TokenMcpAuthenticator(oversized)); + } + [Fact] public void McpAuthenticatorFactory_NoEnv_ReturnsLocalStdio() { @@ -1801,6 +1809,24 @@ public void TokenAuthenticator_WrongLengthToken_UniformWireResponse() Assert.Equal(-32001, shortResp["error"]!["code"]!.GetValue()); } + [Fact] + public void TokenAuthenticator_OversizedPresentedToken_ReturnsUnauthorized() + { + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion(), false, + new TokenMcpAuthenticator("s3cret")); + var oversized = new string('x', McpAuthenticationLimits.MaxTokenCharacters + 1); + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"ping","params":{"auth":{"token":""" + + JsonSerializer.Serialize(oversized) + + "}}}")!; + + var response = server.HandleMessage(request)!; + + Assert.Null(response["result"]); + Assert.Equal(-32001, response["error"]!["code"]!.GetValue()); + Assert.Equal("Unauthorized", response["error"]!["message"]!.GetValue()); + } + [Fact] public void McpAuthenticatorFactory_TokenSet_ReturnsTokenAuthenticator() {