Skip to content

Commit 87a736b

Browse files
authored
Cap MCP session diagnostics for roots and capabilities (#3330)
* Cap MCP session roots for status diagnostics (#3076) * Cap MCP client capabilities for status diagnostics (#3225) * Preserve MCP roots while capping status diagnostics (#3076) * Preserve MCP capability flags after diagnostic caps (#3225)
1 parent dc7b024 commit 87a736b

8 files changed

Lines changed: 316 additions & 16 deletions

File tree

AGENT_GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ CI watching must be bounded. Do not loop indefinitely.
144144
- `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).
145145
- `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).
146146
- `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.
147-
- 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`.
147+
- 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.
148148
- Keep `README.md`, `DEVELOPER_GUIDE.md`, and this file synchronized if this contract changes.
149149

150150
## Reference Extraction

DEVELOPER_GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,7 +970,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int
970970
| Version and forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`. |
971971
| 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`. |
972972
| Remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`. |
973-
| 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`. |
973+
| 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. |
974974
| 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. |
975975

976976
Runtime diagnostic subcontracts:
@@ -2988,7 +2988,7 @@ AI エージェント向け検索ルールのテンプレートについては
29882988
| version / forward compatibility | `index_writer_version`, `index_newer_than_reader`, `index_newer_than_reader_reason`|
29892989
| 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`|
29902990
| remediation fields | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`|
2991-
| MCP-only session diagnostics | `mcp_session`。これは persisted DB state ではなく session-scoped diagnostics で、`log_level``roots`、任意の `client_info`任意の `client_capabilities` を含みます。 |
2991+
| 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 を示します|
29922992
| documentation sync | この一覧は `README.md``AGENT_GUIDE.md` と同期してください。必須 field がそれらの docs から欠けると `DocumentationStatusContractTests` が失敗します。 |
29932993

29942994
runtime diagnostic subcontract:

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ After a current full-repository scan, `unknown_extension_file_count` reports how
232232

233233
`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.
234234

235-
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`.
235+
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.
236236

237237
`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.
238238

@@ -545,7 +545,7 @@ readiness field のいずれかが degraded の場合、`degraded_root_cause`
545545

546546
`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 を無効化します。
547547

548-
MCP `status``mcp_session` は永続化された index 状態ではなく、セッション単位の診断情報です。`log_level``roots`、任意の `client_info`、任意の `client_capabilities` を含みます。
548+
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 を示します。
549549

550550
`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 を含みます。
551551

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 3076
5+
affected:
6+
- src/CodeIndex/Mcp/McpServer.cs
7+
- src/CodeIndex/Mcp/McpToolHandlers.cs
8+
- tests/CodeIndex.Tests/McpServerTests.cs
9+
- README.md
10+
- DEVELOPER_GUIDE.md
11+
- AGENT_GUIDE.md
12+
---
13+
14+
## English
15+
16+
- **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.
17+
18+
## 日本語
19+
20+
- **MCP session status が保持する client root を上限付きにしました (#3076)**`initialize` の root 診断は root 一覧と個別 root URI の長さを上限付きで保持し、advertised root が短縮された場合は MCP `status``roots_truncated` metadata を出すようになりました。
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 3225
5+
affected:
6+
- src/CodeIndex/Mcp/McpServer.cs
7+
- src/CodeIndex/Mcp/McpToolHandlers.cs
8+
- tests/CodeIndex.Tests/McpServerTests.cs
9+
- README.md
10+
- DEVELOPER_GUIDE.md
11+
- AGENT_GUIDE.md
12+
---
13+
14+
## English
15+
16+
- **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.
17+
18+
## 日本語
19+
20+
- **MCP session status が保持する client capabilities を上限付きにしました (#3225)**`initialize` の capabilities 診断は serialized byte size と JSON depth で上限管理され、capabilities が短縮された場合は MCP `status` に明示的な `client_capabilities_truncated` metadata を出すようになりました。

src/CodeIndex/Mcp/McpServer.cs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,14 @@ public partial class McpServer : IDisposable
114114
private BoundedMcpText? _clientNameDisplay;
115115
private BoundedMcpText? _clientVersionDisplay;
116116
private JsonNode? _clientCapabilities;
117+
private int? _clientCapabilitiesSerializedBytes;
118+
private string? _clientCapabilitiesTruncationReason;
119+
private bool _clientSupportsRoots;
120+
private bool _clientSupportsSampling;
117121
private JsonArray _clientRoots = [];
122+
private JsonArray _clientRootDiagnostics = [];
123+
private int _clientRootCount;
124+
private bool _clientRootsTruncated;
118125
private string _mcpLogLevel = "info";
119126
// Opaque per-server-instance session id copied into suggestion attribution records (#1873).
120127
// #1873 の提案 attribution 用に保存する、サーバーインスタンス単位の不透明セッションID。
@@ -176,6 +183,10 @@ public partial class McpServer : IDisposable
176183
internal const int MaxBatchRequestCount = 100;
177184
internal const int MaxRequestIdCharacterCount = 128;
178185
internal const int MaxRequestIdByteLength = 256;
186+
internal const int MaxClientRootCount = 16;
187+
internal const int MaxClientRootUriChars = 512;
188+
internal const int MaxClientCapabilitiesJsonBytes = 8 * 1024;
189+
internal const int MaxClientCapabilitiesDepth = 8;
179190
// Stdio buffer for the JSON-RPC loop. Sized to fit typical large MCP payloads (e.g. batch_query)
180191
// in a single read so the StreamReader does not grow from its 1 KB default toward MaxLineCharacterCount.
181192
// JSON-RPCループのstdioバッファ。大きめのMCPペイロードを1回の読み取りで吸収し、
@@ -1827,29 +1838,93 @@ private void CaptureClientInfo(JsonNode? initializeParams)
18271838
private void CaptureClientSession(JsonNode? initializeParams)
18281839
{
18291840
_clientCapabilities = null;
1830-
_clientRoots = [];
1841+
_clientCapabilitiesSerializedBytes = null;
1842+
_clientCapabilitiesTruncationReason = null;
1843+
_clientSupportsRoots = false;
1844+
_clientSupportsSampling = false;
1845+
ResetClientRoots();
18311846
if (initializeParams is not JsonObject obj)
18321847
return;
18331848

18341849
if (!obj.TryGetPropertyValue("capabilities", out var capabilities))
18351850
obj.TryGetPropertyValue("clientCapabilities", out capabilities);
18361851
if (capabilities is not null)
1837-
_clientCapabilities = JsonNode.Parse(capabilities.ToJsonString());
1852+
CaptureClientCapabilities(capabilities);
18381853

18391854
if (TryReadStringValue(obj["rootUri"]) is { Length: > 0 } rootUri)
1840-
_clientRoots.Add(rootUri);
1855+
CaptureClientRoot(rootUri);
18411856

18421857
if (obj["roots"] is JsonArray roots)
18431858
{
18441859
foreach (var root in roots)
18451860
{
18461861
var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root);
18471862
if (!string.IsNullOrWhiteSpace(uri))
1848-
_clientRoots.Add(uri);
1863+
CaptureClientRoot(uri);
18491864
}
18501865
}
18511866
}
18521867

1868+
private void CaptureClientCapabilities(JsonNode capabilities)
1869+
{
1870+
CaptureClientCapabilityFlags(capabilities);
1871+
var json = capabilities.ToJsonString();
1872+
var serializedBytes = Encoding.UTF8.GetByteCount(json);
1873+
_clientCapabilitiesSerializedBytes = serializedBytes;
1874+
if (serializedBytes > MaxClientCapabilitiesJsonBytes)
1875+
{
1876+
TruncateClientCapabilities("byte_limit");
1877+
return;
1878+
}
1879+
1880+
try
1881+
{
1882+
_clientCapabilities = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = MaxClientCapabilitiesDepth });
1883+
}
1884+
catch (JsonException)
1885+
{
1886+
TruncateClientCapabilities("depth_limit");
1887+
}
1888+
}
1889+
1890+
private void TruncateClientCapabilities(string reason)
1891+
{
1892+
_clientCapabilities = new JsonObject();
1893+
_clientCapabilitiesTruncationReason = reason;
1894+
}
1895+
1896+
private void CaptureClientCapabilityFlags(JsonNode capabilities)
1897+
{
1898+
if (capabilities is not JsonObject obj)
1899+
return;
1900+
1901+
_clientSupportsRoots = obj.TryGetPropertyValue("roots", out var roots) && roots is not null;
1902+
_clientSupportsSampling = obj.TryGetPropertyValue("sampling", out var sampling) && sampling is not null;
1903+
}
1904+
1905+
private void CaptureClientRoot(string uri)
1906+
{
1907+
_clientRoots.Add(uri);
1908+
_clientRootCount++;
1909+
if (_clientRootDiagnostics.Count >= MaxClientRootCount)
1910+
{
1911+
_clientRootsTruncated = true;
1912+
return;
1913+
}
1914+
1915+
var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars);
1916+
_clientRootDiagnostics.Add(display.Text);
1917+
_clientRootsTruncated |= display.Truncated;
1918+
}
1919+
1920+
private void ResetClientRoots()
1921+
{
1922+
_clientRoots = [];
1923+
_clientRootDiagnostics = [];
1924+
_clientRootCount = 0;
1925+
_clientRootsTruncated = false;
1926+
}
1927+
18531928
internal JsonNode? ClientCapabilitiesForTests => _clientCapabilities is null ? null : JsonNode.Parse(_clientCapabilities.ToJsonString());
18541929

18551930
internal string[] ClientRootsForTests => _clientRoots
@@ -1858,6 +1933,10 @@ private void CaptureClientSession(JsonNode? initializeParams)
18581933
.Cast<string>()
18591934
.ToArray();
18601935

1936+
internal bool ClientSupportsRootsForTests => _clientSupportsRoots;
1937+
1938+
internal bool ClientSupportsSamplingForTests => _clientSupportsSampling;
1939+
18611940
internal string McpLogLevelForTests => _mcpLogLevel;
18621941

18631942
internal Func<string, JsonObject?, JsonNode?>? ClientRequestHandlerForTests { get; set; }

src/CodeIndex/Mcp/McpToolHandlers.cs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2281,14 +2281,21 @@ private JsonNode ExecuteStatus(JsonNode? id)
22812281
private JsonObject BuildMcpSessionStatus()
22822282
{
22832283
var roots = new JsonArray();
2284-
foreach (var root in _clientRoots)
2284+
foreach (var root in _clientRootDiagnostics)
22852285
roots.Add(root?.DeepClone());
22862286

22872287
var session = new JsonObject
22882288
{
22892289
["log_level"] = _mcpLogLevel,
22902290
["roots"] = roots,
22912291
};
2292+
if (_clientRootsTruncated)
2293+
{
2294+
session["roots_truncated"] = true;
2295+
session["root_count"] = _clientRootCount;
2296+
session["root_limit"] = MaxClientRootCount;
2297+
session["root_uri_length_limit"] = MaxClientRootUriChars;
2298+
}
22922299
if (_clientName is not null || _clientVersion is not null)
22932300
{
22942301
var clientInfo = new JsonObject();
@@ -2306,6 +2313,15 @@ private JsonObject BuildMcpSessionStatus()
23062313
}
23072314
if (_clientCapabilities is not null)
23082315
session["client_capabilities"] = _clientCapabilities.DeepClone();
2316+
if (_clientCapabilitiesTruncationReason is not null)
2317+
{
2318+
session["client_capabilities_truncated"] = true;
2319+
session["client_capabilities_truncation_reason"] = _clientCapabilitiesTruncationReason;
2320+
if (_clientCapabilitiesSerializedBytes is { } serializedBytes)
2321+
session["client_capabilities_serialized_bytes"] = serializedBytes;
2322+
session["client_capabilities_byte_limit"] = MaxClientCapabilitiesJsonBytes;
2323+
session["client_capabilities_depth_limit"] = MaxClientCapabilitiesDepth;
2324+
}
23092325
return session;
23102326
}
23112327

@@ -3706,14 +3722,13 @@ private async Task RefreshClientRootsIfNeededAsync()
37063722
if (result?["roots"] is not JsonArray roots)
37073723
return;
37083724

3709-
var refreshed = new JsonArray();
3725+
ResetClientRoots();
37103726
foreach (var root in roots)
37113727
{
37123728
var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root);
37133729
if (!string.IsNullOrWhiteSpace(uri))
3714-
refreshed.Add(uri);
3730+
CaptureClientRoot(uri);
37153731
}
3716-
_clientRoots = refreshed;
37173732
_clientRootsStale = false;
37183733
}
37193734

@@ -4972,9 +4987,14 @@ private static string TruncateUtf8(string value, int maxBytes)
49724987
}
49734988

49744989
private bool HasClientCapability(string name)
4975-
=> _clientCapabilities is JsonObject obj
4976-
&& obj.TryGetPropertyValue(name, out var node)
4977-
&& node is not null;
4990+
=> name switch
4991+
{
4992+
"roots" => _clientSupportsRoots,
4993+
"sampling" => _clientSupportsSampling,
4994+
_ => _clientCapabilities is JsonObject obj
4995+
&& obj.TryGetPropertyValue(name, out var node)
4996+
&& node is not null,
4997+
};
49784998

49794999
private static bool IsSamplingEnabled()
49805000
{

0 commit comments

Comments
 (0)