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
12 changes: 8 additions & 4 deletions DEVELOPER_GUIDE.md

Large diffs are not rendered by default.

34 changes: 28 additions & 6 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1352,8 +1352,19 @@ Example output:

| Environment variable | Meaning |
|---|---|
| `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`. |
| `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. |
| `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. |

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

| Environment variable | Default | Maximum | Invalid value |
|---|---:|---:|---|
| `CDIDX_MCP_RESPONSE_MAX_BYTES` | `10485760` (10 MiB) | `67108864` (64 MiB) | Uses the default and emits a warning |
| `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` | `1048576` (1 MiB) | `10485760` (10 MiB) | Uses the default and emits a warning |

Values above the maximum are clamped with a warning. `status` reports effective response caps under `mcp.limits.max_response_bytes` and `mcp.limits.batch_response_bytes`.

Pagination-style MCP graph tools clamp `offset` to `10000` before querying SQLite; `tools/list` advertises the schema maximum, and `status` reports the offset cap under `mcp.limits.max_pagination_offset`. `status` also reports the current rate limiter settings under `mcp.rate_limit`.

Caller identity is captured from the `clientInfo.name` (and `version` when present) of the MCP `initialize` request. Tool calls received before `initialize` are billed against an anonymous `"unknown"` bucket so an unidentified client cannot bypass the limiter. The captured caller is sticky for the lifetime of the session — once a named identity has been recorded, subsequent `initialize` calls under a different name are ignored (with a one-line `stderr` warning) so a long-lived stdio or networked session cannot reset its bucket mid-flight by re-identifying.

Expand Down Expand Up @@ -1980,7 +1991,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 current server does not emit unsolicited frames, but 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`. 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 @@ -3464,8 +3475,19 @@ MCP ツールで catch-all まで突き抜けた例外(想定外の SQLite 例

| 環境変数 | 意味 |
|---|---|
| `CDIDX_MCP_RATE_LIMIT_RPS` | 1 秒あたりのトークン補充レート。レート制限を有効化するために必須。未設定・非数値・0 以下・非有限値(`Infinity`/`NaN`)の場合は無効のまま、1 行の警告を `stderr` に出力します。 |
| `CDIDX_MCP_RATE_LIMIT_BURST` | バケット容量(最大バースト)。任意。既定は `max(rps, 1)`。不正値・非有限値は既定にフォールバックし警告を出力。`rps` はそのまま尊重されます。 |
| `CDIDX_MCP_RATE_LIMIT_RPS` | 1 秒あたりのトークン補充レート。レート制限を有効化するために必須。未設定・非数値・0 以下・非有限値(`Infinity`/`NaN`)の場合は無効のまま、1 行の警告を `stderr` に出力します。`100` を超える値は警告付きで `100` にクランプされます。 |
| `CDIDX_MCP_RATE_LIMIT_BURST` | バケット容量(最大バースト)。任意。既定は `max(rps, 1)`。不正値・非有限値は既定にフォールバックし警告を出力。`rps` はそのまま尊重されます。`1000` を超える値は警告付きで `1000` にクランプされます。 |

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

| 環境変数 | 既定 | 最大 | 不正値 |
|---|---:|---:|---|
| `CDIDX_MCP_RESPONSE_MAX_BYTES` | `10485760` (10 MiB) | `67108864` (64 MiB) | 警告を出して既定値を使用 |
| `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` | `1048576` (1 MiB) | `10485760` (10 MiB) | 警告を出して既定値を使用 |

最大値を超える値は警告付きでクランプされます。`status` は有効なレスポンス上限を `mcp.limits.max_response_bytes` と `mcp.limits.batch_response_bytes` に返します。

ページング型の MCP graph ツールは SQLite クエリ前に `offset` を `10000` へクランプします。`tools/list` は schema の最大値を広告し、`status` は offset 上限を `mcp.limits.max_pagination_offset` に返します。`status` は現在のレート制限設定も `mcp.rate_limit` に返します。

呼び出し元 ID は MCP `initialize` リクエストの `clientInfo.name`(および `version` があれば併記)から取得します。`initialize` 前に届いたツール呼び出しは匿名 `"unknown"` バケットで計量され、未識別クライアントによる制限回避を防ぎます。取得済みの caller はセッション中 sticky で、名前付き ID が一度記録されると以降の別名 `initialize` は無視され(`stderr` に 1 行警告)、長期 stdio / 通信セッションが途中で再 initialize してバケットをリセットする経路を塞ぎます。

Expand Down Expand Up @@ -4073,7 +4095,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 を開きます。現サーバーは自発的な frame をまだ送信しませんが、この 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` 付き)です。永続 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/2861.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: security
issues:
- 2861
affected:
- src/CodeIndex/Mcp/McpServer.cs
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- USER_GUIDE.md
- DEVELOPER_GUIDE.md
---

