JSON-RPC 2.0 is a small specification: the client sends {"jsonrpc":"2.0","method":"...","params":{...},"id":1}, and the server answers with a result or an error. REST makes you decide on routes, HTTP verbs and status codes; JSON-RPC only cares about which method to call and with what parameters. For an API where you own both the front end and the back end, that is pleasant to work with.
Polhem.JsonRpc is a set of .NET packages for JSON-RPC 2.0: a transport-independent server, an ASP.NET Core endpoint and a client, built on System.Text.Json alone. It started as the API layer of the Polhem framework. I have now extracted it into standalone packages that do not depend on the framework, so other projects can reference them directly. The source is on GitHub under the MIT license, and the packages are on NuGet.
1️⃣ What it covers
- Full JSON-RPC 2.0: ordinary calls, notifications (calls that expect no response), and batches (several calls in one message). An id can be a string, an integer or
null. - A transport-independent server: the dispatcher (the part that finds and runs the method) knows nothing about HTTP. The ASP.NET Core endpoint is a separate package.
- A client: an HTTP transport, batches, notifications, and interceptors that can rewrite the parameters and result of each call.
- Optional payload encryption: the parameters and result go into an envelope that can be compressed and encrypted, and replayed calls can be rejected.
It is split into several NuGet packages, so an application references only what it needs:
| Package | Use |
|---|---|
| Polhem.JsonRpc | Message types, error codes and the transport abstraction shared by the server and the client |
| Polhem.JsonRpc.Server | The dispatcher: method resolution, parameter binding and filters |
| Polhem.JsonRpc.AspNetCore | The ASP.NET Core endpoint, MapJsonRpc
|
| Polhem.JsonRpc.Client | The client, JsonRpcConnector
|
| Polhem.JsonRpc.Payload | Optional: the payload envelope, compression, encryption and replay protection |
| Polhem.JsonRpc.Payload.Client | Optional: a client that seals the parameters and opens the result of every call |
| Polhem.JsonRpc.Payload.Server | Optional: opens the envelope and checks for replays on the server |
The server references Polhem.JsonRpc.AspNetCore, and the client references only Polhem.JsonRpc.Client. The split keeps a client, whether a desktop app or a mobile app, from pulling in ASP.NET Core and the reflection-based dispatcher.
Native AOT compiles an app to native code at publish time, so nothing can rely on reflection at run time; iOS and WebAssembly both need it. The shared package, the client and the payload packages are marked AOT-compatible, and CI publishes a test program with Native AOT and runs it. The server package finds methods by reflection, so it does not support AOT. The target framework is net10.0.
2️⃣ Getting started
ASP.NET Core server (Minimal API)
The server uses ASP.NET Core Minimal APIs (endpoints mapped directly in Program.cs with MapXxx, without MVC controllers). MapJsonRpc creates a POST endpoint at the given path, and every JSON-RPC call comes in through it. AddControllers is not needed.
Program.cs needs three things: register the object factory, register the JSON-RPC services, and map the endpoint.
using Polhem.JsonRpc.AspNetCore;
using Polhem.JsonRpc.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IJsonRpcObjectFactory, AppObjectFactory>();
builder.Services.AddJsonRpcServer();
var app = builder.Build();
app.MapJsonRpc("/api");
await app.RunAsync();
AddJsonRpcServer takes two sets of options. The first configures the server itself, such as filters, the method policy, the batch limit and serializer options. The second configures the HTTP endpoint, such as the request body size limit and which HTTP status code an error returns. Leave them out and the defaults apply.
builder.Services.AddJsonRpcServer(
options =>
{
options.MaxBatchSize = 50;
options.Filters.Add(new ApiKeyFilter(apiKey)); // see section 5
},
http => http.MaxRequestBodySize = 1024 * 1024);
MapJsonRpc returns the same thing as any other Minimal API endpoint, so authorization, CORS and rate limiting chain on the usual way, for example app.MapJsonRpc("/api").RequireAuthorization();.
The object factory and the class that handles the calls:
public sealed class AppObjectFactory : IJsonRpcObjectFactory
{
public object? CreateObject(string progId, JsonRpcRequestContext context) => progId switch
{
"Calculator" => new Calculator(),
_ => null,
};
}
public sealed class Calculator
{
public AddResponse Add(AddRequest request) => new() { Sum = request.A + request.B };
}
Client
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5080/api") };
var rpc = new JsonRpcConnector(new HttpTransport(http));
var added = await rpc.InvokeAsync<AddResponse>("Calculator.Add", new AddRequest { A = 1, B = 2 });
That is enough to call Calculator.Add. Note that the server has no AddMethod or similar registration, and Calculator carries no attribute.
3️⃣ With no registration, which methods can be called?
Skipping registration is convenient, but it raises a worry: if one day I add a public method to the class for internal use, can it be called from the network too? When the server receives Calculator.Add, it runs the following checks in order.
Name format. The method name must be ProgId.Action. A ProgId (program identifier) may contain letters, digits, underscores and hyphens; an action letters, digits and underscores; each at most 64 characters. A malformed name is rejected before any lookup.
The application decides the object. The ProgId is passed to your implementation of IJsonRpcObjectFactory.CreateObject, which decides which object to create; returning null means the ProgId is unknown. The packages keep no registry of names, so which ProgIds exist is entirely up to the factory. After the call, the object is passed to ReleaseObjectAsync to be released.
Method signature. An action maps only to a public, non-generic instance method with exactly one parameter. Static methods, property accessors and methods declared by object do not count. If two or more methods with the same name qualify, the server does not pick one; it treats the method as not found.
Naming convention. The parameter type must be named {Action}Request, and the return type (or the result type of Task<T> or ValueTask<T>) must be named {Action}Response. Add takes an AddRequest and returns an AddResponse. This is the default method policy; any public method that does not follow it is treated as nonexistent and answered with -32601 Method not found. An internal helper method that does not follow this naming cannot be called from outside.
The rule can be replaced through JsonRpcServerOptions.MethodPolicy. The Polhem framework replaces it with one that reads its own access-control attribute.
Policy before filters. The method policy runs before every filter, so filters such as authorization checks or decryption only ever see calls that are allowed to run.
Only after these checks are the params bound. params must be a JSON object, deserialized into the request class with camelCase names by default; missing params or an array (positional parameters) are answered with -32602 Invalid params. To return a custom error, a method throws JsonRpcErrorException; any other exception is answered with -32603 Internal error, and the exception message is not sent to the client.
4️⃣ The client
An error returned by the server becomes a JsonRpcErrorException, which carries the error code and message:
try
{
await rpc.InvokeAsync<DivideResponse>("Calculator.Divide", new DivideRequest { Dividend = 1, Divisor = 0 });
}
catch (JsonRpcErrorException ex)
{
Console.WriteLine($"Error {ex.Code}: {ex.Message}");
}
Notifications and batches:
// The server runs it but does not respond.
await rpc.NotifyAsync("Calculator.Log", new LogRequest { Message = "Hello" });
// Two calls sent in the same message.
var batch = rpc.CreateBatch();
var first = batch.Add<AddResponse>("Calculator.Add", new AddRequest { A = 2, B = 3 });
var second = batch.Add<AddResponse>("Calculator.Add", new AddRequest { A = 4, B = 5 });
await batch.SendAsync();
Console.WriteLine($"{(await first)!.Sum}, {(await second)!.Sum}");
The request and response classes usually live in a project shared by both sides, which is how the samples in the repo are arranged.
5️⃣ Extension points: filters and interceptors
A server filter wraps every call. It can reject the call, rewrite the parameters before the method runs, and rewrite the result afterwards. For example, checking an API key:
public sealed class ApiKeyFilter(string expectedKey) : IJsonRpcFilter
{
public ValueTask InvokeAsync(JsonRpcRequestContext context, JsonRpcFilterDelegate next)
{
context.Transport.Headers.TryGetValue("X-Api-Key", out var key);
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(key ?? ""), Encoding.UTF8.GetBytes(expectedKey)))
{
throw new JsonRpcErrorException(-32001, "Unauthorized");
}
return next(context);
}
}
builder.Services.AddJsonRpcServer(options => options.Filters.Add(new ApiKeyFilter(apiKey)));
To add an HTTP header on the client, use HttpClient's own DelegatingHandler; the packages add no layer of their own. To rewrite the parameters and result of every call, add an IJsonRpcClientInterceptor.
The Polhem framework plugs its sessions and authorization checks into these extension points (the object factory, the method policy and filters). None of that logic is in the packages themselves.
6️⃣ Encrypted payloads
HTTPS protects the connection. If the parameters and results also need end-to-end protection, or a call must not run a second time when it is resent, add the optional payload packages.
They put params and result into an envelope with three formats: plain JSON, encoded (serialized, then gzip-compressed), and encrypted (AES-256-CBC with HMAC-SHA256, encrypt-then-MAC). A frame carrying a timestamp and a sequence number can also be put in front of the content, so the server can reject expired or repeated requests; that is the replay protection.
// Server: the application's policy decides the keys and which methods must be encrypted or replay-protected.
builder.Services.AddJsonRpcServer(options => options.UsePayload(payloadOptions, new MyPayloadPolicy()));
// Client: called the same way as an ordinary call; each call seals its parameters and opens its result.
var rpc = new PayloadConnector(connector, new PayloadProcessor(payloadOptions),
new PayloadConnectorOptions { KeyProvider = () => sessionKey });
var added = await rpc.InvokeAsync<AddResponse>("Calculator.Add", request);
A few more security details:
- The HMAC is compared in constant time and verified before anything is decrypted.
- The HMAC covers the method name and the direction, so an intercepted call cannot be redirected to another method, and a response cannot be sent back as a request.
- A method can require a minimum format; a request in a lower format is rejected before any key is asked for.
How keys are negotiated (exchanged after sign-in, provisioned in advance, and so on) is outside the packages and left to the application.
7️⃣ How it differs from common libraries
There are quite a few JSON-RPC libraries for .NET; here are two common ones for comparison. The version in each column header is the latest stable release on NuGet as of October 2026, and the details come from that release's documentation and NuGet dependency list. Later releases may differ.
| StreamJsonRpc 2.25.29 | Tochka.JsonRpc 7.4.1 | Polhem.JsonRpc 1.2.0 | |
|---|---|---|---|
| JSON library | Newtonsoft.Json by default, switchable to System.Text.Json; the package still depends on Newtonsoft.Json and MessagePack | System.Text.Json | System.Text.Json, with no other dependencies |
| Transport model | Full duplex over a Stream, WebSocket or Pipe | HTTP on ASP.NET Core MVC | HTTP request and response on ASP.NET Core Minimal APIs |
| Client | Yes, with proxies generated from interfaces | Yes | Yes |
| How methods are registered |
AddLocalRpcTarget adds a target object whose public methods become callable |
Controllers that inherit JsonRpcControllerBase; each action is a method |
No registration; found by ProgId.Action and the naming convention |
| Native AOT | Partially supported, within the limits its documentation lists | Tied to MVC, not supported | The shared package, the client and the payload packages support it; the server does not |
| Good fit | Two-way communication, such as Visual Studio extensions or LSP | You already use MVC and want to keep writing controllers | An API where you own both ends, possibly with desktop or mobile clients |
StreamJsonRpc is the most complete, with cancellation, progress reporting and server-to-client calls, but it is designed for streams and full duplex; turning it into an HTTP request/response API means building the endpoint yourself.
Tochka.JsonRpc is the closest in scope to Polhem.JsonRpc. It builds on MVC controllers, so MVC filters and model binding work as they are, at the cost of sharing MVC's lack of AOT support.
Polhem.JsonRpc goes in a different direction: it trades a fixed call shape for "no registration" and "an API that can evolve", as the next section explains.
8️⃣ Design trade-offs
The fixed call shape is there so the API can evolve. The ProgId.Action name, one request class and one response class are a deliberate restriction. When a member is added to a request or response class, a member an older front end does not send binds to its default on the server, and a result member an older front end does not know is simply skipped when read. Old and new front ends call the same method, with no v1 and v2 endpoints to maintain. Positional parameters cannot do this: add, remove or reorder one parameter and every caller has to change.
Defaults follow the specification. The internal error code is -32603, a response carries only jsonrpc, result or error, and id, and id is always written, as null when it cannot be determined. The Polhem framework's original response format differed from the specification in places; when the packages were extracted, it was the framework that changed to follow the specification, not the packages that bent to the old format.
The reasoning behind each decision is recorded as ADRs (Architecture Decision Records) in the repo's maintainers/adr/, and the security design is summarized in docs/security.md.
✅ Wrapping up
Install the server and client packages:
dotnet add package Polhem.JsonRpc.AspNetCore
dotnet add package Polhem.JsonRpc.Client
The repo includes samples: an ASP.NET Core Minimal API server, a console client (ordinary calls, error handling, notifications and batches), and a server and client using encrypted payloads.
These packages do convention-based JSON-RPC, not general-purpose JSON-RPC. Protocols whose method names and parameters are defined by someone else, such as the Language Server Protocol, MCP or an Ethereum node's API (names like textDocument/didOpen and eth_call with positional parameters), cannot be implemented with them; StreamJsonRpc or the MCP C# SDK fit those better. Nor do they offer a full-duplex connection where the server calls the client: one call is one HTTP request and its response.
If you own both ends of your API, want JSON-RPC that relies on System.Text.Json alone, or have clients that run as desktop or mobile apps, give it a try. Feedback, issues and pull requests are welcome.
Further reading
- JSON-RPC 2.0 Specification: the full JSON-RPC 2.0 specification
- StreamJsonRpc: the choice when you need full-duplex streams
- Tochka.JsonRpc: JSON-RPC built on ASP.NET Core MVC controllers
- Native AOT deployment: the limits and compatibility of .NET Native AOT
📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/polhem-jsonrpc
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet
Top comments (0)