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/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/JsonRpcMessageSerializationBenchmarks.cs b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs new file mode 100644 index 00000000..f9e99994 --- /dev/null +++ b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Benchmarks; + +[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] + 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)!; +} \ No newline at end of file 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); 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..bd9fae31 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. + /// + internal 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 :