## English

- **MCP response byte-limit overrides are now capped (#2861)** — `CDIDX_MCP_RESPONSE_MAX_BYTES` and `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` reject invalid values, clamp oversized values, and expose the effective caps through MCP `status`.

## 日本語

- **MCP response byte-limit override に上限を設けました (#2861)** — `CDIDX_MCP_RESPONSE_MAX_BYTES` と `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` は不正値を拒否し、過大値をクランプし、有効な上限を MCP `status` から確認できるようになりました。
20 changes: 20 additions & 0 deletions changelog.d/unreleased/2895.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
category: security
issues:
- 2895
affected:
- src/CodeIndex/Mcp/McpServer.cs
- src/CodeIndex/Mcp/McpToolDefinitions.cs
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- USER_GUIDE.md
- DEVELOPER_GUIDE.md
---

## English

- **MCP graph pagination offsets are now capped before SQL queries (#2895)** — `references`, `callers`, and `callees` clamp oversized offsets, advertise the cap in `tools/list`, and report it through MCP `status`.

## 日本語

- **MCP graph pagination offset を SQL query 前に上限クランプするようにしました (#2895)** — `references`、`callers`、`callees` は過大な offset をクランプし、`tools/list` と MCP `status` から上限を確認できるようになりました。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/2899.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: security
issues:
- 2899
affected:
- src/CodeIndex/Mcp/McpServer.cs
- tests/CodeIndex.Tests/HttpMcpTransportTests.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- USER_GUIDE.md
- DEVELOPER_GUIDE.md
---

## English

- **MCP HTTP keep-alive intervals now reject unsafe environment values (#2899)** — `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` only accepts finite values from 1 to 300 seconds and leaves keep-alive disabled on invalid or out-of-range input.

## 日本語

- **MCP HTTP keep-alive interval が安全でない環境変数値を拒否するようになりました (#2899)** — `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` は有限な 1〜300 秒だけを受け入れ、不正値や範囲外では keep-alive を無効のままにします。
19 changes: 19 additions & 0 deletions changelog.d/unreleased/2900.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: security
issues:
- 2900
affected:
- src/CodeIndex/Mcp/McpToolHandlers.cs
- src/CodeIndex/Mcp/RateLimiter.cs
- tests/CodeIndex.Tests/McpServerTests.cs
- tests/CodeIndex.Tests/RateLimiterTests.cs
- USER_GUIDE.md
---

## English

- **MCP rate-limit environment values now have upper caps (#2900)** — excessive RPS and burst overrides are clamped with warnings and the effective limiter settings are visible in MCP `status`.

## 日本語

- **MCP rate-limit 環境変数値に上限を設けました (#2900)** — 過大な RPS / burst override は警告付きでクランプされ、有効な limiter 設定を MCP `status` で確認できます。
41 changes: 36 additions & 5 deletions src/CodeIndex/Mcp/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ public partial class McpServer : IDisposable
internal const int MaxLineCharacterCount = 1_000_000;
internal const int MaxLineByteLength = 1_048_576;
internal const int DefaultMaxResponseBytes = 10 * 1024 * 1024;
internal const int MaxConfiguredResponseBytes = 64 * 1024 * 1024;
internal const int MaxMcpPaginationOffset = 10_000;
internal const double MinKeepAliveIntervalSeconds = 1.0;
internal const double MaxKeepAliveIntervalSeconds = 300.0;
private const string MaxResponseBytesEnvVar = "CDIDX_MCP_RESPONSE_MAX_BYTES";
private const string KeepAliveIntervalEnvironmentVariable = "CDIDX_MCP_KEEP_ALIVE_INTERVAL_S";
internal const string DebugEnvironmentVariable = "CDIDX_DEBUG";
Expand Down Expand Up @@ -1336,8 +1340,14 @@ private string BuildKeepAliveNotificationJson()
if (string.IsNullOrWhiteSpace(raw))
return null;
if (!double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| seconds <= 0)
|| !double.IsFinite(seconds)
|| seconds < MinKeepAliveIntervalSeconds
|| seconds > MaxKeepAliveIntervalSeconds)
{
Console.Error.WriteLine(
$"[cdidx-mcp] Ignoring invalid {KeepAliveIntervalEnvironmentVariable}='{raw}'. Expected a finite value between {MinKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} and {MaxKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} seconds. Keep-alive notifications stay disabled.");
return null;
}
return TimeSpan.FromSeconds(seconds);
}

Expand Down Expand Up @@ -3026,11 +3036,32 @@ private static JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id,
}

private static int GetMaxResponseBytes()
=> ReadPositiveIntEnvironmentLimit(
MaxResponseBytesEnvVar,
DefaultMaxResponseBytes,
MaxConfiguredResponseBytes,
"MCP response byte limit");

private static int ReadPositiveIntEnvironmentLimit(string envVar, int defaultValue, int maximumValue, string description)
{
var configured = Environment.GetEnvironmentVariable(MaxResponseBytesEnvVar);
if (int.TryParse(configured, out var limit) && limit > 0)
return limit;
return DefaultMaxResponseBytes;
var raw = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrWhiteSpace(raw))
return defaultValue;

if (!int.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var limit)
|| limit <= 0)
{
Console.Error.WriteLine($"[cdidx-mcp] Ignoring invalid {envVar}='{raw}'. Expected a positive integer for {description}. Using default {defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}.");
return defaultValue;
}

if (limit > maximumValue)
{
Console.Error.WriteLine($"[cdidx-mcp] Clamping {envVar}='{raw}' to maximum {maximumValue.ToString(System.Globalization.CultureInfo.InvariantCulture)} for {description}.");
return maximumValue;
}

return limit;
}

/// <summary>
Expand Down
4 changes: 4 additions & 0 deletions src/CodeIndex/Mcp/McpToolDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,10 @@ private static void ApplyCommonSchemaConstraint(string toolName, string name, Js
obj.TryAdd("minimum", 1);
obj.TryAdd("maximum", MaxLimit);
break;
case "offset":
obj.TryAdd("minimum", 0);
obj.TryAdd("maximum", MaxMcpPaginationOffset);
break;
case "startLine":
case "endLine":
obj.TryAdd("minimum", 1);
Expand Down
30 changes: 22 additions & 8 deletions src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace CodeIndex.Mcp;
public partial class McpServer
{
private const int DefaultBatchQueryResponseByteLimit = MaxLineByteLength;
internal const int MaxBatchQueryResponseByteLimit = 10 * 1024 * 1024;
private const int DefaultExcerptOutputByteLimit = MaxLineByteLength;
private const string BatchQueryResponseByteLimitEnvVar = "CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES";
internal const int MaxMcpArrayFilterCount = 100;
Expand Down Expand Up @@ -290,7 +291,7 @@ private static JsonObject BuildExcerptArgs(string path, int startLine, int endLi
private static int ClampLimit(int limit) => Math.Clamp(limit, 1, MaxLimit);

private static int ReadOffset(JsonNode? args)
=> Math.Max(0, args?["offset"]?.GetValue<int>() ?? 0);
=> Math.Clamp(args?["offset"]?.GetValue<int>() ?? 0, 0, MaxMcpPaginationOffset);

private static string ReadResponseFormat(JsonNode? args)
=> args?["format"]?.GetValue<string>()?.Trim().ToLowerInvariant() ?? "full";
Expand Down Expand Up @@ -1970,9 +1971,23 @@ private JsonNode ExecuteStatus(JsonNode? id)
["max_request_characters"] = MaxLineCharacterCount,
["max_request_bytes"] = MaxLineByteLength,
["max_response_bytes"] = GetMaxResponseBytes(),
["max_configured_response_bytes"] = MaxConfiguredResponseBytes,
["batch_response_bytes"] = GetBatchQueryResponseByteLimit(),
["max_batch_response_bytes"] = MaxBatchQueryResponseByteLimit,
["max_pagination_offset"] = MaxMcpPaginationOffset,
["max_json_depth"] = MaxJsonDepth,
["max_batch_requests"] = MaxBatchRequestCount,
}
["keep_alive_min_interval_s"] = MinKeepAliveIntervalSeconds,
["keep_alive_max_interval_s"] = MaxKeepAliveIntervalSeconds,
["rate_limit_max_rps"] = RateLimiterOptions.MaxRefillTokensPerSecond,
["rate_limit_max_burst"] = RateLimiterOptions.MaxBurstCapacity,
},
["rate_limit"] = new JsonObject
{
["enabled"] = RateLimiter.Options.IsEnabled,
["rps"] = RateLimiter.Options.RefillTokensPerSecond,
["burst"] = RateLimiter.Options.BurstCapacity,
},
};
return CreateToolResult(id, "Database stats returned.", structured);
});
Expand Down Expand Up @@ -2729,12 +2744,11 @@ string BuildSummary()
}

private static int GetBatchQueryResponseByteLimit()
{
var configured = Environment.GetEnvironmentVariable(BatchQueryResponseByteLimitEnvVar);
if (int.TryParse(configured, out var limit) && limit > 0)
return limit;
return DefaultBatchQueryResponseByteLimit;
}
=> ReadPositiveIntEnvironmentLimit(
BatchQueryResponseByteLimitEnvVar,
DefaultBatchQueryResponseByteLimit,
MaxBatchQueryResponseByteLimit,
"MCP batch_query response byte limit");

private int EstimateJsonUtf8Bytes(JsonNode node) =>
Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions));
Expand Down
Loading
Loading