diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 5437a37838..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`, 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 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 5f528990c2..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`, `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 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` を含みます。 | +| 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 47ee3b6cb7..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`. +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` を含みます。 +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/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/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 fd51f8ad28..3c08bf61e3 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -114,7 +114,14 @@ public partial class McpServer : IDisposable private BoundedMcpText? _clientNameDisplay; private BoundedMcpText? _clientVersionDisplay; private JsonNode? _clientCapabilities; + private int? _clientCapabilitiesSerializedBytes; + private string? _clientCapabilitiesTruncationReason; + private bool _clientSupportsRoots; + private bool _clientSupportsSampling; private JsonArray _clientRoots = []; + private JsonArray _clientRootDiagnostics = []; + 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 +183,10 @@ 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; + 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回の読み取りで吸収し、 @@ -1827,17 +1838,21 @@ private void CaptureClientInfo(JsonNode? initializeParams) private void CaptureClientSession(JsonNode? initializeParams) { _clientCapabilities = null; - _clientRoots = []; + _clientCapabilitiesSerializedBytes = null; + _clientCapabilitiesTruncationReason = null; + _clientSupportsRoots = false; + _clientSupportsSampling = false; + ResetClientRoots(); if (initializeParams is not JsonObject obj) return; 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) - _clientRoots.Add(rootUri); + CaptureClientRoot(rootUri); if (obj["roots"] is JsonArray roots) { @@ -1845,11 +1860,71 @@ private void CaptureClientSession(JsonNode? initializeParams) { var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root); if (!string.IsNullOrWhiteSpace(uri)) - _clientRoots.Add(uri); + CaptureClientRoot(uri); } } } + private void CaptureClientCapabilities(JsonNode capabilities) + { + CaptureClientCapabilityFlags(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 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); + _clientRootCount++; + if (_clientRootDiagnostics.Count >= MaxClientRootCount) + { + _clientRootsTruncated = true; + return; + } + + var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); + _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 @@ -1858,6 +1933,10 @@ private void CaptureClientSession(JsonNode? initializeParams) .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 8750eb0da1..17a5bc30b3 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 @@ -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(); @@ -2313,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; } @@ -3713,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; } @@ -4967,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 6ba8999fdd..31823c367d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -835,6 +835,167 @@ 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 + 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)!; + 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 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() + { + 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 + { + ["roots"] = new JsonObject(), + ["sampling"] = new JsonObject(), + ["experimental"] = new JsonObject + { + ["large"] = largeValue, + }, + }, + }, + }; + _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)!; + 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(); + capabilities["roots"] = new JsonObject(); + capabilities["sampling"] = 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()); + 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)!; + 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() {