From edbcac1793ee263906ec4b60f837fcae2deecd86 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:38:25 +0900 Subject: [PATCH 1/4] Cap MCP response byte limits --- DEVELOPER_GUIDE.md | 6 +++-- USER_GUIDE.md | 18 +++++++++++++ changelog.d/unreleased/2861.security.md | 19 ++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 30 +++++++++++++++++++--- src/CodeIndex/Mcp/McpToolHandlers.cs | 15 ++++++----- tests/CodeIndex.Tests/McpServerTests.cs | 34 +++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/2861.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2998f677a7..732892d254 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1177,7 +1177,8 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Authoritative C# metadata-target trust** — `deps` / `impact` metadata-attribute edges (linking `[Foo]` usage to the defining `FooAttribute` class) are promoted from a signature-shape heuristic to an authoritative resolver whenever `is_metadata_target` is persisted under the current `metadata_target_version_csharp` contract. The resolver walks C# class base lists with fixed-point transitive resolution through same-DB class rows and falls back to the BCL `Attribute` suffix convention only for unresolved external bases. Readiness lives in `codeindex_meta`, and the reader uses a three-way branch: (1) ready → `is_metadata_target = 1`; (2) column present but not stamped (legacy row) → `signature LIKE '%: %'`; (3) column missing → naming-only fallback. This fixes non-attribute impostors (`class FooAttribute : BaseService`) silently dropping edges when they shared names with real `FooAttribute : Attribute` classes (#435). - **Human-readable default** — All commands default to human-readable output. `--json` for AI/machine consumption. - **Structured MCP responses** — MCP tool calls return typed JSON in `structuredContent` and keep `content` concise for compatibility. -- **MCP `batch_query` response cap** — `batch_query` estimates the UTF-8 JSON size of aggregate slot results and stops appending once the response would exceed `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` (default: 1,000,000 bytes, aligned with the JSON-RPC line cap). Truncated responses include `truncated: true`, `truncated_queries`, and byte-limit metadata so clients can split the batch or lower per-slot limits without parsing prose (#1416). +- **MCP envelope response cap** — `CDIDX_MCP_RESPONSE_MAX_BYTES` defaults to 10 MiB and clamps at 64 MiB. Invalid values fall back to the default; values above the cap are clamped with a stderr warning so operators cannot accidentally disable the JSON-RPC response guard. +- **MCP `batch_query` response cap** — `batch_query` estimates the UTF-8 JSON size of aggregate slot results and stops appending once the response would exceed `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` (default: 1 MiB / 1,048,576 bytes; maximum: 10 MiB). Truncated responses include `truncated: true`, `truncated_queries`, and byte-limit metadata so clients can split the batch or lower per-slot limits without parsing prose (#1416). Invalid values fall back to the default, values above the maximum are clamped with a stderr warning, and MCP `status` exposes the effective value under `mcp.limits.batch_response_bytes`. - **MCP array argument bounds** — MCP string-array filters such as `path`, `project`, `excludePaths`, and mixed `names` arrays reject invalid entries instead of silently dropping them. Arrays are capped at 100 entries and each entry is capped at 4096 characters; `batch_query` reports these validation failures per slot with `request_index` and `ok: false`. - **MCP schema lock-down** — Every tool `inputSchema` includes `additionalProperties: false`, and `tools/call` mirrors that contract by rejecting unknown argument names with `-32602` / `invalid_argument` instead of silently defaulting misspelled fields. - **MCP stability markers and naming** — Every tool advertises `x-stability` (`stable`, `experimental`, or `deprecated`). MCP structured payload keys use snake_case, matching the CLI JSON contract; do not add camelCase aliases for new fields. @@ -2776,7 +2777,8 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**JS/TS の no-paren constructor**: JavaScript / TypeScript の zero-argument constructor call で `()` を合法的に省略できる `new Foo;`、`new Date;`、`new Demo.Provider;`、`new Box;` も、専用の言語別経路で `instantiate` edge として出す。行末 `new Foo` に対する次行 `.bar()` / `[0]` continuation は suppress し、phantom な単独 instantiation にしない。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および C# XML doc の `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。C# XML doc の `cref` 抽出は、実際に後続宣言へ結び付く XML-doc comment である `///` 行と delimited `/** ... */` block の両方を対象にしつつ、通常の `//` / `////` コメントや通常の block comment は phantom 依存として扱わない。また、同じ物理行でも closing `*/` より後ろに続く code / string の内容、doc comment と後続宣言の間へ割り込むトップレベル実行文、brace-free field/property initializer continuation、brace-free expression lambda、nested executable continuation、複数行 raw/verbatim string のうち行頭がたまたま `/**` で始まる内容は doc-comment slice の外として扱う。regex 自体は narrowed した doc-comment slice に対して走らせるが、`symbol_references.column` は元の物理ソース行位置に固定したまま保持する。C# の read path では、`using static` による constant-pattern suppress が `is` / `case` の前後の trivia を考慮してトークン単位で判定され、anchor が前行にある場合は anchor-aware な複数行コンテキストをインデックス済み行から再構成するため、`value is/*comment*/Red`、`value is\n Red or Blue`、`value is\n // comment\n Red`、`case\n // comment\n Point:`、長い `case` / `or` 連鎖、`case\tRed:` のような形でも phantom `type_reference` を漏らさない。qualified constant/member pattern は exact-name read path でも qualifier 起点で suppress するため、`case Color.Red or Color.Blue:` に対して無関係な `class Red {}` が suppress を打ち消さない。extractor 側の pending type-pattern carry も trivia-only 区切り行、standalone な continuation-line `not`、複数行 `case` head / logical continuation をまたいで維持されるため、comment-only 行や `not` だけの継続行で後続の本物の type head を落とさない。`case > 0:` や `case not > 0:` のような非型 `case` ラベルではその pending carry を armed にしないため、次行の call/identifier token が `type_reference` に混入しない。同名型の rescue も `file` 可視性を尊重し、file-local な型は同じ物理ファイル内の参照だけを救済する。基底クラスから見える protected/public/internal nested type は、基底型参照を active な型 alias / namespace alias 経由まで正規化し、さらに alias 展開後に constructed generic な基底型を再 canonicalize したうえで derived class の pattern head を救済する一方、implemented interface は inherited nested-type rescue に参加しない。さらに same-file `using Namespace;`、project-wide `global using Namespace;`、型 alias も同じ rescue 集合に入る。一方で extractor は file-local な情報だけでは同一 namespace の別ファイルにある実型を判定できないため、`value is Red` のような曖昧な unqualified `using static` head は DB に残し、pure constant-only case の抑止は workspace-aware な read path 側で行う。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **構造化MCPレスポンス** — MCPツール呼び出しは `structuredContent` に型付きJSONを返し、`content` は互換性のため簡潔に保つ。 -- **MCP `batch_query` レスポンス上限** — `batch_query` は集約した slot 結果の UTF-8 JSON サイズを見積もり、`CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES`(既定: JSON-RPC 行上限と揃えた 1,000,000 bytes)を超える場合は追加を止める。切り詰めたレスポンスには `truncated: true`、`truncated_queries`、byte limit メタデータを含めるため、クライアントは prose を parsing せず batch 分割や slot limit 縮小を判断できる (#1416)。 +- **MCP envelope レスポンス上限** — `CDIDX_MCP_RESPONSE_MAX_BYTES` は既定 10 MiB、最大 64 MiB。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプするため、operator が誤って JSON-RPC response guard を実質無効化できない。 +- **MCP `batch_query` レスポンス上限** — `batch_query` は集約した slot 結果の UTF-8 JSON サイズを見積もり、`CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES`(既定: 1 MiB / 1,048,576 bytes、最大: 10 MiB)を超える場合は追加を止める。切り詰めたレスポンスには `truncated: true`、`truncated_queries`、byte limit メタデータを含めるため、クライアントは prose を parsing せず batch 分割や slot limit 縮小を判断できる (#1416)。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプし、有効値は MCP `status` の `mcp.limits.batch_response_bytes` で確認できる。 - **MCP 配列引数の上限** — `path` / `project` / `excludePaths` / mixed `names` などの string-array filter は、不正要素を暗黙に落とさず拒否する。配列は 100 件、各要素は 4096 文字を上限とし、`batch_query` では `request_index` と `ok: false` 付きの slot 失敗として報告する。 - **MCP schema のロックダウン** — すべての tool `inputSchema` は `additionalProperties: false` を含み、`tools/call` も同じ契約として未知の引数名を黙って既定値にせず `-32602` / `invalid_argument` で拒否する。 - **MCP stability marker と命名** — すべての tool は `x-stability`(`stable`、`experimental`、`deprecated`)を公開する。MCP の構造化 payload key は CLI JSON 契約に合わせて snake_case を使う。新規 field に camelCase alias を追加しないこと。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 225521bee0..1e383fc441 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1355,6 +1355,15 @@ 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`. | | `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. | +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`. + 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. Over-quota tool calls receive a structured JSON-RPC `-32000` error: @@ -3467,6 +3476,15 @@ MCP ツールで catch-all まで突き抜けた例外(想定外の SQLite 例 | `CDIDX_MCP_RATE_LIMIT_RPS` | 1 秒あたりのトークン補充レート。レート制限を有効化するために必須。未設定・非数値・0 以下・非有限値(`Infinity`/`NaN`)の場合は無効のまま、1 行の警告を `stderr` に出力します。 | | `CDIDX_MCP_RATE_LIMIT_BURST` | バケット容量(最大バースト)。任意。既定は `max(rps, 1)`。不正値・非有限値は既定にフォールバックし警告を出力。`rps` はそのまま尊重されます。 | +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` に返します。 + 呼び出し元 ID は MCP `initialize` リクエストの `clientInfo.name`(および `version` があれば併記)から取得します。`initialize` 前に届いたツール呼び出しは匿名 `"unknown"` バケットで計量され、未識別クライアントによる制限回避を防ぎます。取得済みの caller はセッション中 sticky で、名前付き ID が一度記録されると以降の別名 `initialize` は無視され(`stderr` に 1 行警告)、長期 stdio / 通信セッションが途中で再 initialize してバケットをリセットする経路を塞ぎます。 超過したツール呼び出しには構造化された JSON-RPC `-32000` エラーを返します: diff --git a/changelog.d/unreleased/2861.security.md b/changelog.d/unreleased/2861.security.md new file mode 100644 index 0000000000..ea194492aa --- /dev/null +++ b/changelog.d/unreleased/2861.security.md @@ -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` から確認できるようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index d2a9965b53..cd7b8d71f9 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -159,6 +159,7 @@ 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; 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"; @@ -3026,11 +3027,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; } /// diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 2cadc9772f..b116e43a65 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -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; @@ -1970,6 +1971,9 @@ 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_json_depth"] = MaxJsonDepth, ["max_batch_requests"] = MaxBatchRequestCount, } @@ -2729,12 +2733,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)); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e15f8ed74f..4d56db5fd4 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5196,6 +5196,27 @@ public void ToolsCall_ResponseOverByteLimit_ReturnsStructuredError() Assert.True(response["error"]!["data"]!["actual_bytes"]!.GetValue() > 256); } + [Fact] + public void ToolsCall_Status_ReportsResponseByteLimitCaps() + { + using var env = EnvironmentVariableScope.Capture( + "CDIDX_MCP_RESPONSE_MAX_BYTES", + "CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"); + env.Set("CDIDX_MCP_RESPONSE_MAX_BYTES", int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + env.Set("CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES", int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: true); + var response = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status"}}""")!)!; + + var mcp = response["result"]!["structuredContent"]!["mcp"]!; + var limits = mcp["limits"]!; + Assert.Equal(McpServer.MaxConfiguredResponseBytes, limits["max_response_bytes"]!.GetValue()); + Assert.Equal(McpServer.MaxConfiguredResponseBytes, limits["max_configured_response_bytes"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["batch_response_bytes"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["max_batch_response_bytes"]!.GetValue()); + } + [Fact] public async Task ProcessFrameAsync_BatchResponseOverByteLimit_ReturnsStructuredError() { @@ -6766,6 +6787,19 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() } } + [Fact] + public void ToolsCall_BatchQuery_ClampsTooLargeResponseLimitEnvironment() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"); + env.Set("CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES", int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[{"tool":"ping"}]}}}""")!; + var response = _server.HandleMessage(request)!; + + var metadata = response["result"]!["structuredContent"]!["metadata"]!; + Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, metadata["response_byte_limit"]!.GetValue()); + } + [Fact] public void ApplyExcerptOutputBudget_TruncatesAtLineBoundary_Issue1605() { From 3aee5a85fc80678e4a4941ca78247258803ef938 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:40:38 +0900 Subject: [PATCH 2/4] Cap MCP graph pagination offsets --- DEVELOPER_GUIDE.md | 2 + USER_GUIDE.md | 4 ++ changelog.d/unreleased/2895.security.md | 20 +++++++++ src/CodeIndex/Mcp/McpServer.cs | 1 + src/CodeIndex/Mcp/McpToolDefinitions.cs | 4 ++ src/CodeIndex/Mcp/McpToolHandlers.cs | 3 +- tests/CodeIndex.Tests/McpServerTests.cs | 57 +++++++++++++++++++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2895.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 732892d254..f3ca95b717 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1179,6 +1179,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Structured MCP responses** — MCP tool calls return typed JSON in `structuredContent` and keep `content` concise for compatibility. - **MCP envelope response cap** — `CDIDX_MCP_RESPONSE_MAX_BYTES` defaults to 10 MiB and clamps at 64 MiB. Invalid values fall back to the default; values above the cap are clamped with a stderr warning so operators cannot accidentally disable the JSON-RPC response guard. - **MCP `batch_query` response cap** — `batch_query` estimates the UTF-8 JSON size of aggregate slot results and stops appending once the response would exceed `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` (default: 1 MiB / 1,048,576 bytes; maximum: 10 MiB). Truncated responses include `truncated: true`, `truncated_queries`, and byte-limit metadata so clients can split the batch or lower per-slot limits without parsing prose (#1416). Invalid values fall back to the default, values above the maximum are clamped with a stderr warning, and MCP `status` exposes the effective value under `mcp.limits.batch_response_bytes`. +- **MCP pagination offset cap** — `references`, `callers`, and `callees` clamp `offset` to 10,000 before executing SQL queries. `tools/list` advertises the maximum in each offset schema, and MCP `status` mirrors it under `mcp.limits.max_pagination_offset`. - **MCP array argument bounds** — MCP string-array filters such as `path`, `project`, `excludePaths`, and mixed `names` arrays reject invalid entries instead of silently dropping them. Arrays are capped at 100 entries and each entry is capped at 4096 characters; `batch_query` reports these validation failures per slot with `request_index` and `ok: false`. - **MCP schema lock-down** — Every tool `inputSchema` includes `additionalProperties: false`, and `tools/call` mirrors that contract by rejecting unknown argument names with `-32602` / `invalid_argument` instead of silently defaulting misspelled fields. - **MCP stability markers and naming** — Every tool advertises `x-stability` (`stable`, `experimental`, or `deprecated`). MCP structured payload keys use snake_case, matching the CLI JSON contract; do not add camelCase aliases for new fields. @@ -2779,6 +2780,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **構造化MCPレスポンス** — MCPツール呼び出しは `structuredContent` に型付きJSONを返し、`content` は互換性のため簡潔に保つ。 - **MCP envelope レスポンス上限** — `CDIDX_MCP_RESPONSE_MAX_BYTES` は既定 10 MiB、最大 64 MiB。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプするため、operator が誤って JSON-RPC response guard を実質無効化できない。 - **MCP `batch_query` レスポンス上限** — `batch_query` は集約した slot 結果の UTF-8 JSON サイズを見積もり、`CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES`(既定: 1 MiB / 1,048,576 bytes、最大: 10 MiB)を超える場合は追加を止める。切り詰めたレスポンスには `truncated: true`、`truncated_queries`、byte limit メタデータを含めるため、クライアントは prose を parsing せず batch 分割や slot limit 縮小を判断できる (#1416)。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプし、有効値は MCP `status` の `mcp.limits.batch_response_bytes` で確認できる。 +- **MCP pagination offset 上限** — `references`、`callers`、`callees` は SQL query 実行前に `offset` を 10,000 へクランプする。`tools/list` は各 offset schema に最大値を広告し、MCP `status` も `mcp.limits.max_pagination_offset` に同じ値を返す。 - **MCP 配列引数の上限** — `path` / `project` / `excludePaths` / mixed `names` などの string-array filter は、不正要素を暗黙に落とさず拒否する。配列は 100 件、各要素は 4096 文字を上限とし、`batch_query` では `request_index` と `ok: false` 付きの slot 失敗として報告する。 - **MCP schema のロックダウン** — すべての tool `inputSchema` は `additionalProperties: false` を含み、`tools/call` も同じ契約として未知の引数名を黙って既定値にせず `-32602` / `invalid_argument` で拒否する。 - **MCP stability marker と命名** — すべての tool は `x-stability`(`stable`、`experimental`、`deprecated`)を公開する。MCP の構造化 payload key は CLI JSON 契約に合わせて snake_case を使う。新規 field に camelCase alias を追加しないこと。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1e383fc441..aa82c3664a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1364,6 +1364,8 @@ MCP response-size limits are bounded so environment overrides cannot disable the 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`. + 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. Over-quota tool calls receive a structured JSON-RPC `-32000` error: @@ -3485,6 +3487,8 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が 最大値を超える値は警告付きでクランプされます。`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` に返します。 + 呼び出し元 ID は MCP `initialize` リクエストの `clientInfo.name`(および `version` があれば併記)から取得します。`initialize` 前に届いたツール呼び出しは匿名 `"unknown"` バケットで計量され、未識別クライアントによる制限回避を防ぎます。取得済みの caller はセッション中 sticky で、名前付き ID が一度記録されると以降の別名 `initialize` は無視され(`stderr` に 1 行警告)、長期 stdio / 通信セッションが途中で再 initialize してバケットをリセットする経路を塞ぎます。 超過したツール呼び出しには構造化された JSON-RPC `-32000` エラーを返します: diff --git a/changelog.d/unreleased/2895.security.md b/changelog.d/unreleased/2895.security.md new file mode 100644 index 0000000000..a1afd29d63 --- /dev/null +++ b/changelog.d/unreleased/2895.security.md @@ -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` から上限を確認できるようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index cd7b8d71f9..63c073c2ae 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -160,6 +160,7 @@ public partial class McpServer : IDisposable 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; 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"; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index f4a3e1eb14..d9e3731269 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -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); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index b116e43a65..907e44c574 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -291,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() ?? 0); + => Math.Clamp(args?["offset"]?.GetValue() ?? 0, 0, MaxMcpPaginationOffset); private static string ReadResponseFormat(JsonNode? args) => args?["format"]?.GetValue()?.Trim().ToLowerInvariant() ?? "full"; @@ -1974,6 +1974,7 @@ private JsonNode ExecuteStatus(JsonNode? id) ["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, } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 4d56db5fd4..870cf3c709 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5217,6 +5217,63 @@ public void ToolsCall_Status_ReportsResponseByteLimitCaps() Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["max_batch_response_bytes"]!.GetValue()); } + [Fact] + public void ToolsList_ReferencesOffsetSchemaAdvertisesCap() + { + var response = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!)!; + + var tools = response["result"]!["tools"]!.AsArray(); + var references = tools.First(tool => tool!["name"]!.GetValue() == "references")!; + var offset = references["inputSchema"]!["properties"]!["offset"]!; + Assert.Equal(0, offset["minimum"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpPaginationOffset, offset["maximum"]!.GetValue()); + } + + [Fact] + public void ToolsCall_References_ClampsTooLargeOffset() + { + InsertIndexedFile( + "src/offset-clamp.cs", + "csharp", + """ + public class OffsetClampCaller { public void Hit(App app) { app.Run(); } } + """); + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "references", + ["arguments"] = new JsonObject + { + ["query"] = "Run", + ["lang"] = "csharp", + ["offset"] = McpServer.MaxMcpPaginationOffset + 1, + ["limit"] = 1, + }, + }, + }; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(McpServer.MaxMcpPaginationOffset, structured["offset"]!.GetValue()); + Assert.True(structured["total"]!.GetValue() > 0); + } + + [Fact] + public void ToolsCall_Status_ReportsPaginationOffsetCap() + { + var response = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status"}}""")!)!; + + var limits = response["result"]!["structuredContent"]!["mcp"]!["limits"]!; + Assert.Equal(McpServer.MaxMcpPaginationOffset, limits["max_pagination_offset"]!.GetValue()); + } + [Fact] public async Task ProcessFrameAsync_BatchResponseOverByteLimit_ReturnsStructuredError() { From 3ccf4e044fd774561ba7b3d25546cbefd128f4d3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:45:06 +0900 Subject: [PATCH 3/4] Bound MCP keep-alive interval overrides --- DEVELOPER_GUIDE.md | 4 ++-- USER_GUIDE.md | 4 ++-- changelog.d/unreleased/2899.security.md | 19 +++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 10 +++++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 ++ .../CodeIndex.Tests/HttpMcpTransportTests.cs | 2 +- tests/CodeIndex.Tests/McpServerTests.cs | 24 +++++++++++++++++++ 7 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/2899.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f3ca95b717..f735e3a316 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -484,7 +484,7 @@ The MCP JSON-RPC `ping` method returns a structured health object with `status`, ### MCP keep-alive notifications -HTTP MCP `/events` streams can emit opt-in server-initiated `notifications/keep_alive` JSON-RPC notifications. Set `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` to a positive number of seconds to enable them; unset or non-positive values keep the default off behavior. Stdio sessions do not emit keep-alive notifications by default because the parent process owns liveness for that transport. +HTTP MCP `/events` streams can emit opt-in server-initiated `notifications/keep_alive` JSON-RPC notifications. Set `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` to a finite value from `1` to `300` seconds to enable them; unset, non-finite, or out-of-range values keep the default off behavior and emit a warning. Stdio sessions do not emit keep-alive notifications by default because the parent process owns liveness for that transport. Each keep-alive notification includes `server_time` and `uptime_s` under `params`. The notification is best-effort: disconnected SSE clients are removed from the stream registry, and keep-alive write failures must not terminate the MCP server. @@ -502,7 +502,7 @@ MCP JSON-RPC の `ping` method は、`status`、`uptime_s`、`last_request_at` ### MCP keep-alive notification -HTTP MCP の `/events` stream は、opt-in の server-initiated `notifications/keep_alive` JSON-RPC notification を送信できます。`CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` に正の秒数を設定すると有効になり、未設定または非正値では既定どおり無効です。stdio session は親プロセスが liveness を管理する transport なので、既定では keep-alive notification を出しません。 +HTTP MCP の `/events` stream は、opt-in の server-initiated `notifications/keep_alive` JSON-RPC notification を送信できます。`CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` に有限な `1`〜`300` 秒を設定すると有効になり、未設定・非有限値・範囲外では警告を出して既定どおり無効です。stdio session は親プロセスが liveness を管理する transport なので、既定では keep-alive notification を出しません。 各 keep-alive notification は `params` に `server_time` と `uptime_s` を含めます。この notification は best-effort であり、切断された SSE client は stream registry から除外され、keep-alive 書き込み失敗で MCP server 自体を終了させてはいけません。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index aa82c3664a..db426fc1da 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1991,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: @@ -4095,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 を記録します。リクエスト/レスポンス本文は記録しません。 セキュリティ既定: diff --git a/changelog.d/unreleased/2899.security.md b/changelog.d/unreleased/2899.security.md new file mode 100644 index 0000000000..5f852fda53 --- /dev/null +++ b/changelog.d/unreleased/2899.security.md @@ -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 を無効のままにします。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 63c073c2ae..fdadbee598 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -161,6 +161,8 @@ public partial class McpServer : IDisposable 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"; @@ -1338,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); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 907e44c574..cf58db0e22 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1977,6 +1977,8 @@ private JsonNode ExecuteStatus(JsonNode? id) ["max_pagination_offset"] = MaxMcpPaginationOffset, ["max_json_depth"] = MaxJsonDepth, ["max_batch_requests"] = MaxBatchRequestCount, + ["keep_alive_min_interval_s"] = MinKeepAliveIntervalSeconds, + ["keep_alive_max_interval_s"] = MaxKeepAliveIntervalSeconds, } }; return CreateToolResult(id, "Database stats returned.", structured); diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 97a36bd92f..7499bc08f3 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -269,7 +269,7 @@ public async Task HttpTransport_EventsStream_DoesNotBlockPostRequests() public async Task HttpTransport_EventsStream_EmitsOptInKeepAliveNotifications() { using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"); - env.Set("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", "0.05"); + env.Set("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", "1"); await using var harness = await McpHttpHarness.StartAsync(_dbPath); using var client = new HttpClient(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 870cf3c709..8f4e94a756 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5274,6 +5274,30 @@ public void ToolsCall_Status_ReportsPaginationOffsetCap() Assert.Equal(McpServer.MaxMcpPaginationOffset, limits["max_pagination_offset"]!.GetValue()); } + [Fact] + public void Constructor_InvalidKeepAliveEnvironment_DoesNotThrow() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"); + env.Set("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", "Infinity"); + + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: true); + var response = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"ping"}""")!)!; + + Assert.Equal("ok", response["result"]!["status"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Status_ReportsKeepAliveIntervalBounds() + { + var response = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status"}}""")!)!; + + var limits = response["result"]!["structuredContent"]!["mcp"]!["limits"]!; + Assert.Equal(McpServer.MinKeepAliveIntervalSeconds, limits["keep_alive_min_interval_s"]!.GetValue()); + Assert.Equal(McpServer.MaxKeepAliveIntervalSeconds, limits["keep_alive_max_interval_s"]!.GetValue()); + } + [Fact] public async Task ProcessFrameAsync_BatchResponseOverByteLimit_ReturnsStructuredError() { From e03674fe4b7138650e4f2b90c487206b5ba80580 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:45:33 +0900 Subject: [PATCH 4/4] Cap MCP rate limiter overrides --- USER_GUIDE.md | 12 ++++---- changelog.d/unreleased/2900.security.md | 19 ++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 10 ++++++- src/CodeIndex/Mcp/RateLimiter.cs | 12 ++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 24 ++++++++++++++++ tests/CodeIndex.Tests/RateLimiterTests.cs | 35 +++++++++++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/2900.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index db426fc1da..4e53626ff7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1352,8 +1352,8 @@ 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: @@ -1364,7 +1364,7 @@ MCP response-size limits are bounded so environment overrides cannot disable the 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`. +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. @@ -3475,8 +3475,8 @@ 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 が実質無効化されないよう上限付きです: @@ -3487,7 +3487,7 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が 最大値を超える値は警告付きでクランプされます。`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` に返します。 +ページング型の 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 してバケットをリセットする経路を塞ぎます。 diff --git a/changelog.d/unreleased/2900.security.md b/changelog.d/unreleased/2900.security.md new file mode 100644 index 0000000000..954cdd6596 --- /dev/null +++ b/changelog.d/unreleased/2900.security.md @@ -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` で確認できます。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index cf58db0e22..c692d3610c 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1979,7 +1979,15 @@ private JsonNode ExecuteStatus(JsonNode? id) ["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); }); diff --git a/src/CodeIndex/Mcp/RateLimiter.cs b/src/CodeIndex/Mcp/RateLimiter.cs index b1c9879a32..c650b65404 100644 --- a/src/CodeIndex/Mcp/RateLimiter.cs +++ b/src/CodeIndex/Mcp/RateLimiter.cs @@ -127,6 +127,8 @@ internal sealed class RateLimiterOptions { internal const string RpsEnvVar = "CDIDX_MCP_RATE_LIMIT_RPS"; internal const string BurstEnvVar = "CDIDX_MCP_RATE_LIMIT_BURST"; + internal const double MaxRefillTokensPerSecond = 100.0; + internal const double MaxBurstCapacity = 1000.0; public double RefillTokensPerSecond { get; init; } public double BurstCapacity { get; init; } @@ -148,6 +150,11 @@ public static RateLimiterOptions FromEnvironment(Func? envReade warningSink($"[cdidx-mcp] Ignoring invalid {RpsEnvVar}='{rpsRaw}'. Expected a positive number (tokens per second). Rate limiting stays disabled."); return Disabled; } + if (rps > MaxRefillTokensPerSecond) + { + warningSink($"[cdidx-mcp] Clamping {RpsEnvVar}='{rpsRaw}' to maximum {MaxRefillTokensPerSecond.ToString(CultureInfo.InvariantCulture)} tokens per second."); + rps = MaxRefillTokensPerSecond; + } var burstRaw = envReader(BurstEnvVar); double burst; @@ -164,6 +171,11 @@ public static RateLimiterOptions FromEnvironment(Func? envReade warningSink($"[cdidx-mcp] Ignoring invalid {BurstEnvVar}='{burstRaw}'. Expected a positive number (bucket capacity). Falling back to default burst."); burst = Math.Max(rps, 1.0); } + else if (burst > MaxBurstCapacity) + { + warningSink($"[cdidx-mcp] Clamping {BurstEnvVar}='{burstRaw}' to maximum {MaxBurstCapacity.ToString(CultureInfo.InvariantCulture)} tokens."); + burst = MaxBurstCapacity; + } return new RateLimiterOptions { RefillTokensPerSecond = rps, BurstCapacity = burst }; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 8f4e94a756..86657aa368 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5298,6 +5298,30 @@ public void ToolsCall_Status_ReportsKeepAliveIntervalBounds() Assert.Equal(McpServer.MaxKeepAliveIntervalSeconds, limits["keep_alive_max_interval_s"]!.GetValue()); } + [Fact] + public void ToolsCall_Status_ReportsEffectiveRateLimitCaps() + { + using var env = EnvironmentVariableScope.Capture( + "CDIDX_MCP_RATE_LIMIT_RPS", + "CDIDX_MCP_RATE_LIMIT_BURST"); + env.Set("CDIDX_MCP_RATE_LIMIT_RPS", "1000000"); + env.Set("CDIDX_MCP_RATE_LIMIT_BURST", "1000000"); + + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: true); + var response = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status"}}""")!)!; + + var mcp = response["result"]!["structuredContent"]!["mcp"]!; + var limits = mcp["limits"]!; + Assert.Equal(RateLimiterOptions.MaxRefillTokensPerSecond, limits["rate_limit_max_rps"]!.GetValue()); + Assert.Equal(RateLimiterOptions.MaxBurstCapacity, limits["rate_limit_max_burst"]!.GetValue()); + + var rateLimit = mcp["rate_limit"]!; + Assert.True(rateLimit["enabled"]!.GetValue()); + Assert.Equal(RateLimiterOptions.MaxRefillTokensPerSecond, rateLimit["rps"]!.GetValue()); + Assert.Equal(RateLimiterOptions.MaxBurstCapacity, rateLimit["burst"]!.GetValue()); + } + [Fact] public async Task ProcessFrameAsync_BatchResponseOverByteLimit_ReturnsStructuredError() { diff --git a/tests/CodeIndex.Tests/RateLimiterTests.cs b/tests/CodeIndex.Tests/RateLimiterTests.cs index d63428af6f..34b314cd9d 100644 --- a/tests/CodeIndex.Tests/RateLimiterTests.cs +++ b/tests/CodeIndex.Tests/RateLimiterTests.cs @@ -201,6 +201,41 @@ public void FromEnvironment_ExplicitBurst_IsHonored() Assert.Equal(20.0, opts.BurstCapacity); } + [Fact] + public void FromEnvironment_TooLargeRps_ClampsAndWarns() + { + var warnings = new List(); + var opts = RateLimiterOptions.FromEnvironment( + key => key == RateLimiterOptions.RpsEnvVar ? "1000000" : null, + warnings.Add); + + Assert.True(opts.IsEnabled); + Assert.Equal(RateLimiterOptions.MaxRefillTokensPerSecond, opts.RefillTokensPerSecond); + Assert.Equal(RateLimiterOptions.MaxRefillTokensPerSecond, opts.BurstCapacity); + Assert.Single(warnings); + Assert.Contains("Clamping CDIDX_MCP_RATE_LIMIT_RPS", warnings[0]); + } + + [Fact] + public void FromEnvironment_TooLargeBurst_ClampsAndWarns() + { + var warnings = new List(); + var opts = RateLimiterOptions.FromEnvironment( + key => key switch + { + RateLimiterOptions.RpsEnvVar => "3", + RateLimiterOptions.BurstEnvVar => "1000000", + _ => null, + }, + warnings.Add); + + Assert.True(opts.IsEnabled); + Assert.Equal(3.0, opts.RefillTokensPerSecond); + Assert.Equal(RateLimiterOptions.MaxBurstCapacity, opts.BurstCapacity); + Assert.Single(warnings); + Assert.Contains("Clamping CDIDX_MCP_RATE_LIMIT_BURST", warnings[0]); + } + [Fact] public void FromEnvironment_InvalidRps_WarnsAndDisables() {