English | 繁體中文
JSON-RPC 2.0 for .NET, built on System.Text.Json: a transport-independent server, an ASP.NET Core endpoint and a client, published as separate NuGet packages so an application takes only the part it needs.
It is convention-based JSON-RPC, not a general-purpose one: a method is named ProgId.Action, takes one request class
and returns one response class, and is found by that convention instead of being registered. The reasons and what
it rules out are under Design choices and when not to use it.
The packages are published on nuget.org; the changes of each release are in the CHANGELOG.
| Package | What it contains | Depends on |
|---|---|---|
Polhem.JsonRpc |
Message types, error codes and the transport abstraction shared by the server and the client | .NET only |
Polhem.JsonRpc.Server |
The dispatcher: method resolution, parameter binding, filters and an in-process transport | Polhem.JsonRpc |
Polhem.JsonRpc.AspNetCore |
The ASP.NET Core endpoint (MapJsonRpc) and the HTTP request handler |
Polhem.JsonRpc.Server, ASP.NET Core |
Polhem.JsonRpc.Client |
JsonRpcConnector with an HTTP transport, batches, notifications and request interceptors |
Polhem.JsonRpc |
Polhem.JsonRpc.Payload |
Optional: the payload envelope around params and result, with codecs, gzip, AES-CBC-HMAC encryption and a replay-protection frame |
Polhem.JsonRpc |
Polhem.JsonRpc.Payload.Client |
Optional: PayloadConnector, which seals the parameters of each call and opens its result |
Polhem.JsonRpc.Payload, Polhem.JsonRpc.Client |
Polhem.JsonRpc.Payload.Server |
Optional: the server filter that opens the envelope and checks the frame | Polhem.JsonRpc.Payload, Polhem.JsonRpc.Server |
A server application references Polhem.JsonRpc.AspNetCore (or Polhem.JsonRpc.Server for a host that is not
ASP.NET Core). A client application references Polhem.JsonRpc.Client only. An application that encrypts its payloads
adds Polhem.JsonRpc.Payload.Client on the client and Polhem.JsonRpc.Payload.Server on the server.
The packages do not depend on the Polhem framework. Polhem uses them for its API, with the payload packages for its encryption and compression and its own filters for authorization.
Server (ASP.NET Core):
builder.Services.AddSingleton<IJsonRpcObjectFactory, AppObjectFactory>();
builder.Services.AddJsonRpcServer();
app.MapJsonRpc("/api");
// "Calculator.Add": the ProgId "Calculator" names the object, the action "Add" the method.
public sealed class AppObjectFactory : IJsonRpcObjectFactory
{
public object? CreateObject(string progId, JsonRpcRequestContext context) => progId switch
{
"Calculator" => new Calculator(),
_ => null,
};
}
public sealed class Calculator
{
// Callable because the parameter is AddRequest and the result AddResponse.
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 });A method name has the form ProgId.Action, for example Calculator.Add. No method is registered: the server finds
it by these rules.
| Step | Rule |
|---|---|
| Name | ProgId.Action. A ProgId holds letters, digits, underscores and hyphens; an action letters, digits and underscores; each at most 64 characters. The action is matched case-sensitively; the ProgId is passed to the factory as written. |
| Object | The application's IJsonRpcObjectFactory.CreateObject(progId, context) creates the object the call runs on. null means the ProgId is unknown. After the call the object is passed to ReleaseObjectAsync. |
| Action | A public, non-generic instance method of that object with exactly one parameter. A name with more than one such method is ambiguous and not found. |
| Convention | The method is callable only when its parameter type is named {Action}Request and its return type, or the result of the Task<T> / ValueTask<T> it returns, {Action}Response: AddResponse Add(AddRequest request). Any other public method is answered as if it did not exist. Replace the rule with JsonRpcServerOptions.MethodPolicy. |
| Parameters | params must be a JSON object, deserialized into the request (camelCase names by default). An absent params or an array is answered with -32602 Invalid params. |
| Result | The response object becomes result. |
| Id | A string, an integer or null, echoed as it came. A number with a fraction or an exponent (1.0, 1e2), or one beyond a 64-bit integer, is answered with -32600 Invalid Request and a null id, because the specification discourages them; send integers or strings. |
| Errors | An unknown name, object or action is answered with -32601 Method not found. Throw JsonRpcErrorException to answer with your own code and message; any other exception is answered with -32603 Internal error, without its message. |
The fixed shape (a ProgId.Action name, one request class in, one response class out) is deliberate: it lets an API
change without breaking the clients already deployed. Adding a member to a request or a response class does not
break a client built before it. A member the client does not send binds its default on the server, and a result
member the client does not know is skipped. Old and new front ends keep calling the same method, with no v1 and
v2 endpoints to maintain. This holds with the default System.Text.Json settings; setting
JsonUnmappedMemberHandling.Disallow or RespectRequiredConstructorParameters turns it off.
The same choice rules out protocols whose method names and parameters are defined by someone else. The packages do
not implement the Language Server Protocol, the Model Context Protocol or an Ethereum node's API, which use names like
textDocument/didOpen or eth_call and positional parameters; StreamJsonRpc
or the MCP C# SDK fit those. Nor do they offer a full-duplex
connection in which the server calls the client: a call is an HTTP request and its response.
| Sample | What it shows |
|---|---|
| QuickStart.Server | A server on ASP.NET Core minimal APIs |
| QuickStart.Client | Calls, errors, notifications and batches from a console application |
| QuickStart.Contracts | The request and response classes both sides share |
| PayloadQuickStart.Server | Encrypted parameters and results with replay protection, on the server |
| PayloadQuickStart.Client | The same from a console application with PayloadConnector, including a repeated sequence number refused |
A filter runs around every call on the server. It can reject the call by throwing JsonRpcErrorException,
rewrite context.Request.Params before next, and rewrite context.Result after it:
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)));On the client, HTTP headers belong to the HttpClient: add them with a DelegatingHandler. To rewrite parameters
and results on every call, add an IJsonRpcClientInterceptor to JsonRpcClientOptions.Interceptors.
public sealed class ApiKeyHandler(string key) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Add("X-Api-Key", key);
return base.SendAsync(request, cancellationToken);
}
}
using var http = new HttpClient(new ApiKeyHandler(apiKey) { InnerHandler = new HttpClientHandler() }) { BaseAddress = endpoint };HTTPS protects the connection. When parameters and results must also be protected end to end, or a call must not be
replayable, add the optional payload packages: Polhem.JsonRpc.Payload.Server on the server and
Polhem.JsonRpc.Payload.Client on the client. They carry params and result in an envelope that is plain JSON, encoded
(a codec, then gzip) or encrypted (AES-256-CBC with HMAC-SHA256), with an optional frame that rejects a repeated
sequence number.
// Server: the application supplies the key and the replay rules.
builder.Services.AddJsonRpcServer(options => options.UsePayload(payloadOptions, new MyPayloadPolicy()));
// Client: called like JsonRpcConnector; each call is sealed and its result opened.
var rpc = new PayloadConnector(connector, new PayloadProcessor(payloadOptions), new PayloadConnectorOptions { KeyProvider = () => sessionKey });
var added = await rpc.InvokeAsync<AddResponse>("Calculator.Add", request);Both ends share the same PayloadOptions settings; how the key is agreed is up to the application. A client that
seals by hand calls PayloadProcessor.WrapRequest and UnwrapResult around JsonRpcConnector instead. The
PayloadQuickStart samples run it end to end, and
ADR-002 describes the format, which other clients can implement.
What the packages do against hostile callers and networks, what is left to the application, and the known limits are in docs/security.md.
The reasons behind the package split and the main design choices are recorded in ADR-001, and those of the payload packages in ADR-002.
See CONTRIBUTING.md.