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
21 changes: 16 additions & 5 deletions DEVELOPER_GUIDE.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,7 @@ Example output:
|---|---|
| `CDIDX_MCP_RATE_LIMIT_RPS` | Refill rate in tokens per second. Required to enable rate limiting; values that are missing, non-numeric, zero, negative, or non-finite (`Infinity`, `NaN`) leave the limiter disabled and emit a one-line warning on `stderr`. Values above `100` are clamped to `100` with a warning. |
| `CDIDX_MCP_RATE_LIMIT_BURST` | Bucket capacity (maximum burst). Optional. Defaults to `max(rps, 1)`. Invalid or non-finite values fall back to the default and emit a warning while leaving `rps` honored. Values above `1000` are clamped to `1000` with a warning. |
| `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` | Idle bucket TTL. Optional. Defaults to 900 seconds. Stale `(tool, caller)` buckets are pruned on later calls so long-lived servers do not retain historical caller identities forever. Invalid or non-finite values fall back to the default and emit a warning. |

MCP response-size limits are bounded so environment overrides cannot disable the response guards:

Expand Down Expand Up @@ -2001,7 +2002,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`. 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`; 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.

Security defaults:

Expand Down Expand Up @@ -3495,6 +3496,7 @@ MCP ツールで catch-all まで突き抜けた例外(想定外の SQLite 例
|---|---|
| `CDIDX_MCP_RATE_LIMIT_RPS` | 1 秒あたりのトークン補充レート。レート制限を有効化するために必須。未設定・非数値・0 以下・非有限値(`Infinity`/`NaN`)の場合は無効のまま、1 行の警告を `stderr` に出力します。`100` を超える値は警告付きで `100` にクランプされます。 |
| `CDIDX_MCP_RATE_LIMIT_BURST` | バケット容量(最大バースト)。任意。既定は `max(rps, 1)`。不正値・非有限値は既定にフォールバックし警告を出力。`rps` はそのまま尊重されます。`1000` を超える値は警告付きで `1000` にクランプされます。 |
| `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` | 未使用バケットの TTL。任意。既定は 900 秒です。古い `(tool, caller)` バケットは後続呼び出し時に pruning され、長時間稼働するサーバーが過去の caller ID を永続保持しません。不正値・非有限値は既定にフォールバックし警告を出力します。 |

MCP のレスポンスサイズ上限は、環境変数 override で guard が実質無効化されないよう上限付きです:

Expand Down Expand Up @@ -4113,7 +4115,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` 付き)です。永続 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` で調整でき、不正値は既定にフォールバックします。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。リクエスト/レスポンス本文は記録しません。

セキュリティ既定:

Expand Down
19 changes: 19 additions & 0 deletions changelog.d/unreleased/2815.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: security
issues:
- 2815
affected:
- src/CodeIndex/Mcp/HttpMcpTransport.cs
- src/CodeIndex/Cli/ProgramRunner.cs
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
- USER_GUIDE.md
- DEVELOPER_GUIDE.md
---

## English

- **HTTP MCP now bounds request bodies and pending POST queue depth (#2815)** — HTTP requests over the configured body limit now return `413 Payload Too Large`, and a full pending request queue returns `429 Too Many Requests` with `Retry-After: 1` instead of retaining unbounded work.

## 日本語

- **HTTP MCP がリクエスト本文と保留中 POST queue の深さを制限するようになりました (#2815)** — 設定された本文上限を超える HTTP request は `413 Payload Too Large` を返し、保留中 request queue が満杯の場合は work を無制限に保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2823.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2823
affected:
- src/CodeIndex/Mcp/HttpMcpTransport.cs
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
- DEVELOPER_GUIDE.md
---

## English

- **HTTP MCP SSE streams no longer retain completed stream tasks (#2823)** — event streams are tracked only through the active stream registry, and disconnected streams are removed without keeping completed `Task` references for the process lifetime.

## 日本語

- **HTTP MCP SSE stream が完了済み stream task を保持しなくなりました (#2823)** — event stream は active stream registry だけで追跡され、切断済み stream はプロセス寿命いっぱい完了済み `Task` 参照を保持せずに削除されます。
18 changes: 18 additions & 0 deletions changelog.d/unreleased/2824.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: security
issues:
- 2824
affected:
- src/CodeIndex/Mcp/RateLimiter.cs
- tests/CodeIndex.Tests/RateLimiterTests.cs
- USER_GUIDE.md
- DEVELOPER_GUIDE.md
---

## English

- **MCP rate limiter buckets now expire after an idle TTL (#2824)** — stale `(tool, caller)` token buckets are pruned on later acquisitions, with `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` defaulting to 900 seconds so long-running servers do not retain historical caller identities forever.

## 日本語

- **MCP rate limiter bucket が idle TTL 後に期限切れになるようになりました (#2824)** — 古い `(tool, caller)` token bucket は後続 acquisition 時に pruning され、`CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` の既定値 900 秒により長時間稼働するサーバーが過去の caller ID を永続保持しません。
1 change: 1 addition & 0 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,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)}).");
}

internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string error)
Expand Down
110 changes: 100 additions & 10 deletions src/CodeIndex/Mcp/HttpMcpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,20 @@ namespace CodeIndex.Mcp;
/// </summary>
internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport
{
internal const int DefaultMaxRequestBodyBytes = 1_000_000;
internal const int DefaultMaxQueuedRequests = 64;
internal const string MaxRequestBodyBytesEnvVar = "CDIDX_MCP_HTTP_MAX_REQUEST_BYTES";
internal const string MaxQueueDepthEnvVar = "CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH";

private readonly HttpListener _listener;
private readonly string _endpoint;
private readonly Action<HttpRequestLogRecord>? _requestLogger;
private readonly object _requestLoggerGate = new();
private readonly ConcurrentDictionary<Guid, EventStream> _eventStreams = new();
private readonly ConcurrentBag<Task> _sseStreams = new();
private readonly CancellationTokenSource _acceptCts = new();
private readonly Channel<PendingRequest> _requestQueue = Channel.CreateUnbounded<PendingRequest>();
private readonly Channel<PendingRequest> _requestQueue;
private readonly int _maxRequestBodyBytes;
private readonly int _maxQueuedRequests;
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
Expand All @@ -43,6 +49,7 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport
// 攻撃者入力のみハッシュ計算する。これにより設定トークン長による timing 漏洩を排除する。
private readonly byte[]? _bearerTokenHash;
private PendingRequest? _pendingRequest;
private int _queuedRequestCount;
private bool _disposed;

/// <summary>
Expand All @@ -54,8 +61,24 @@ internal sealed class HttpMcpTransport : IMcpTransport, IOutOfBandMcpTransport
/// が空でない場合、すべてのリクエストに `Authorization: Bearer ...` ヘッダーが必要。トークン未指定で
/// loopback 以外に bind しようとした場合は明示的に拒否し、秘密情報なしの LAN 露出を防ぐ。
/// </summary>
internal HttpMcpTransport(string prefix, string host, int boundPort, string? bearerToken, Action<HttpRequestLogRecord>? requestLogger = null)
internal HttpMcpTransport(
string prefix,
string host,
int boundPort,
string? bearerToken,
Action<HttpRequestLogRecord>? requestLogger = null,
int? maxRequestBodyBytes = null,
int? maxQueuedRequests = null)
{
_maxRequestBodyBytes = ResolvePositiveIntOption(maxRequestBodyBytes, MaxRequestBodyBytesEnvVar, DefaultMaxRequestBodyBytes);
_maxQueuedRequests = ResolvePositiveIntOption(maxQueuedRequests, MaxQueueDepthEnvVar, DefaultMaxQueuedRequests);
_requestQueue = Channel.CreateBounded<PendingRequest>(new BoundedChannelOptions(_maxQueuedRequests)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait,
AllowSynchronousContinuations = false,
});
_listener = new HttpListener();
_listener.Prefixes.Add(prefix);
_listener.Start();
Expand Down Expand Up @@ -83,6 +106,12 @@ internal HttpMcpTransport(string prefix, string host, int boundPort, string? bea

internal bool HasEventStreams => !_eventStreams.IsEmpty;

internal int MaxRequestBodyBytes => _maxRequestBodyBytes;

internal int MaxQueuedRequests => _maxQueuedRequests;

internal int QueuedRequestCount => Volatile.Read(ref _queuedRequestCount);

/// <summary>
/// Resolve a `host:port` listen spec into the corresponding HTTP prefix. Ephemeral ports
/// (port `0`) are resolved up-front by binding a temporary <see cref="TcpListener"/> so the
Expand Down Expand Up @@ -175,6 +204,22 @@ private static int FindFreePort(IPAddress address)
}
}

private static int ResolvePositiveIntOption(int? explicitValue, string envVar, int defaultValue)
{
if (explicitValue is { } configured)
{
if (configured <= 0)
throw new ArgumentOutOfRangeException(nameof(explicitValue), configured, "HTTP MCP limits must be positive integers.");
return configured;
}

var raw = Environment.GetEnvironmentVariable(envVar);
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0)
return parsed;

return defaultValue;
}

public async Task<string?> ReadFrameAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Expand All @@ -184,6 +229,7 @@ private static int FindFreePort(IPAddress address)
try
{
var request = await _requestQueue.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
Interlocked.Decrement(ref _queuedRequestCount);
_pendingRequest = request;
return request.Body;
}
Expand Down Expand Up @@ -255,7 +301,7 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT
return;
}

_sseStreams.Add(Task.Run(() => RunEventStreamAsync(request, cancellationToken), CancellationToken.None));
_ = Task.Run(() => RunEventStreamAsync(request, cancellationToken), CancellationToken.None);
return;
}

Expand All @@ -267,11 +313,9 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT
return;
}

string body;
using (var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding ?? Encoding.UTF8))
{
body = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
}
var body = await TryReadRequestBodyAsync(request, cancellationToken).ConfigureAwait(false);
if (body is null)
return;

if (string.IsNullOrWhiteSpace(body))
{
Expand All @@ -286,7 +330,53 @@ private async Task HandleContextAsync(HttpListenerContext context, CancellationT
if (TryHandleOutOfBandFrame(request, body))
return;

await _requestQueue.Writer.WriteAsync(request, cancellationToken).ConfigureAwait(false);
if (!TryQueueRequest(request))
{
context.Response.AddHeader("Retry-After", "1");
await RespondAsync(context, (int)HttpStatusCode.TooManyRequests, "MCP HTTP request queue is full.\n").ConfigureAwait(false);
LogRequest(request, (int)HttpStatusCode.TooManyRequests);
}
}

private async Task<string?> TryReadRequestBodyAsync(PendingRequest request, CancellationToken cancellationToken)
{
var context = request.Context;
if (context.Request.ContentLength64 > _maxRequestBodyBytes)
{
await RespondAsync(context, (int)HttpStatusCode.RequestEntityTooLarge, $"MCP HTTP request body exceeds the configured {_maxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)} byte limit.\n").ConfigureAwait(false);
LogRequest(request, (int)HttpStatusCode.RequestEntityTooLarge);
return null;
}

using var buffer = new MemoryStream();
var scratch = new byte[Math.Min(8192, _maxRequestBodyBytes)];
while (true)
{
var read = await context.Request.InputStream.ReadAsync(scratch.AsMemory(), cancellationToken).ConfigureAwait(false);
if (read == 0)
break;

if (buffer.Length + read > _maxRequestBodyBytes)
{
await RespondAsync(context, (int)HttpStatusCode.RequestEntityTooLarge, $"MCP HTTP request body exceeds the configured {_maxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)} byte limit.\n").ConfigureAwait(false);
LogRequest(request, (int)HttpStatusCode.RequestEntityTooLarge);
return null;
}

buffer.Write(scratch, 0, read);
}

return (context.Request.ContentEncoding ?? Encoding.UTF8).GetString(buffer.ToArray());
}

private bool TryQueueRequest(PendingRequest request)
{
Interlocked.Increment(ref _queuedRequestCount);
if (_requestQueue.Writer.TryWrite(request))
return true;

Interlocked.Decrement(ref _queuedRequestCount);
return false;
}

private bool TryHandleOutOfBandFrame(PendingRequest request, string body)
Expand Down
Loading
Loading