diff --git a/.gitignore b/.gitignore index 171615f97..2184f142a 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 6da9521f7..9320d9910 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 0850150be..111973c0e 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -41,4 +41,7 @@ + + + diff --git a/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs b/benchmarks/ModelContextProtocol.Benchmarks/JsonRpcMessageSerializationBenchmarks.cs new file mode 100644 index 000000000..f9e999947 --- /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 000000000..c25c9dfef --- /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 000000000..c9a046727 --- /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 8bc9e21b0..974d7c324 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 5de344db8..0e0bdfcd9 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 b3176937c..bd9fae315 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 8233df485..a32d72d8f 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 ed6c8982a..037f7b04b 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 c7d824b77..86889d2d2 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 8d445deb8..b302e26c5 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 :