Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <token>` を要求し、定数時間で比較する。トークン未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。
Expand Down
4 changes: 2 additions & 2 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 を記録します。リクエスト/レスポンス本文は記録しません。

セキュリティ既定:

Expand Down
21 changes: 21 additions & 0 deletions changelog.d/unreleased/3157-3227.fixed.md
Original file line number Diff line number Diff line change
@@ -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 が誤設定されることを防ぎます。
14 changes: 13 additions & 1 deletion src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -2544,7 +2556,7 @@ private static void PrintMcpUsage()
{
Console.Error.WriteLine("Usage: cdidx mcp [--db <path>] [--transport stdio|http] [--http-listen <host:port>] [--audit-log <path>] [--audit-log-include-values] [--audit-log-max-bytes <n>] [--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}=<bytes> (default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}=<n> (default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}).");
Console.Error.WriteLine($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}=<bytes> (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}=<n> (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}).");
}

internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string error)
Expand Down
45 changes: 39 additions & 6 deletions src/CodeIndex/Mcp/HttpMcpTransport.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";

Expand Down Expand Up @@ -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<PendingRequest>(new BoundedChannelOptions(_maxQueuedRequests)
{
SingleReader = true,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading