Optimize JsonRpcMessage deserialization with single-pass parsing (#1138)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: scooletz <scooletz@gmail.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
This commit is contained in:
parent
4a2c36cf0c
commit
2bc105b612
2 changed files with 411 additions and 30 deletions
|
|
@ -1,6 +1,8 @@
|
|||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Protocol;
|
||||
|
|
@ -78,53 +80,127 @@ public abstract class JsonRpcMessage
|
|||
throw new JsonException("Expected StartObject token");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.ParseValue(ref reader);
|
||||
var root = doc.RootElement;
|
||||
// Local variables for parsed message data
|
||||
bool hasJsonRpc = false;
|
||||
RequestId id = default;
|
||||
string? method = null;
|
||||
JsonNode? parameters = null;
|
||||
JsonRpcErrorDetail? error = null;
|
||||
JsonNode? result = null;
|
||||
bool hasResult = false;
|
||||
|
||||
// All JSON-RPC messages must have a jsonrpc property with value "2.0"
|
||||
if (!root.TryGetProperty("jsonrpc", out var versionProperty) ||
|
||||
versionProperty.GetString() != "2.0")
|
||||
while (true)
|
||||
{
|
||||
throw new JsonException("Invalid or missing jsonrpc version");
|
||||
bool success = reader.Read();
|
||||
Debug.Assert(success, "custom converters are guaranteed to be passed fully buffered objects");
|
||||
|
||||
if (reader.TokenType is JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Debug.Assert(reader.TokenType is JsonTokenType.PropertyName);
|
||||
string propertyName = reader.GetString()!;
|
||||
|
||||
success = reader.Read();
|
||||
Debug.Assert(success, "custom converters are guaranteed to be passed fully buffered objects");
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case "jsonrpc":
|
||||
// Validate that the value is "2.0" without allocating a string
|
||||
if (!reader.ValueTextEquals("2.0"u8))
|
||||
{
|
||||
throw new JsonException("Invalid jsonrpc version");
|
||||
}
|
||||
hasJsonRpc = true;
|
||||
break;
|
||||
|
||||
case "id":
|
||||
id = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<RequestId>());
|
||||
break;
|
||||
|
||||
case "method":
|
||||
method = reader.GetString();
|
||||
break;
|
||||
|
||||
case "params":
|
||||
parameters = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<JsonNode>());
|
||||
break;
|
||||
|
||||
case "error":
|
||||
error = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<JsonRpcErrorDetail>());
|
||||
break;
|
||||
|
||||
case "result":
|
||||
result = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<JsonNode>());
|
||||
hasResult = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Skip unknown properties
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the message type based on the presence of id, method, and error properties
|
||||
bool hasId = root.TryGetProperty("id", out _);
|
||||
bool hasMethod = root.TryGetProperty("method", out _);
|
||||
bool hasError = root.TryGetProperty("error", out _);
|
||||
|
||||
var rawText = root.GetRawText();
|
||||
|
||||
// Messages with an id but no method are responses
|
||||
if (hasId && !hasMethod)
|
||||
// All JSON-RPC messages must have a jsonrpc property with value "2.0"
|
||||
if (!hasJsonRpc)
|
||||
{
|
||||
// Messages with an error property are error responses
|
||||
if (hasError)
|
||||
throw new JsonException("Missing jsonrpc version");
|
||||
}
|
||||
|
||||
// Determine message type based on presence of id and method properties
|
||||
if (method is not null)
|
||||
{
|
||||
if (id.Id is not null)
|
||||
{
|
||||
return JsonSerializer.Deserialize(rawText, options.GetTypeInfo<JsonRpcError>());
|
||||
// Messages with both method and id are requests
|
||||
return new JsonRpcRequest
|
||||
{
|
||||
Id = id,
|
||||
Method = method,
|
||||
Params = parameters
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Messages with a method but no id are notifications
|
||||
return new JsonRpcNotification
|
||||
{
|
||||
Method = method,
|
||||
Params = parameters
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (id.Id is not null)
|
||||
{
|
||||
if (error is not null)
|
||||
{
|
||||
// Messages with an error and id are error responses
|
||||
return new JsonRpcError
|
||||
{
|
||||
Id = id,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
// Messages with a result property are success responses
|
||||
if (root.TryGetProperty("result", out _))
|
||||
if (hasResult)
|
||||
{
|
||||
return JsonSerializer.Deserialize(rawText, options.GetTypeInfo<JsonRpcResponse>());
|
||||
// Messages with a result and id are success responses
|
||||
return new JsonRpcResponse
|
||||
{
|
||||
Id = id,
|
||||
Result = result
|
||||
};
|
||||
}
|
||||
|
||||
// Error: Messages with an id but no method, error, or result are invalid
|
||||
throw new JsonException("Response must have either result or error");
|
||||
}
|
||||
|
||||
// Messages with a method but no id are notifications
|
||||
if (hasMethod && !hasId)
|
||||
{
|
||||
return JsonSerializer.Deserialize(rawText, options.GetTypeInfo<JsonRpcNotification>());
|
||||
}
|
||||
|
||||
// Messages with both method and id are requests
|
||||
if (hasMethod && hasId)
|
||||
{
|
||||
return JsonSerializer.Deserialize(rawText, options.GetTypeInfo<JsonRpcRequest>());
|
||||
}
|
||||
|
||||
// Error: Messages with neither id nor method are invalid
|
||||
throw new JsonException("Invalid JSON-RPC message format");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the optimized JsonRpcMessage.Converter implementation.
|
||||
/// </summary>
|
||||
public static class JsonRpcMessageConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcRequest_WithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","id":123,"method":"test/method","params":{"key":"value"}}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcRequest>(message);
|
||||
var request = (JsonRpcRequest)message;
|
||||
Assert.Equal("2.0", request.JsonRpc);
|
||||
Assert.Equal(new RequestId(123), request.Id);
|
||||
Assert.Equal("test/method", request.Method);
|
||||
Assert.NotNull(request.Params);
|
||||
Assert.Equal("value", request.Params["key"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcRequest_WithStringId()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","id":"abc-123","method":"test/method"}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcRequest>(message);
|
||||
var request = (JsonRpcRequest)message;
|
||||
Assert.Equal("2.0", request.JsonRpc);
|
||||
Assert.Equal(new RequestId("abc-123"), request.Id);
|
||||
Assert.Equal("test/method", request.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcNotification_WithParams()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":50}}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcNotification>(message);
|
||||
var notification = (JsonRpcNotification)message;
|
||||
Assert.Equal("2.0", notification.JsonRpc);
|
||||
Assert.Equal("notifications/progress", notification.Method);
|
||||
Assert.NotNull(notification.Params);
|
||||
Assert.Equal(50, notification.Params["progress"]?.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcResponse_WithResult()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","id":42,"result":{"status":"success","data":[1,2,3]}}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcResponse>(message);
|
||||
var response = (JsonRpcResponse)message;
|
||||
Assert.Equal("2.0", response.JsonRpc);
|
||||
Assert.Equal(new RequestId(42), response.Id);
|
||||
Assert.NotNull(response.Result);
|
||||
Assert.Equal("success", response.Result["status"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcResponse_WithNullResult()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","id":1,"result":null}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcResponse>(message);
|
||||
var response = (JsonRpcResponse)message;
|
||||
Assert.Equal("2.0", response.JsonRpc);
|
||||
Assert.Equal(new RequestId(1), response.Id);
|
||||
Assert.Null(response.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcError_WithErrorDetails()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","id":"req-1","error":{"code":-32600,"message":"Invalid Request","data":"Additional error info"}}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcError>(message);
|
||||
var error = (JsonRpcError)message;
|
||||
Assert.Equal("2.0", error.JsonRpc);
|
||||
Assert.Equal(new RequestId("req-1"), error.Id);
|
||||
Assert.NotNull(error.Error);
|
||||
Assert.Equal(-32600, error.Error.Code);
|
||||
Assert.Equal("Invalid Request", error.Error.Message);
|
||||
Assert.Equal("Additional error info", error.Error.Data?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_JsonRpcMessage_IgnoresUnknownProperties()
|
||||
{
|
||||
// Arrange - JSON with unknown properties
|
||||
string json = """{"jsonrpc":"2.0","id":1,"method":"test","params":{},"extra":"ignored","another":123}""";
|
||||
|
||||
// Act
|
||||
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert - should successfully deserialize, ignoring unknown properties
|
||||
Assert.NotNull(message);
|
||||
Assert.IsType<JsonRpcRequest>(message);
|
||||
var request = (JsonRpcRequest)message;
|
||||
Assert.Equal("test", request.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_InvalidJsonRpcVersion_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"1.0","id":1,"method":"test"}""";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Contains("jsonrpc version", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_MissingJsonRpcVersion_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"id":1,"method":"test"}""";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Contains("jsonrpc version", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_ResponseWithoutResultOrError_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
string json = """{"jsonrpc":"2.0","id":1}""";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Contains("result or error", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Deserialize_InvalidMessageFormat_ThrowsException()
|
||||
{
|
||||
// Arrange - neither request nor response nor notification
|
||||
string json = """{"jsonrpc":"2.0"}""";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() =>
|
||||
JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions));
|
||||
Assert.Contains("Invalid JSON-RPC message format", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Serialize_JsonRpcRequest_ProducesCorrectJson()
|
||||
{
|
||||
// Arrange
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
JsonRpc = "2.0",
|
||||
Id = new RequestId(123),
|
||||
Method = "test/method",
|
||||
Params = JsonNode.Parse("""{"key":"value"}""")
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize<JsonRpcMessage>(request, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"jsonrpc\":\"2.0\"", json);
|
||||
Assert.Contains("\"id\":123", json);
|
||||
Assert.Contains("\"method\":\"test/method\"", json);
|
||||
Assert.Contains("\"key\":\"value\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void Serialize_JsonRpcNotification_ProducesCorrectJson()
|
||||
{
|
||||
// Arrange
|
||||
var notification = new JsonRpcNotification
|
||||
{
|
||||
JsonRpc = "2.0",
|
||||
Method = "notifications/test"
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize<JsonRpcMessage>(notification, McpJsonUtilities.DefaultOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("\"jsonrpc\":\"2.0\"", json);
|
||||
Assert.Contains("\"method\":\"notifications/test\"", json);
|
||||
Assert.DoesNotContain("\"id\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void RoundTrip_Request_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var original = new JsonRpcRequest
|
||||
{
|
||||
JsonRpc = "2.0",
|
||||
Id = new RequestId("test-id"),
|
||||
Method = "some/method",
|
||||
Params = JsonNode.Parse("""{"nested":{"value":42}}""")
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize<JsonRpcMessage>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions) as JsonRpcRequest;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(original.JsonRpc, deserialized.JsonRpc);
|
||||
Assert.Equal(original.Id, deserialized.Id);
|
||||
Assert.Equal(original.Method, deserialized.Method);
|
||||
Assert.Equal(42, deserialized.Params?["nested"]?["value"]?.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void RoundTrip_Response_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var original = new JsonRpcResponse
|
||||
{
|
||||
JsonRpc = "2.0",
|
||||
Id = new RequestId(999),
|
||||
Result = JsonNode.Parse("""{"success":true,"items":[1,2,3]}""")
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize<JsonRpcMessage>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions) as JsonRpcResponse;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(original.JsonRpc, deserialized.JsonRpc);
|
||||
Assert.Equal(original.Id, deserialized.Id);
|
||||
Assert.True(deserialized.Result?["success"]?.GetValue<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public static void RoundTrip_Error_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var original = new JsonRpcError
|
||||
{
|
||||
JsonRpc = "2.0",
|
||||
Id = new RequestId(100),
|
||||
Error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = -32601,
|
||||
Message = "Method not found",
|
||||
Data = "test/unknown"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize<JsonRpcMessage>(original, McpJsonUtilities.DefaultOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions) as JsonRpcError;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(original.JsonRpc, deserialized.JsonRpc);
|
||||
Assert.Equal(original.Id, deserialized.Id);
|
||||
Assert.Equal(original.Error.Code, deserialized.Error.Code);
|
||||
Assert.Equal(original.Error.Message, deserialized.Error.Message);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue