Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions changelog.d/unreleased/3055.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: fixed
issues:
- 3055
affected:
- src/CodeIndex/Mcp/McpJsonNode.cs
- src/CodeIndex/Mcp/McpErrorEnvelope.cs
- src/CodeIndex/Mcp/McpServer.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **MCP JSON node cloning no longer serializes and reparses valid nodes (#3055)** — MCP response ids, session capabilities, progress tokens, and structured error extra data now use `JsonNode.DeepClone()` through a shared helper instead of `ToJsonString()` plus `JsonNode.Parse(...)`.

## 日本語

- **MCP の JSON node clone が有効な node を文字列化して再 parse しなくなりました (#3055)** — MCP response id、session capabilities、progress token、structured error の追加データは、`ToJsonString()` と `JsonNode.Parse(...)` の組み合わせではなく、共有 helper 経由の `JsonNode.DeepClone()` を使うようになりました。
2 changes: 1 addition & 1 deletion src/CodeIndex/Mcp/McpErrorEnvelope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public static JsonObject BuildData(string category, string suggestion, bool retr
// canonical キーは extra でも上書きさせない。
if (kvp.Key is "category" or "suggestion" or "retry_safe")
continue;
data[kvp.Key] = kvp.Value is null ? null : JsonNode.Parse(kvp.Value.ToJsonString());
data[kvp.Key] = McpJsonNode.Clone(kvp.Value);
}
}
return data;
Expand Down
9 changes: 9 additions & 0 deletions src/CodeIndex/Mcp/McpJsonNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Nodes;

namespace CodeIndex.Mcp;

internal static class McpJsonNode
{
public static JsonNode? Clone(JsonNode? node)
=> node?.DeepClone();
}
62 changes: 45 additions & 17 deletions src/CodeIndex/Mcp/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ private bool TryCloneClientResponsePayload(JsonNode? payload, out JsonNode? clon
if (!TryMeasureJsonUtf8BytesWithinLimit(payload, _jsonOptions, MaxClientResponseJsonBytes, out bytesWritten))
return false;

clone = payload.DeepClone();
clone = McpJsonNode.Clone(payload);
return true;
}

Expand Down Expand Up @@ -1212,7 +1212,7 @@ private static void ExtractResponseId(JsonNode request, out bool hasId, out Json
if (request is JsonObject obj)
{
if (TryGetRequestId(obj, out hasId, out var requestId))
id = requestId is null ? null : JsonNode.Parse(requestId.ToJsonString());
id = McpJsonNode.Clone(requestId);
else
id = null;
return;
Expand Down Expand Up @@ -1916,23 +1916,51 @@ private void CaptureClientSession(JsonNode? initializeParams)
private void CaptureClientCapabilities(JsonNode capabilities)
{
CaptureClientCapabilityFlags(capabilities);
var json = capabilities.ToJsonString();
var serializedBytes = Encoding.UTF8.GetByteCount(json);
_clientCapabilitiesSerializedBytes = serializedBytes;
if (serializedBytes > MaxClientCapabilitiesJsonBytes)
if (!TryMeasureJsonUtf8BytesWithinLimit(capabilities, _jsonOptions, MaxClientCapabilitiesJsonBytes, out var serializedBytes))
{
_clientCapabilitiesSerializedBytes = serializedBytes;
TruncateClientCapabilities("byte_limit");
return;
}

try
_clientCapabilitiesSerializedBytes = serializedBytes;
if (!IsJsonNodeDepthWithinLimit(capabilities, MaxClientCapabilitiesDepth))
{
_clientCapabilities = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = MaxClientCapabilitiesDepth });
TruncateClientCapabilities("depth_limit");
return;
}
catch (JsonException)

_clientCapabilities = McpJsonNode.Clone(capabilities);
}

private static bool IsJsonNodeDepthWithinLimit(JsonNode node, int maxDepth)
=> IsJsonNodeDepthWithinLimit(node, depth: 0, maxDepth);

private static bool IsJsonNodeDepthWithinLimit(JsonNode? node, int depth, int maxDepth)
{
if (node is null)
return true;
if (depth > maxDepth)
return false;

if (node is JsonObject obj)
{
TruncateClientCapabilities("depth_limit");
foreach (var kvp in obj)
{
if (!IsJsonNodeDepthWithinLimit(kvp.Value, depth + 1, maxDepth))
return false;
}
}
else if (node is JsonArray array)
{
foreach (var item in array)
{
if (!IsJsonNodeDepthWithinLimit(item, depth + 1, maxDepth))
return false;
}
}

return true;
}

private void TruncateClientCapabilities(string reason)
Expand Down Expand Up @@ -1973,7 +2001,7 @@ private void ResetClientRoots()
_clientRootsTruncated = false;
}

internal JsonNode? ClientCapabilitiesForTests => _clientCapabilities is null ? null : JsonNode.Parse(_clientCapabilities.ToJsonString());
internal JsonNode? ClientCapabilitiesForTests => McpJsonNode.Clone(_clientCapabilities);

internal string[] ClientRootsForTests => _clientRoots
.Select(root => root?.GetValue<string>())
Expand Down Expand Up @@ -2426,7 +2454,7 @@ private static JsonObject CreateUnsupportedProtocolError(JsonNode? id, BoundedMc
{
["jsonrpc"] = "2.0",
["error"] = error,
["id"] = id is null ? JsonNode.Parse("null") : JsonNode.Parse(id.ToJsonString())
["id"] = McpJsonNode.Clone(id)
};
return response;
}
Expand Down Expand Up @@ -2503,7 +2531,7 @@ internal static JsonObject CreateRateLimitedErrorResponse(JsonNode? id, string t
{
["jsonrpc"] = "2.0",
["error"] = error,
["id"] = id is null ? JsonNode.Parse("null") : JsonNode.Parse(id.ToJsonString())
["id"] = McpJsonNode.Clone(id)
};
return response;
}
Expand Down Expand Up @@ -2790,7 +2818,7 @@ private void EmitToolInvocationTelemetry(string toolName, JsonNode? args, JsonNo
return null;

return TryMeasureJsonUtf8BytesWithinLimit(token, _jsonOptions, McpBoundedText.MaxProgressTokenJsonBytes, out _)
? token.DeepClone()
? McpJsonNode.Clone(token)
: null;
}

Expand Down Expand Up @@ -2854,7 +2882,7 @@ private void EmitProgressNotification(JsonNode? progressToken, long progress, lo

var parameters = new JsonObject
{
["progressToken"] = progressToken.DeepClone(),
["progressToken"] = McpJsonNode.Clone(progressToken),
["progress"] = progress,
};
if (total.HasValue)
Expand Down Expand Up @@ -3578,7 +3606,7 @@ private static JsonObject CreateSuccessResponse(bool hasId, JsonNode? id, JsonNo
["result"] = result
};
if (hasId)
response["id"] = id is null ? JsonNode.Parse("null") : JsonNode.Parse(id.ToJsonString());
response["id"] = McpJsonNode.Clone(id);
return response;
}

Expand Down Expand Up @@ -3676,7 +3704,7 @@ private static JsonObject CreateErrorResponse(bool hasId, JsonNode? id, int code
}
};
if (hasId)
response["id"] = id is null ? JsonNode.Parse("null") : JsonNode.Parse(id.ToJsonString());
response["id"] = McpJsonNode.Clone(id);
return response;
}

Expand Down
53 changes: 53 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,59 @@ public void Initialize_CapturesClientCapabilitiesAndRootsForSessionStatus()
Assert.Equal("info", session["log_level"]!.GetValue<string>());
}

[Fact]
public void Initialize_CapturesClientCapabilitiesAsDetachedClone_Issue3055()
{
var capabilities = new JsonObject
{
["experimental"] = new JsonObject
{
["progress"] = true,
},
};
var request = new JsonObject
{
["jsonrpc"] = "2.0",
["id"] = 1,
["method"] = "initialize",
["params"] = new JsonObject
{
["capabilities"] = capabilities,
},
};

_server.HandleMessage(request);
capabilities["experimental"]!["progress"] = false;
var copy = _server.ClientCapabilitiesForTests!;
copy["experimental"]!["progress"] = false;

Assert.True(_server.ClientCapabilitiesForTests!["experimental"]!["progress"]!.GetValue<bool>());
}

[Fact]
public void ErrorEnvelope_ClonesExtraDataWithoutSerializeParseRoundTrip_Issue3055()
{
var details = new JsonObject
{
["ok"] = true,
};
var extra = new JsonObject
{
["details"] = details,
["category"] = "shadow",
};

var data = McpErrorEnvelope.BuildData(
"custom_category",
"custom suggestion",
retrySafe: true,
extra);
details["ok"] = false;

Assert.Equal("custom_category", data["category"]!.GetValue<string>());
Assert.True(data["details"]!["ok"]!.GetValue<bool>());
}

[Fact]
public void Initialize_CapsClientRootsForSessionStatus_Issue3076()
{
Expand Down
Loading