From f6fc86333f99ecb97d457220b2bfc28730d6daf1 Mon Sep 17 00:00:00 2001 From: Szymon Kulec Date: Mon, 21 Jul 2025 14:20:21 +0200 Subject: [PATCH 1/4] Add BenchmarkDotNet project for JSON-RPC message deserialization --- Directory.Packages.props | 1 + ModelContextProtocol.slnx | 3 + ...JsonRpcMessageDeserializationBenchmarks.cs | 69 +++++++++++++++++++ .../ModelContextProtocol.Benchmarks.csproj | 19 +++++ .../Program.cs | 3 + 5 files changed, 95 insertions(+) create mode 100644 benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs create mode 100644 benchmarks/ModelContextProtocol.Benchmarks/ModelContextProtocol.Benchmarks.csproj create mode 100644 benchmarks/ModelContextProtocol.Benchmarks/Program.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6da9521f..9320d991 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -79,5 +79,6 @@ + \ No newline at end of file diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 5ed8ba0d..730ffd4a 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -40,4 +40,7 @@ + + + diff --git a/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs new file mode 100644 index 00000000..723600ac --- /dev/null +++ b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs @@ -0,0 +1,69 @@ +using BenchmarkDotNet.Attributes; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +public class JsonRpcMessageDeserializationBenchmarks +{ + private byte[] _requestJson = null!; + private byte[] _notificationJson = null!; + private byte[] _responseJson = null!; + private byte[] _errorJson = null!; + private JsonSerializerOptions _options = null!; + + [GlobalSetup] + public void Setup() + { + _options = McpJsonUtilities.DefaultOptions; + + _requestJson = JsonSerializer.SerializeToUtf8Bytes( + new JsonRpcRequest + { + Id = new RequestId("1"), + Method = "test", + Params = JsonValue.Create(1) + }, + _options); + + _notificationJson = JsonSerializer.SerializeToUtf8Bytes( + new JsonRpcNotification + { + Method = "notify", + Params = JsonValue.Create(2) + }, + _options); + + _responseJson = JsonSerializer.SerializeToUtf8Bytes( + new JsonRpcResponse + { + Id = new RequestId("1"), + Result = JsonValue.Create(3) + }, + _options); + + _errorJson = JsonSerializer.SerializeToUtf8Bytes( + new JsonRpcError + { + Id = new RequestId("1"), + Error = new JsonRpcErrorDetail { Code = 42, Message = "oops" } + }, + _options); + } + + [Benchmark] + public JsonRpcMessage DeserializeRequest() => + JsonSerializer.Deserialize(_requestJson, _options)!; + + [Benchmark] + public JsonRpcMessage DeserializeNotification() => + JsonSerializer.Deserialize(_notificationJson, _options)!; + + [Benchmark] + public JsonRpcMessage DeserializeResponse() => + JsonSerializer.Deserialize(_responseJson, _options)!; + + [Benchmark] + public JsonRpcMessage DeserializeError() => + JsonSerializer.Deserialize(_errorJson, _options)!; +} diff --git a/benchmarks/ModelContextProtocol.Benchmarks/ModelContextProtocol.Benchmarks.csproj b/benchmarks/ModelContextProtocol.Benchmarks/ModelContextProtocol.Benchmarks.csproj new file mode 100644 index 00000000..c25c9dfe --- /dev/null +++ b/benchmarks/ModelContextProtocol.Benchmarks/ModelContextProtocol.Benchmarks.csproj @@ -0,0 +1,19 @@ + + + + Exe + net9.0 + enable + enable + false + + + + + + + + + + + diff --git a/benchmarks/ModelContextProtocol.Benchmarks/Program.cs b/benchmarks/ModelContextProtocol.Benchmarks/Program.cs new file mode 100644 index 00000000..c9a04672 --- /dev/null +++ b/benchmarks/ModelContextProtocol.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); From 2bceacfe37bd8dc7b6f3abaa24db167d799a719f Mon Sep 17 00:00:00 2001 From: scooletz Date: Mon, 21 Jul 2025 14:28:26 +0200 Subject: [PATCH 2/4] fixed namespaces and ignores --- .gitignore | 6 +++++- ....cs => JsonRpcMessageSerializationBenchmarks.cs} | 13 ++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) rename benchmarks/ModelContextProtocol.Benchmarks/{JsonRpcMessageDeserializationBenchmarks.cs => JsonRpcMessageSerializationBenchmarks.cs} (94%) diff --git a/.gitignore b/.gitignore index 171615f9..2184f142 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,8 @@ docs/api # Rider .idea/ -.idea_modules/ \ No newline at end of file +.idea_modules/ + +# Benchmarkdotnet + +benchmarks/ModelContextProtocol.Benchmarks/BenchmarkDotNet.Artifacts/ \ No newline at end of file diff --git a/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs similarity index 94% rename from benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs rename to benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs index 723600ac..f9e99994 100644 --- a/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageDeserializationBenchmarks.cs +++ b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs @@ -1,15 +1,18 @@ -using BenchmarkDotNet.Attributes; -using ModelContextProtocol; -using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Benchmarks; -public class JsonRpcMessageDeserializationBenchmarks +[MemoryDiagnoser] +public class JsonRpcMessageSerializationBenchmarks { private byte[] _requestJson = null!; private byte[] _notificationJson = null!; private byte[] _responseJson = null!; private byte[] _errorJson = null!; + private JsonSerializerOptions _options = null!; [GlobalSetup] @@ -66,4 +69,4 @@ public JsonRpcMessage DeserializeResponse() => [Benchmark] public JsonRpcMessage DeserializeError() => JsonSerializer.Deserialize(_errorJson, _options)!; -} +} \ No newline at end of file From 7c01d9f30bfd8a781322b06ba042cd2d2a29702c Mon Sep 17 00:00:00 2001 From: scooletz Date: Mon, 21 Jul 2025 15:30:13 +0200 Subject: [PATCH 3/4] union based deserialization --- .../McpJsonUtilities.cs | 3 + .../Protocol/JsonRpcError.cs | 4 +- .../Protocol/JsonRpcMessage.cs | 99 +++++++++++++++---- .../Protocol/JsonRpcMessageWithId.cs | 4 +- .../Protocol/JsonRpcRequest.cs | 7 +- .../Protocol/JsonRpcResponse.cs | 4 +- .../Protocol/RequestId.cs | 5 + 7 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 21e2468d..08281ee7 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -96,6 +96,9 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(JsonRpcNotification))] [JsonSerializable(typeof(JsonRpcResponse))] [JsonSerializable(typeof(JsonRpcError))] + + // JSON-RPC union to make it faster to deserialize messages + [JsonSerializable(typeof(JsonRpcMessage.Converter.Union))] // MCP Notification Params [JsonSerializable(typeof(CancelledNotificationParams))] diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcError.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcError.cs index 5de344db..0e0bdfcd 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcError.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcError.cs @@ -18,10 +18,12 @@ namespace ModelContextProtocol.Protocol; /// public sealed class JsonRpcError : JsonRpcMessageWithId { + internal const string ErrorPropertyName = "error"; + /// /// Gets detailed error information for the failed request, containing an error code, /// message, and optional additional data /// - [JsonPropertyName("error")] + [JsonPropertyName(ErrorPropertyName)] public required JsonRpcErrorDetail Error { get; init; } } diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index b3176937..a8ab4527 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -1,6 +1,7 @@ using ModelContextProtocol.Server; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol; @@ -16,6 +17,8 @@ namespace ModelContextProtocol.Protocol; [JsonConverter(typeof(Converter))] public abstract class JsonRpcMessage { + private const string JsonRpcPropertyName = "jsonrpc"; + /// Prevent external derivations. private protected JsonRpcMessage() { @@ -25,7 +28,7 @@ private protected JsonRpcMessage() /// Gets the JSON-RPC protocol version used. /// /// - [JsonPropertyName("jsonrpc")] + [JsonPropertyName(JsonRpcPropertyName)] public string JsonRpc { get; init; } = "2.0"; /// @@ -75,6 +78,48 @@ private protected JsonRpcMessage() [EditorBrowsable(EditorBrowsableState.Never)] public sealed class Converter : JsonConverter { + /// + /// The union to deserialize. + /// + public struct Union + { + /// + /// + /// + [JsonPropertyName(JsonRpcPropertyName)] + public string JsonRpc { get; set; } + + /// + /// + /// + [JsonPropertyName(JsonRpcMessageWithId.IdPropertyName)] + public RequestId Id { get; set; } + + /// + /// + /// + [JsonPropertyName(JsonRpcRequest.MethodPropertyName)] + public string? Method { get; set; } + + /// + /// + /// + [JsonPropertyName(JsonRpcRequest.ParamsPropertyName)] + public JsonNode? Params { get; set; } + + /// + /// + /// + [JsonPropertyName(JsonRpcError.ErrorPropertyName)] + public JsonRpcErrorDetail? Error { get; set; } + + /// + /// + /// + [JsonPropertyName(JsonRpcResponse.ResultPropertyName)] + public JsonNode? Result { get; set; } + } + /// public override JsonRpcMessage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -83,51 +128,63 @@ public sealed class Converter : JsonConverter throw new JsonException("Expected StartObject token"); } - using var doc = JsonDocument.ParseValue(ref reader); - var root = doc.RootElement; + var union = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); // All JSON-RPC messages must have a jsonrpc property with value "2.0" - if (!root.TryGetProperty("jsonrpc", out var versionProperty) || - versionProperty.GetString() != "2.0") + if (union.JsonRpc != "2.0") { throw new JsonException("Invalid or missing jsonrpc version"); } - // 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) + if (union.Id.HasValue && union.Method is null) { // Messages with an error property are error responses - if (hasError) + if (union.Error != null) { - return JsonSerializer.Deserialize(rawText, options.GetTypeInfo()); + return new JsonRpcError + { + Id = union.Id, + Error = union.Error, + JsonRpc = union.JsonRpc, + }; } // Messages with a result property are success responses - if (root.TryGetProperty("result", out _)) + if (union.Result != null) { - return JsonSerializer.Deserialize(rawText, options.GetTypeInfo()); + return new JsonRpcResponse + { + Id = union.Id, + Result = union.Result, + JsonRpc = union.JsonRpc, + }; } throw new JsonException("Response must have either result or error"); } // Messages with a method but no id are notifications - if (hasMethod && !hasId) + if (union.Method != null && !union.Id.HasValue) { - return JsonSerializer.Deserialize(rawText, options.GetTypeInfo()); + return new JsonRpcNotification + { + Method = union.Method, + JsonRpc = union.JsonRpc, + Params = union.Params, + }; } // Messages with both method and id are requests - if (hasMethod && hasId) + if (union.Method != null && union.Id.HasValue) { - return JsonSerializer.Deserialize(rawText, options.GetTypeInfo()); + return new JsonRpcRequest + { + Id = union.Id, + Method = union.Method, + JsonRpc = union.JsonRpc, + Params = union.Params, + }; } throw new JsonException("Invalid JSON-RPC message format"); diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageWithId.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageWithId.cs index 8233df48..a32d72d8 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageWithId.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageWithId.cs @@ -14,6 +14,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class JsonRpcMessageWithId : JsonRpcMessage { + internal const string IdPropertyName = "id"; + /// Prevent external derivations. private protected JsonRpcMessageWithId() { @@ -25,6 +27,6 @@ private protected JsonRpcMessageWithId() /// /// Each ID is expected to be unique within the context of a given session. /// - [JsonPropertyName("id")] + [JsonPropertyName(IdPropertyName)] public RequestId Id { get; init; } } diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcRequest.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcRequest.cs index ed6c8982..037f7b04 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcRequest.cs @@ -16,16 +16,19 @@ namespace ModelContextProtocol.Protocol; /// public sealed class JsonRpcRequest : JsonRpcMessageWithId { + internal const string MethodPropertyName = "method"; + internal const string ParamsPropertyName = "params"; + /// /// Name of the method to invoke. /// - [JsonPropertyName("method")] + [JsonPropertyName(MethodPropertyName)] public required string Method { get; init; } /// /// Optional parameters for the method. /// - [JsonPropertyName("params")] + [JsonPropertyName(ParamsPropertyName)] public JsonNode? Params { get; init; } internal JsonRpcRequest WithId(RequestId id) diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcResponse.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcResponse.cs index c7d824b7..86889d2d 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcResponse.cs @@ -18,12 +18,14 @@ namespace ModelContextProtocol.Protocol; /// public sealed class JsonRpcResponse : JsonRpcMessageWithId { + internal const string ResultPropertyName = "result"; + /// /// Gets the result of the method invocation. /// /// /// This property contains the result data returned by the server in response to the JSON-RPC method request. /// - [JsonPropertyName("result")] + [JsonPropertyName(ResultPropertyName)] public required JsonNode? Result { get; init; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestId.cs b/src/ModelContextProtocol.Core/Protocol/RequestId.cs index 8d445deb..b302e26c 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestId.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestId.cs @@ -34,6 +34,11 @@ public RequestId(long value) /// This will either be a , a boxed , or . public object? Id => _id; + /// + /// Returns true if the underlying id is set. + /// + public bool HasValue => _id != null; + /// public override string ToString() => _id is string stringValue ? stringValue : From f1fbfcbea9a16aec6f864decf74fc9ac700411a2 Mon Sep 17 00:00:00 2001 From: scooletz Date: Mon, 21 Jul 2025 18:37:57 +0200 Subject: [PATCH 4/4] Union internalized --- src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index a8ab4527..bd9fae31 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -81,7 +81,7 @@ public sealed class Converter : JsonConverter /// /// The union to deserialize. /// - public struct Union + internal struct Union { /// ///