From 90c59098c4b3714390069d79ffd24ae57a03569f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 20:14:59 +0900 Subject: [PATCH 1/4] Cap MCP session roots for status diagnostics (#3076) --- AGENT_GUIDE.md | 2 +- DEVELOPER_GUIDE.md | 4 +-- README.md | 4 +-- changelog.d/unreleased/3076.fixed.md | 20 ++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 24 +++++++++++++-- src/CodeIndex/Mcp/McpToolHandlers.cs | 7 +++++ tests/CodeIndex.Tests/McpServerTests.cs | 41 +++++++++++++++++++++++++ 7 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/3076.fixed.md diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 5437a37838..6f7a4422d7 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -144,7 +144,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. It is omitted on non-Linux hosts, unconstrained processes, or unreadable proc attributes (#1768). - `status` also surfaces DB/WAL size, per-language symbol-kind histograms, current process heap/GC/working-set metrics, and the last successful index run metadata. `process` is captured at status-call time; `last_index_run` is persisted at the end of successful CLI and MCP index runs and can include a peak-memory summary when CLI `--memory-trace` was used. `last_workspace_freshened_at` is the latest successful index/update timestamp and can be newer than `indexed_at` when a partial or no-op update confirms freshness without rewriting indexed file rows. -- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, captured `roots`, optional `client_info`, and optional `client_capabilities`. +- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. - Keep `README.md`, `DEVELOPER_GUIDE.md`, and this file synchronized if this contract changes. ## Reference Extraction diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 5f528990c2..0a1b20899b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -963,7 +963,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | Version and forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`. | | Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `extractors`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `stale_after_seconds`, `index_age_seconds`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`. | -| MCP-only session diagnostics | `mcp_session`, which is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. | +| MCP-only session diagnostics | `mcp_session`, which is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. | | Documentation sync | Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. | Runtime diagnostic subcontracts: @@ -2974,7 +2974,7 @@ AI エージェント向け検索ルールのテンプレートについては | version / forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`。 | | unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `extractors`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `stale_after_seconds`, `index_age_seconds`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`。 | -| MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。 | +| MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。 | | documentation sync | この一覧は `README.md` と `AGENT_GUIDE.md` と同期してください。必須 field がそれらの docs から欠けると `DocumentationStatusContractTests` が失敗します。 | runtime diagnostic subcontract: diff --git a/README.md b/README.md index 47ee3b6cb7..254bb8ec9d 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ After a current full-repository scan, `unknown_extension_file_count` reports how `hooks[]` includes metadata-only hook candidates and `callback_budget_ms`; `status` does not load hook assemblies. `CDIDX_HOOK_CALLBACK_BUDGET_MS` bounds each post-extraction hook callback in milliseconds (default: 5000); callbacks that exceed the budget emit sanitized index warnings, drop timed-out mutations, and disable that hook for the current index run. -For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. +For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. Root diagnostics are bounded; when advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. `process` is captured at status-call time and includes heap, GC collection, and working-set counters. `last_index_run` is persisted by successful CLI and MCP index runs with the run mode, duration, file counts, byte count, row-change counts, and optional peak-memory summary from CLI `--memory-trace`. `last_workspace_freshened_at` is the latest successful index/update run timestamp and can be newer than `indexed_at` when a partial or no-op update confirms freshness without rewriting indexed file rows. @@ -531,7 +531,7 @@ readiness field のいずれかが degraded の場合、`degraded_root_cause` `hooks[]` は metadata-only の hook candidate と `callback_budget_ms` を含み、`status` は hook assembly を読み込みません。`CDIDX_HOOK_CALLBACK_BUDGET_MS` は post-extraction hook callback ごとの上限ミリ秒を指定します(既定値: 5000)。上限を超えた callback は sanitized index warning を出し、timeout した変更を捨て、その index run 中は該当 hook を無効化します。 -MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。 +MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。root 診断は上限付きで、advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。 `process` は status 呼び出し時点の heap、GC collection、working-set counters です。`last_index_run` は成功した CLI / MCP index 実行が永続化し、run mode、duration、file counts、byte count、row-change counts、CLI `--memory-trace` 由来の任意の peak-memory summary を含みます。 diff --git a/changelog.d/unreleased/3076.fixed.md b/changelog.d/unreleased/3076.fixed.md new file mode 100644 index 0000000000..a5c0629b7c --- /dev/null +++ b/changelog.d/unreleased/3076.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3076 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - README.md + - DEVELOPER_GUIDE.md + - AGENT_GUIDE.md +--- + +## English + +- **MCP session status now caps retained client roots (#3076)** — `initialize` root diagnostics keep a bounded root list, cap individual root URI text, and expose `roots_truncated` metadata in MCP `status` when advertised roots are shortened. + +## 日本語 + +- **MCP session status が保持する client root を上限付きにしました (#3076)** — `initialize` の root 診断は root 一覧と個別 root URI の長さを上限付きで保持し、advertised root が短縮された場合は MCP `status` に `roots_truncated` metadata を出すようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index fd51f8ad28..baaee2d845 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -115,6 +115,8 @@ public partial class McpServer : IDisposable private BoundedMcpText? _clientVersionDisplay; private JsonNode? _clientCapabilities; private JsonArray _clientRoots = []; + private int _clientRootCount; + private bool _clientRootsTruncated; private string _mcpLogLevel = "info"; // Opaque per-server-instance session id copied into suggestion attribution records (#1873). // #1873 の提案 attribution 用に保存する、サーバーインスタンス単位の不透明セッションID。 @@ -176,6 +178,8 @@ public partial class McpServer : IDisposable internal const int MaxBatchRequestCount = 100; internal const int MaxRequestIdCharacterCount = 128; internal const int MaxRequestIdByteLength = 256; + internal const int MaxClientRootCount = 16; + internal const int MaxClientRootUriChars = 512; // Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query) // in a single read so the StreamReader does not grow from its 1 KB default toward MaxLineCharacterCount. // JSON-RPCループのstdioバッファ。大きめのMCPペイロードを1回の読み取りで吸収し、 @@ -1828,6 +1832,8 @@ private void CaptureClientSession(JsonNode? initializeParams) { _clientCapabilities = null; _clientRoots = []; + _clientRootCount = 0; + _clientRootsTruncated = false; if (initializeParams is not JsonObject obj) return; @@ -1837,7 +1843,7 @@ private void CaptureClientSession(JsonNode? initializeParams) _clientCapabilities = JsonNode.Parse(capabilities.ToJsonString()); if (TryReadStringValue(obj["rootUri"]) is { Length: > 0 } rootUri) - _clientRoots.Add(rootUri); + CaptureClientRoot(rootUri); if (obj["roots"] is JsonArray roots) { @@ -1845,11 +1851,25 @@ private void CaptureClientSession(JsonNode? initializeParams) { var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root); if (!string.IsNullOrWhiteSpace(uri)) - _clientRoots.Add(uri); + CaptureClientRoot(uri); } } } + private void CaptureClientRoot(string uri) + { + _clientRootCount++; + if (_clientRoots.Count >= MaxClientRootCount) + { + _clientRootsTruncated = true; + return; + } + + var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); + _clientRoots.Add(display.Text); + _clientRootsTruncated |= display.Truncated; + } + internal JsonNode? ClientCapabilitiesForTests => _clientCapabilities is null ? null : JsonNode.Parse(_clientCapabilities.ToJsonString()); internal string[] ClientRootsForTests => _clientRoots diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 8750eb0da1..c38f18d353 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2296,6 +2296,13 @@ private JsonObject BuildMcpSessionStatus() ["log_level"] = _mcpLogLevel, ["roots"] = roots, }; + if (_clientRootsTruncated) + { + session["roots_truncated"] = true; + session["root_count"] = _clientRootCount; + session["root_limit"] = MaxClientRootCount; + session["root_uri_length_limit"] = MaxClientRootUriChars; + } if (_clientName is not null || _clientVersion is not null) { var clientInfo = new JsonObject(); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 6ba8999fdd..502ea90fbf 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -835,6 +835,47 @@ public void Initialize_CapturesClientCapabilitiesAndRootsForSessionStatus() Assert.Equal("info", session["log_level"]!.GetValue()); } + [Fact] + public void Initialize_CapsClientRootsForSessionStatus_Issue3076() + { + var longRoot = "file:///" + new string('r', McpServer.MaxClientRootUriChars + 50); + var roots = new JsonArray(); + for (var i = 0; i < McpServer.MaxClientRootCount + 3; i++) + { + roots.Add(new JsonObject + { + ["uri"] = i == 0 ? longRoot : $"file:///workspace/{i}", + }); + } + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "initialize", + ["params"] = new JsonObject + { + ["rootUri"] = "file:///workspace", + ["roots"] = roots, + }, + }; + _server.HandleMessage(request); + + Assert.Equal(McpServer.MaxClientRootCount, _server.ClientRootsForTests.Length); + Assert.DoesNotContain(longRoot, _server.ClientRootsForTests); + + var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; + var response = _server.HandleMessage(status)!; + var session = response["result"]!["structuredContent"]!["mcp_session"]!; + + Assert.True(session["roots_truncated"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootCount + 4, session["root_count"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootCount, session["root_limit"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootUriChars, session["root_uri_length_limit"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootCount, session["roots"]!.AsArray().Count); + Assert.DoesNotContain(longRoot, response.ToJsonString(), StringComparison.Ordinal); + } + [Fact] public void Initialize_ClientInfo_TruncatesSessionStatusAndCallerIdentity_Issue3120() { From 86bb938a63c3ddd339625233144b439effc21f7b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 20:20:42 +0900 Subject: [PATCH 2/4] Cap MCP client capabilities for status diagnostics (#3225) --- AGENT_GUIDE.md | 2 +- DEVELOPER_GUIDE.md | 4 +- README.md | 4 +- changelog.d/unreleased/3225.fixed.md | 20 +++++++ src/CodeIndex/Mcp/McpServer.cs | 35 +++++++++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 9 +++ tests/CodeIndex.Tests/McpServerTests.cs | 75 +++++++++++++++++++++++++ 7 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3225.fixed.md diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 6f7a4422d7..a3ec255799 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -144,7 +144,7 @@ CI watching must be bounded. Do not loop indefinitely. - `status` also surfaces filesystem case-sensitivity via `path_case_sensitive`, stamped on every successful `cdidx index` run (full scan AND partial update, plus MCP-driven indexes) from `core.ignorecase` + a live filesystem probe. `true` means the volume is case-sensitive (`Foo.cs` and `foo.cs` are distinct); `false` means case-insensitive. Omitted on legacy DBs that predate the stamp. Use it to audit path-equality decisions on case-sensitive APFS, WSL NTFS / dev-drive, and ReFS mounts where the prior OS-keyed heuristic could mis-classify the workspace (#1546). - `status` also surfaces Linux mandatory-access-control context via `mac_profile` when `/proc/self/attr/current` or `/proc/self/attr/exec` indicates an AppArmor or SELinux profile. It is omitted on non-Linux hosts, unconstrained processes, or unreadable proc attributes (#1768). - `status` also surfaces DB/WAL size, per-language symbol-kind histograms, current process heap/GC/working-set metrics, and the last successful index run metadata. `process` is captured at status-call time; `last_index_run` is persisted at the end of successful CLI and MCP index runs and can include a peak-memory summary when CLI `--memory-trace` was used. `last_workspace_freshened_at` is the latest successful index/update timestamp and can be newer than `indexed_at` when a partial or no-op update confirms freshness without rewriting indexed file rows. -- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. +- MCP `status` also surfaces session diagnostics via `mcp_session`. It is not persisted DB state; it includes the current `log_level`, bounded captured `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. - Keep `README.md`, `DEVELOPER_GUIDE.md`, and this file synchronized if this contract changes. ## Reference Extraction diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 0a1b20899b..a69e69b618 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -963,7 +963,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | Version and forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`. | | Unknown-extension and runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `extractors`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `stale_after_seconds`, `index_age_seconds`. | | Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`. | -| MCP-only session diagnostics | `mcp_session`, which is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. | +| MCP-only session diagnostics | `mcp_session`, which is session-scoped diagnostics rather than persisted DB state. It contains `log_level`, bounded `roots`, optional `client_info`, and bounded optional `client_capabilities`. When advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. When client capabilities are capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. | | Documentation sync | Keep this list synchronized with `README.md` and `AGENT_GUIDE.md`; `DocumentationStatusContractTests` fails when any required field is missing from one of those docs. | Runtime diagnostic subcontracts: @@ -2974,7 +2974,7 @@ AI エージェント向け検索ルールのテンプレートについては | version / forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`。 | | unknown-extension / runtime diagnostics | `unknown_extension_file_count`, `unknown_extension_files`, `unknown_extension_files_truncated`, `unknown_extension_file_path_limit`, `extractors`, `path_case_sensitive`, `data_dir_mode`, `mac_profile`, `stale_after_seconds`, `index_age_seconds`。 | | remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`。 | -| MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。 | +| MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level`、上限付きの `roots`、任意の `client_info`、上限付きの任意の `client_capabilities` を含みます。advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities が切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。 | | documentation sync | この一覧は `README.md` と `AGENT_GUIDE.md` と同期してください。必須 field がそれらの docs から欠けると `DocumentationStatusContractTests` が失敗します。 | runtime diagnostic subcontract: diff --git a/README.md b/README.md index 254bb8ec9d..49cfbcb435 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ After a current full-repository scan, `unknown_extension_file_count` reports how `hooks[]` includes metadata-only hook candidates and `callback_budget_ms`; `status` does not load hook assemblies. `CDIDX_HOOK_CALLBACK_BUDGET_MS` bounds each post-extraction hook callback in milliseconds (default: 5000); callbacks that exceed the budget emit sanitized index warnings, drop timed-out mutations, and disable that hook for the current index run. -For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. Root diagnostics are bounded; when advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. +For MCP `status`, `mcp_session` is session-scoped diagnostic data rather than persisted index state. It includes `log_level`, `roots`, optional `client_info`, and optional `client_capabilities`. Root diagnostics are bounded; when advertised roots are capped, `roots_truncated`, `root_count`, `root_limit`, and `root_uri_length_limit` describe the truncation. Client capabilities are capped by serialized size and depth; when capped, `client_capabilities_truncated`, `client_capabilities_truncation_reason`, `client_capabilities_serialized_bytes`, `client_capabilities_byte_limit`, and `client_capabilities_depth_limit` describe the retained diagnostic subset. `process` is captured at status-call time and includes heap, GC collection, and working-set counters. `last_index_run` is persisted by successful CLI and MCP index runs with the run mode, duration, file counts, byte count, row-change counts, and optional peak-memory summary from CLI `--memory-trace`. `last_workspace_freshened_at` is the latest successful index/update run timestamp and can be newer than `indexed_at` when a partial or no-op update confirms freshness without rewriting indexed file rows. @@ -531,7 +531,7 @@ readiness field のいずれかが degraded の場合、`degraded_root_cause` `hooks[]` は metadata-only の hook candidate と `callback_budget_ms` を含み、`status` は hook assembly を読み込みません。`CDIDX_HOOK_CALLBACK_BUDGET_MS` は post-extraction hook callback ごとの上限ミリ秒を指定します(既定値: 5000)。上限を超えた callback は sanitized index warning を出し、timeout した変更を捨て、その index run 中は該当 hook を無効化します。 -MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。root 診断は上限付きで、advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。 +MCP `status` の `mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level`、`roots`、任意の `client_info`、任意の `client_capabilities` を含みます。root 診断は上限付きで、advertised root が切り詰められた場合は `roots_truncated`、`root_count`、`root_limit`、`root_uri_length_limit` が切り詰め内容を示します。client capabilities は serialized size と depth で上限管理され、切り詰められた場合は `client_capabilities_truncated`、`client_capabilities_truncation_reason`、`client_capabilities_serialized_bytes`、`client_capabilities_byte_limit`、`client_capabilities_depth_limit` が保持された診断 subset を示します。 `process` は status 呼び出し時点の heap、GC collection、working-set counters です。`last_index_run` は成功した CLI / MCP index 実行が永続化し、run mode、duration、file counts、byte count、row-change counts、CLI `--memory-trace` 由来の任意の peak-memory summary を含みます。 diff --git a/changelog.d/unreleased/3225.fixed.md b/changelog.d/unreleased/3225.fixed.md new file mode 100644 index 0000000000..58299be9db --- /dev/null +++ b/changelog.d/unreleased/3225.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3225 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - README.md + - DEVELOPER_GUIDE.md + - AGENT_GUIDE.md +--- + +## English + +- **MCP session status now caps retained client capabilities (#3225)** — `initialize` capabilities diagnostics are bounded by serialized byte size and JSON depth, and MCP `status` now reports explicit `client_capabilities_truncated` metadata when capabilities are shortened. + +## 日本語 + +- **MCP session status が保持する client capabilities を上限付きにしました (#3225)** — `initialize` の capabilities 診断は serialized byte size と JSON depth で上限管理され、capabilities が短縮された場合は MCP `status` に明示的な `client_capabilities_truncated` metadata を出すようになりました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index baaee2d845..49073a7c3b 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -114,6 +114,8 @@ public partial class McpServer : IDisposable private BoundedMcpText? _clientNameDisplay; private BoundedMcpText? _clientVersionDisplay; private JsonNode? _clientCapabilities; + private int? _clientCapabilitiesSerializedBytes; + private string? _clientCapabilitiesTruncationReason; private JsonArray _clientRoots = []; private int _clientRootCount; private bool _clientRootsTruncated; @@ -180,6 +182,8 @@ public partial class McpServer : IDisposable internal const int MaxRequestIdByteLength = 256; internal const int MaxClientRootCount = 16; internal const int MaxClientRootUriChars = 512; + internal const int MaxClientCapabilitiesJsonBytes = 8 * 1024; + internal const int MaxClientCapabilitiesDepth = 8; // Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query) // in a single read so the StreamReader does not grow from its 1 KB default toward MaxLineCharacterCount. // JSON-RPCループのstdioバッファ。大きめのMCPペイロードを1回の読み取りで吸収し、 @@ -1831,6 +1835,8 @@ private void CaptureClientInfo(JsonNode? initializeParams) private void CaptureClientSession(JsonNode? initializeParams) { _clientCapabilities = null; + _clientCapabilitiesSerializedBytes = null; + _clientCapabilitiesTruncationReason = null; _clientRoots = []; _clientRootCount = 0; _clientRootsTruncated = false; @@ -1840,7 +1846,7 @@ private void CaptureClientSession(JsonNode? initializeParams) if (!obj.TryGetPropertyValue("capabilities", out var capabilities)) obj.TryGetPropertyValue("clientCapabilities", out capabilities); if (capabilities is not null) - _clientCapabilities = JsonNode.Parse(capabilities.ToJsonString()); + CaptureClientCapabilities(capabilities); if (TryReadStringValue(obj["rootUri"]) is { Length: > 0 } rootUri) CaptureClientRoot(rootUri); @@ -1856,6 +1862,33 @@ private void CaptureClientSession(JsonNode? initializeParams) } } + private void CaptureClientCapabilities(JsonNode capabilities) + { + var json = capabilities.ToJsonString(); + var serializedBytes = Encoding.UTF8.GetByteCount(json); + _clientCapabilitiesSerializedBytes = serializedBytes; + if (serializedBytes > MaxClientCapabilitiesJsonBytes) + { + TruncateClientCapabilities("byte_limit"); + return; + } + + try + { + _clientCapabilities = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = MaxClientCapabilitiesDepth }); + } + catch (JsonException) + { + TruncateClientCapabilities("depth_limit"); + } + } + + private void TruncateClientCapabilities(string reason) + { + _clientCapabilities = new JsonObject(); + _clientCapabilitiesTruncationReason = reason; + } + private void CaptureClientRoot(string uri) { _clientRootCount++; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index c38f18d353..dac5d0c3fe 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2320,6 +2320,15 @@ private JsonObject BuildMcpSessionStatus() } if (_clientCapabilities is not null) session["client_capabilities"] = _clientCapabilities.DeepClone(); + if (_clientCapabilitiesTruncationReason is not null) + { + session["client_capabilities_truncated"] = true; + session["client_capabilities_truncation_reason"] = _clientCapabilitiesTruncationReason; + if (_clientCapabilitiesSerializedBytes is { } serializedBytes) + session["client_capabilities_serialized_bytes"] = serializedBytes; + session["client_capabilities_byte_limit"] = MaxClientCapabilitiesJsonBytes; + session["client_capabilities_depth_limit"] = MaxClientCapabilitiesDepth; + } return session; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 502ea90fbf..4581ef60cf 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -876,6 +876,81 @@ public void Initialize_CapsClientRootsForSessionStatus_Issue3076() Assert.DoesNotContain(longRoot, response.ToJsonString(), StringComparison.Ordinal); } + [Fact] + public void Initialize_CapsClientCapabilitiesByteSizeForSessionStatus_Issue3225() + { + var largeValue = new string('c', McpServer.MaxClientCapabilitiesJsonBytes + 100); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "initialize", + ["params"] = new JsonObject + { + ["capabilities"] = new JsonObject + { + ["sampling"] = new JsonObject(), + ["experimental"] = new JsonObject + { + ["large"] = largeValue, + }, + }, + }, + }; + _server.HandleMessage(request); + + Assert.Empty(_server.ClientCapabilitiesForTests!.AsObject()); + + var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; + var response = _server.HandleMessage(status)!; + var session = response["result"]!["structuredContent"]!["mcp_session"]!; + + Assert.True(session["client_capabilities_truncated"]!.GetValue()); + Assert.Equal("byte_limit", session["client_capabilities_truncation_reason"]!.GetValue()); + Assert.True(session["client_capabilities_serialized_bytes"]!.GetValue() > McpServer.MaxClientCapabilitiesJsonBytes); + Assert.Equal(McpServer.MaxClientCapabilitiesJsonBytes, session["client_capabilities_byte_limit"]!.GetValue()); + Assert.Equal(McpServer.MaxClientCapabilitiesDepth, session["client_capabilities_depth_limit"]!.GetValue()); + Assert.Empty(session["client_capabilities"]!.AsObject()); + Assert.DoesNotContain(largeValue, response.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public void Initialize_CapsClientCapabilitiesDepthForSessionStatus_Issue3225() + { + var capabilities = new JsonObject(); + var current = capabilities; + for (var i = 0; i < McpServer.MaxClientCapabilitiesDepth + 4; i++) + { + var next = new JsonObject(); + current[$"level{i}"] = next; + current = next; + } + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "initialize", + ["params"] = new JsonObject + { + ["capabilities"] = capabilities, + }, + }; + _server.HandleMessage(request); + + Assert.Empty(_server.ClientCapabilitiesForTests!.AsObject()); + + var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; + var response = _server.HandleMessage(status)!; + var session = response["result"]!["structuredContent"]!["mcp_session"]!; + + Assert.True(session["client_capabilities_truncated"]!.GetValue()); + Assert.Equal("depth_limit", session["client_capabilities_truncation_reason"]!.GetValue()); + Assert.Equal(McpServer.MaxClientCapabilitiesJsonBytes, session["client_capabilities_byte_limit"]!.GetValue()); + Assert.Equal(McpServer.MaxClientCapabilitiesDepth, session["client_capabilities_depth_limit"]!.GetValue()); + Assert.Empty(session["client_capabilities"]!.AsObject()); + } + [Fact] public void Initialize_ClientInfo_TruncatesSessionStatusAndCallerIdentity_Issue3120() { From 5bcf63916c6d2cbc0bd4102169b6b708199ec27b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 20:44:05 +0900 Subject: [PATCH 3/4] Preserve MCP roots while capping status diagnostics (#3076) --- src/CodeIndex/Mcp/McpServer.cs | 18 ++++++++--- src/CodeIndex/Mcp/McpToolHandlers.cs | 7 ++--- tests/CodeIndex.Tests/McpServerTests.cs | 42 +++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 49073a7c3b..7f3cf05ef1 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -117,6 +117,7 @@ public partial class McpServer : IDisposable private int? _clientCapabilitiesSerializedBytes; private string? _clientCapabilitiesTruncationReason; private JsonArray _clientRoots = []; + private JsonArray _clientRootDiagnostics = []; private int _clientRootCount; private bool _clientRootsTruncated; private string _mcpLogLevel = "info"; @@ -1837,9 +1838,7 @@ private void CaptureClientSession(JsonNode? initializeParams) _clientCapabilities = null; _clientCapabilitiesSerializedBytes = null; _clientCapabilitiesTruncationReason = null; - _clientRoots = []; - _clientRootCount = 0; - _clientRootsTruncated = false; + ResetClientRoots(); if (initializeParams is not JsonObject obj) return; @@ -1891,18 +1890,27 @@ private void TruncateClientCapabilities(string reason) private void CaptureClientRoot(string uri) { + _clientRoots.Add(uri); _clientRootCount++; - if (_clientRoots.Count >= MaxClientRootCount) + if (_clientRootDiagnostics.Count >= MaxClientRootCount) { _clientRootsTruncated = true; return; } var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); - _clientRoots.Add(display.Text); + _clientRootDiagnostics.Add(display.Text); _clientRootsTruncated |= display.Truncated; } + private void ResetClientRoots() + { + _clientRoots = []; + _clientRootDiagnostics = []; + _clientRootCount = 0; + _clientRootsTruncated = false; + } + internal JsonNode? ClientCapabilitiesForTests => _clientCapabilities is null ? null : JsonNode.Parse(_clientCapabilities.ToJsonString()); internal string[] ClientRootsForTests => _clientRoots diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index dac5d0c3fe..c037bb30e9 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2288,7 +2288,7 @@ private JsonNode ExecuteStatus(JsonNode? id) private JsonObject BuildMcpSessionStatus() { var roots = new JsonArray(); - foreach (var root in _clientRoots) + foreach (var root in _clientRootDiagnostics) roots.Add(root?.DeepClone()); var session = new JsonObject @@ -3729,14 +3729,13 @@ private async Task RefreshClientRootsIfNeededAsync() if (result?["roots"] is not JsonArray roots) return; - var refreshed = new JsonArray(); + ResetClientRoots(); foreach (var root in roots) { var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root); if (!string.IsNullOrWhiteSpace(uri)) - refreshed.Add(uri); + CaptureClientRoot(uri); } - _clientRoots = refreshed; _clientRootsStale = false; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 4581ef60cf..ac6c386385 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -861,8 +861,8 @@ public void Initialize_CapsClientRootsForSessionStatus_Issue3076() }; _server.HandleMessage(request); - Assert.Equal(McpServer.MaxClientRootCount, _server.ClientRootsForTests.Length); - Assert.DoesNotContain(longRoot, _server.ClientRootsForTests); + Assert.Equal(McpServer.MaxClientRootCount + 4, _server.ClientRootsForTests.Length); + Assert.Contains(longRoot, _server.ClientRootsForTests); var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = _server.HandleMessage(status)!; @@ -876,6 +876,44 @@ public void Initialize_CapsClientRootsForSessionStatus_Issue3076() Assert.DoesNotContain(longRoot, response.ToJsonString(), StringComparison.Ordinal); } + [Fact] + public void RefreshClientRoots_CapsSessionStatusDiagnostics_Issue3076() + { + var longRoot = "file:///" + new string('r', McpServer.MaxClientRootUriChars + 50); + var advertisedRoots = new JsonArray(); + for (var i = 0; i < McpServer.MaxClientRootCount + 3; i++) + { + advertisedRoots.Add(new JsonObject + { + ["uri"] = i == 0 ? longRoot : $"file:///tmp/cdidx-not-this-workspace/{i}", + }); + } + + _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"capabilities":{"roots":{}}}}""")!); + _server.ClientRequestHandlerForTests = (method, _) => + { + Assert.Equal("roots/list", method); + return new JsonObject { ["roots"] = advertisedRoots.DeepClone() }; + }; + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"index","arguments":{"path":"."}}}""")!; + var indexResponse = _server.HandleMessage(request)!; + + Assert.True(indexResponse["result"]!["isError"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootCount + 3, _server.ClientRootsForTests.Length); + Assert.Contains(longRoot, _server.ClientRootsForTests); + + var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; + var response = _server.HandleMessage(status)!; + var session = response["result"]!["structuredContent"]!["mcp_session"]!; + + Assert.True(session["roots_truncated"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootCount + 3, session["root_count"]!.GetValue()); + Assert.Equal(McpServer.MaxClientRootCount, session["roots"]!.AsArray().Count); + Assert.DoesNotContain(longRoot, response.ToJsonString(), StringComparison.Ordinal); + } + [Fact] public void Initialize_CapsClientCapabilitiesByteSizeForSessionStatus_Issue3225() { From 4227fc7bf8fa97874d53761b037a0bd44bf94401 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 20:44:23 +0900 Subject: [PATCH 4/4] Preserve MCP capability flags after diagnostic caps (#3225) --- src/CodeIndex/Mcp/McpServer.cs | 18 ++++++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 11 ++++++++--- tests/CodeIndex.Tests/McpServerTests.cs | 7 +++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 7f3cf05ef1..3c08bf61e3 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -116,6 +116,8 @@ public partial class McpServer : IDisposable private JsonNode? _clientCapabilities; private int? _clientCapabilitiesSerializedBytes; private string? _clientCapabilitiesTruncationReason; + private bool _clientSupportsRoots; + private bool _clientSupportsSampling; private JsonArray _clientRoots = []; private JsonArray _clientRootDiagnostics = []; private int _clientRootCount; @@ -1838,6 +1840,8 @@ private void CaptureClientSession(JsonNode? initializeParams) _clientCapabilities = null; _clientCapabilitiesSerializedBytes = null; _clientCapabilitiesTruncationReason = null; + _clientSupportsRoots = false; + _clientSupportsSampling = false; ResetClientRoots(); if (initializeParams is not JsonObject obj) return; @@ -1863,6 +1867,7 @@ private void CaptureClientSession(JsonNode? initializeParams) private void CaptureClientCapabilities(JsonNode capabilities) { + CaptureClientCapabilityFlags(capabilities); var json = capabilities.ToJsonString(); var serializedBytes = Encoding.UTF8.GetByteCount(json); _clientCapabilitiesSerializedBytes = serializedBytes; @@ -1888,6 +1893,15 @@ private void TruncateClientCapabilities(string reason) _clientCapabilitiesTruncationReason = reason; } + private void CaptureClientCapabilityFlags(JsonNode capabilities) + { + if (capabilities is not JsonObject obj) + return; + + _clientSupportsRoots = obj.TryGetPropertyValue("roots", out var roots) && roots is not null; + _clientSupportsSampling = obj.TryGetPropertyValue("sampling", out var sampling) && sampling is not null; + } + private void CaptureClientRoot(string uri) { _clientRoots.Add(uri); @@ -1919,6 +1933,10 @@ private void ResetClientRoots() .Cast() .ToArray(); + internal bool ClientSupportsRootsForTests => _clientSupportsRoots; + + internal bool ClientSupportsSamplingForTests => _clientSupportsSampling; + internal string McpLogLevelForTests => _mcpLogLevel; internal Func? ClientRequestHandlerForTests { get; set; } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index c037bb30e9..17a5bc30b3 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -4982,9 +4982,14 @@ private static string TruncateUtf8(string value, int maxBytes) } private bool HasClientCapability(string name) - => _clientCapabilities is JsonObject obj - && obj.TryGetPropertyValue(name, out var node) - && node is not null; + => name switch + { + "roots" => _clientSupportsRoots, + "sampling" => _clientSupportsSampling, + _ => _clientCapabilities is JsonObject obj + && obj.TryGetPropertyValue(name, out var node) + && node is not null, + }; private static bool IsSamplingEnabled() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index ac6c386385..31823c367d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -927,6 +927,7 @@ public void Initialize_CapsClientCapabilitiesByteSizeForSessionStatus_Issue3225( { ["capabilities"] = new JsonObject { + ["roots"] = new JsonObject(), ["sampling"] = new JsonObject(), ["experimental"] = new JsonObject { @@ -938,6 +939,8 @@ public void Initialize_CapsClientCapabilitiesByteSizeForSessionStatus_Issue3225( _server.HandleMessage(request); Assert.Empty(_server.ClientCapabilitiesForTests!.AsObject()); + Assert.True(_server.ClientSupportsRootsForTests); + Assert.True(_server.ClientSupportsSamplingForTests); var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = _server.HandleMessage(status)!; @@ -956,6 +959,8 @@ public void Initialize_CapsClientCapabilitiesByteSizeForSessionStatus_Issue3225( public void Initialize_CapsClientCapabilitiesDepthForSessionStatus_Issue3225() { var capabilities = new JsonObject(); + capabilities["roots"] = new JsonObject(); + capabilities["sampling"] = new JsonObject(); var current = capabilities; for (var i = 0; i < McpServer.MaxClientCapabilitiesDepth + 4; i++) { @@ -977,6 +982,8 @@ public void Initialize_CapsClientCapabilitiesDepthForSessionStatus_Issue3225() _server.HandleMessage(request); Assert.Empty(_server.ClientCapabilitiesForTests!.AsObject()); + Assert.True(_server.ClientSupportsRootsForTests); + Assert.True(_server.ClientSupportsSamplingForTests); var status = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = _server.HandleMessage(status)!;