From 796b090af8e41ce2ea909d61df8b40261e36ad72 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 4 Jun 2026 23:18:11 +0900 Subject: [PATCH 1/2] Enforce HTTP MCP limit caps (#3157) --- src/CodeIndex/Cli/ProgramRunner.cs | 14 ++++++++- src/CodeIndex/Mcp/HttpMcpTransport.cs | 45 +++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 8425d7d039..125db8b2e7 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2464,6 +2464,18 @@ private static int RunMcpHttp(McpServer server, string listenSpec) { transport = new HttpMcpTransport(resolved.Prefix, resolved.Host, resolved.Port, bearerToken, LogHttpMcpRequest); } + catch (FormatException ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + catch (ArgumentOutOfRangeException ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } catch (HttpListenerException ex) { Console.Error.WriteLine($"Error: failed to bind HTTP listener on {resolved.Prefix}: {ex.Message}"); @@ -2544,7 +2556,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}= (default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (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)})."); } 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 8f414ddd40..2bbe82ef4a 100644 --- a/src/CodeIndex/Mcp/HttpMcpTransport.cs +++ b/src/CodeIndex/Mcp/HttpMcpTransport.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Net; using System.Net.Sockets; +using System.Numerics; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -27,7 +28,9 @@ namespace CodeIndex.Mcp; internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport { internal const int DefaultMaxRequestBodyBytes = 1_000_000; + internal const int MaxConfiguredRequestBodyBytes = 16 * 1024 * 1024; internal const int DefaultMaxQueuedRequests = 64; + internal const int MaxConfiguredQueuedRequests = 1024; internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES"; internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH"; @@ -75,8 +78,20 @@ internal HttpMcpTransport( int? maxRequestBodyBytes = null, int? maxQueuedRequests = null) { - _maxRequestBodyBytes = ResolvePositiveIntOption(maxRequestBodyBytes, MaxRequestBodyBytesEnvVar, DefaultMaxRequestBodyBytes); - _maxQueuedRequests = ResolvePositiveIntOption(maxQueuedRequests, MaxQueueDepthEnvVar, DefaultMaxQueuedRequests); + _maxRequestBodyBytes = ResolvePositiveIntOption( + maxRequestBodyBytes, + nameof(maxRequestBodyBytes), + MaxRequestBodyBytesEnvVar, + DefaultMaxRequestBodyBytes, + MaxConfiguredRequestBodyBytes, + "HTTP MCP request body byte limit"); + _maxQueuedRequests = ResolvePositiveIntOption( + maxQueuedRequests, + nameof(maxQueuedRequests), + MaxQueueDepthEnvVar, + DefaultMaxQueuedRequests, + MaxConfiguredQueuedRequests, + "HTTP MCP request queue depth"); _requestQueue = Channel.CreateBounded(new BoundedChannelOptions(_maxQueuedRequests) { SingleReader = true, @@ -209,18 +224,36 @@ private static int FindFreePort(IPAddress address) } } - private static int ResolvePositiveIntOption(int? explicitValue, string envVar, int defaultValue) + private static int ResolvePositiveIntOption( + int? explicitValue, + string explicitValueName, + string envVar, + int defaultValue, + int maximumValue, + string description) { if (explicitValue is { } configured) { if (configured <= 0) - throw new ArgumentOutOfRangeException(nameof(explicitValue), configured, "HTTP MCP limits must be positive integers."); + throw new ArgumentOutOfRangeException( + explicitValueName, + configured, + $"{description} must be between 1 and {maximumValue.ToString(CultureInfo.InvariantCulture)}."); + if (configured > maximumValue) + throw new ArgumentOutOfRangeException( + explicitValueName, + configured, + $"{description} must be between 1 and {maximumValue.ToString(CultureInfo.InvariantCulture)}."); return configured; } var raw = Environment.GetEnvironmentVariable(envVar); - if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0) - return parsed; + if (BigInteger.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0) + { + if (parsed > maximumValue) + throw new FormatException($"{envVar} must be between 1 and {maximumValue.ToString(CultureInfo.InvariantCulture)} for {description}; got {parsed.ToString(CultureInfo.InvariantCulture)}."); + return (int)parsed; + } return defaultValue; } From 69dfa4acf8865ee5fb1af69b74ea2ca7fa5d3109 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 4 Jun 2026 23:18:24 +0900 Subject: [PATCH 2/2] Cover HTTP MCP limit cap configuration (#3227) --- DEVELOPER_GUIDE.md | 13 +- USER_GUIDE.md | 4 +- changelog.d/unreleased/3157-3227.fixed.md | 21 +++ .../CodeIndex.Tests/HttpMcpTransportTests.cs | 138 ++++++++++++++++++ tests/CodeIndex.Tests/ProgramCliTests.cs | 20 +++ 5 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/3157-3227.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2deac25057..2dee49013e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1808,11 +1808,14 @@ return `-32600`. bodies are treated like a closed stdio line and return `204 No Content` *without* killing the loop, so a misbehaving client cannot pin the server on a junk frame. Request bodies are capped by - `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` (default: 1,000,000 bytes) and oversized - bodies return `413 Payload Too Large` before they are fully buffered. The + `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` (default: 1,000,000 bytes, maximum: + 16,777,216 bytes) and oversized bodies return `413 Payload Too Large` + before they are fully buffered. The pending request queue is bounded by `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` - (default: 64); full queues return `429 Too Many Requests` with - `Retry-After: 1` instead of retaining unbounded work. + (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. - `ResolveListenSpec("host:port")` resolves the prefix up-front so the @@ -3472,7 +3475,7 @@ 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)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。 +- 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 を保持しない。 - `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 ` を要求し、定数時間で比較する。トークン未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 335a9d32a9..225e3430b5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2063,7 +2063,7 @@ CDIDX_MCP_HTTP_TOKEN=s3cret cdidx mcp \ --transport http --http-listen 0.0.0.0:9000 # LAN bind; bearer token is mandatory ``` -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`; invalid values fall back to the defaults. 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 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. Security defaults: @@ -4233,7 +4233,7 @@ CDIDX_MCP_HTTP_TOKEN=s3cret cdidx mcp \ --transport http --http-listen 0.0.0.0:9000 # LAN 公開時は bearer token が必須 ``` -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` で調整でき、不正値は既定にフォールバックします。永続 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 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 を記録します。リクエスト/レスポンス本文は記録しません。 セキュリティ既定: diff --git a/changelog.d/unreleased/3157-3227.fixed.md b/changelog.d/unreleased/3157-3227.fixed.md new file mode 100644 index 0000000000..aeff4431e0 --- /dev/null +++ b/changelog.d/unreleased/3157-3227.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 3157 + - 3227 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs + - tests/CodeIndex.Tests/ProgramCliTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **HTTP MCP limit environment variables now enforce hard maximums (#3157, #3227)** - `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` and `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` now reject values above their documented caps before the HTTP listener starts, preventing oversized buffers or request queues from being configured accidentally. + +## 日本語 + +- **HTTP MCP の limit 環境変数がハード上限を強制するようになりました (#3157, #3227)** - `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` と `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` は、HTTP listener 起動前にドキュメント化された上限を超える値を拒否するようになり、過大な buffer や request queue が誤設定されることを防ぎます。 diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index d49a60b997..14deef859c 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -334,6 +335,143 @@ public async Task HttpTransport_RequestBodyOverLimit_Returns413AndDoesNotKillSer Assert.Equal(7, doc.RootElement.GetProperty("id").GetInt32()); } + [Fact] + public async Task HttpTransport_DefaultLimitOptions_UseBoundedDefaults() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + await using var transport = new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null); + + Assert.Equal(HttpMcpTransport.DefaultMaxRequestBodyBytes, transport.MaxRequestBodyBytes); + Assert.Equal(HttpMcpTransport.DefaultMaxQueuedRequests, transport.MaxQueuedRequests); + Assert.InRange(transport.MaxRequestBodyBytes, 1, HttpMcpTransport.MaxConfiguredRequestBodyBytes); + Assert.InRange(transport.MaxQueuedRequests, 1, HttpMcpTransport.MaxConfiguredQueuedRequests); + } + + [Fact] + public async Task HttpTransport_ValidEnvironmentLimitOptions_AreApplied() + { + using var env = EnvironmentVariableScope.Capture( + HttpMcpTransport.MaxRequestBodyBytesEnvVar, + HttpMcpTransport.MaxQueueDepthEnvVar); + env.Set(HttpMcpTransport.MaxRequestBodyBytesEnvVar, (2 * 1024 * 1024).ToString(CultureInfo.InvariantCulture)); + env.Set(HttpMcpTransport.MaxQueueDepthEnvVar, "128"); + + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + await using var transport = new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null); + + Assert.Equal(2 * 1024 * 1024, transport.MaxRequestBodyBytes); + Assert.Equal(128, transport.MaxQueuedRequests); + } + + [Fact] + public void HttpTransport_OversizedRequestBytesEnvironment_ThrowsWithRange() + { + using var env = EnvironmentVariableScope.Capture(HttpMcpTransport.MaxRequestBodyBytesEnvVar); + env.Set( + HttpMcpTransport.MaxRequestBodyBytesEnvVar, + (HttpMcpTransport.MaxConfiguredRequestBodyBytes + 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.MaxRequestBodyBytesEnvVar, ex.Message, StringComparison.Ordinal); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public void HttpTransport_PositiveOverflowRequestBytesEnvironment_ThrowsWithRange() + { + using var env = EnvironmentVariableScope.Capture(HttpMcpTransport.MaxRequestBodyBytesEnvVar); + env.Set(HttpMcpTransport.MaxRequestBodyBytesEnvVar, ((long)int.MaxValue + 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.MaxRequestBodyBytesEnvVar, ex.Message, StringComparison.Ordinal); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public void HttpTransport_OversizedQueueDepthEnvironment_ThrowsWithRange() + { + using var env = EnvironmentVariableScope.Capture(HttpMcpTransport.MaxQueueDepthEnvVar); + env.Set( + HttpMcpTransport.MaxQueueDepthEnvVar, + (HttpMcpTransport.MaxConfiguredQueuedRequests + 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.MaxQueueDepthEnvVar, ex.Message, StringComparison.Ordinal); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public void HttpTransport_PositiveOverflowQueueDepthEnvironment_ThrowsWithRange() + { + using var env = EnvironmentVariableScope.Capture(HttpMcpTransport.MaxQueueDepthEnvVar); + env.Set(HttpMcpTransport.MaxQueueDepthEnvVar, ((long)int.MaxValue + 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.MaxQueueDepthEnvVar, ex.Message, StringComparison.Ordinal); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public void HttpTransport_OversizedExplicitLimitOption_ThrowsWithRange() + { + var listen = HttpMcpTransport.ResolveListenSpec("127.0.0.1:0"); + var ex = Assert.Throws(() => new HttpMcpTransport( + listen.Prefix, + listen.Host, + listen.Port, + bearerToken: null, + maxRequestBodyBytes: HttpMcpTransport.MaxConfiguredRequestBodyBytes + 1)); + + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}", + ex.Message, + StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_RequestQueueFull_Returns429() { diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 2cf80b0f66..ff1d3032d3 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -1,7 +1,9 @@ using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Mcp; using CodeIndex.Models; using Microsoft.Data.Sqlite; +using System.Globalization; using System.IO.Compression; using System.Text.Json; @@ -43,6 +45,24 @@ public void Mcp_UnsupportedOptionReturnUsageError() Assert.DoesNotContain("Warning: unknown option", stderr); } + [Fact] + public void Mcp_HttpOversizedLimitEnvironmentReturnsUsageError() + { + var oversized = (HttpMcpTransport.MaxConfiguredRequestBodyBytes + 1).ToString(CultureInfo.InvariantCulture); + var (exitCode, _, stderr) = RunCliInSubprocess( + ["mcp", "--transport", "http"], + new Dictionary { [HttpMcpTransport.MaxRequestBodyBytesEnvVar] = oversized }); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains(HttpMcpTransport.MaxRequestBodyBytesEnvVar, stderr); + Assert.Contains( + $"between 1 and {HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}", + stderr, + StringComparison.Ordinal); + Assert.Contains("HTTP limits:", stderr); + Assert.DoesNotContain("HTTP transport listening", stderr); + } + [Fact] public void Mcp_DbAcceptsLeadingDoubleDashPathValueViaInlineLiteral() {