Generated client proxies
The client proxy generator is included in the Nerdbank.JsonRpc NuGet package as a C# analyzer. It creates a typed implementation of an interface annotated with JsonRpcContractAttribute. It uses PolyType-generated method shapes for arguments and results. Getting Started shows the contract declaration and how to call Attach on a connection to obtain the proxy; you do not need to construct the generated implementation yourself.
Generated implementations derive from JsonRpcProxy, which provides connection access, capability queries, and resource disposal. The same generated type and factory also support direct local brokered-service calls, including callback isolation and local exception policies, without serializing those calls.
Choosing request return types
For new RPC interfaces whose callers normally await each operation once, prefer ValueTask<TResult> for requests with a result and ValueTask for requests without one. Generated proxies return these awaitables directly rather than converting them to Tasks. Task<TResult> and Task are sensible choices when callers routinely share operations, await them repeatedly, or compose many operations, including in batching-heavy applications. Internal completion-source pooling benefits both return types; any additional savings from ValueTask depend on the request path and should be measured for your workload.
On the .NET 8 and later library targets, the response-processing and notification-submission awaitables are pooled when they suspend. Generated ValueTask-returning proxies expose the same awaitables as the connection APIs; no extra proxy-level async wrapper is needed. Task-returning proxies convert them once to preserve Task semantics, which can require an adapter allocation. Synchronously completed notifications do not need a pooled state machine. Pool reuse and the allocation benefit depend on the workload and consumption pattern.
Consume each returned ValueTask exactly once, normally by awaiting it directly. If you need to await the same operation multiple times, share it among consumers, or use Task.WhenAll, convert it with ValueTask<TResult>.AsTask() once and reuse that Task. Alternatively, call ValueTask<TResult>.Preserve() once and reuse the returned ValueTask. Do not repeatedly await the original ValueTask or repeatedly convert it to a Task: it may be backed by a source that is recycled after consumption.
For batched requests, queue all calls, send the batch, and only then await the saved results. Awaiting directly means consuming an operation once, not necessarily awaiting it immediately. Converting each ValueTask to a Task once is appropriate when using Task.WhenAll to observe all batch operations, including when one fails.
Discarding an awaitable does not cancel its request. Response processing and encoded-payload cleanup still run without application consumption, but a pooled public awaitable that is never consumed cannot return its state machine to the pool; it can be collected when the operation and all references to it have ended. Observe failures and dispose any returned resources as required by their contracts. Do not rely on whether a particular ValueTask is currently Task-backed or pooled: an unconsumed pooled awaitable may lose the opportunity for reuse, while repeated consumption is unsupported and can interfere with a recycled source.
This guidance applies to the ValueTasks returned by the connection and batch request APIs as well as generated proxies. It does not change the wire protocol, and client and server interfaces can use different supported asynchronous return types.
Supported contracts
JsonRpcContractAttribute identifies the RPC contract and enables contract analysis, including detection of colliding wire names.
It replaces the former GenerateJsonRpcProxyAttribute.
Set JsonRpcContractAttribute.GenerateProxy to false to keep these checks without generating a client proxy:
[JsonRpcContract(GenerateProxy = false)]
[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]
public partial interface ITargetOnly
{
ValueTask DisplayMessageAsync(string message, CancellationToken cancellationToken);
}
Generated proxy contracts must be partial, non-nested, non-generic interfaces. Their methods must be non-generic and may return ValueTask<T>, Task<T>, ValueTask, or Task for requests, IAsyncEnumerable<T> for streaming requests, or void for notifications. Generated proxies also implement instance events with synchronous, void-returning delegate types and by-value, non-ref-like parameters, including inherited events.
Attach the proxy and subscribe before starting the connection to avoid losing early notifications; see Events as notifications.
Generated proxies do not implement properties, so omit abstract properties from the contract (or provide default interface implementations).
Parameters may not be params, ref, out, or in. Method parameters and return values must have PolyType shapes so the connection's serializer can encode and decode them. A method may include one CancellationToken, which must be its final parameter and is not serialized as an argument. For requests, cancellation after sending propagates to the remote target using $/cancelRequest. Notifications have no request ID, so their token controls only local argument serialization and enqueueing. See Cancellation.
Optional parameters are supported, including a final cancellation token with = default.
When calling through the interface, the C# compiler supplies omitted arguments using the interface's defaults, and the proxy serializes those values like explicitly supplied arguments.
When an incoming request omits optional arguments, the server uses the defaults from the contract type registered with AddRpcTarget.
Register the interface as the target contract to use its defaults, even if the implementation declares no defaults.
By default, a request using named arguments must name only parameters the target declares, and must supply every parameter that has no default. Set AllowFlexibleNamedArgumentMatching to true when registering a target to ignore unrecognized argument names and to supply default for omitted parameters that have no declared default. Parameters annotated with [ParameterShape(IsRequired = true)] must still be supplied. Positional arguments are unaffected.
The proxy resolves its type-shape provider once and caches the shapes it needs. Event argument binding and dispatch metadata is cached in static fields on the generated proxy class and shared across instances. Each instance retains its own subscriptions and transformed event names; connections and serializers are not captured by the shared metadata. Attach and Attach accept an optional immutable JsonRpcProxyOptions record for per-proxy argument encoding:
ICalculator positionalClient = rpc.Attach<ICalculator>();
Arguments are positional arrays by default; set UseNamedArguments to send named object/map arguments when attaching a proxy. The same generated contract can be attached in either mode, including to a batch. For example, attach ICalculator with the named setting:
ICalculator namedClient = rpc.Attach<ICalculator>(new JsonRpcProxyOptions { UseNamedArguments = true });
Generated proxies stream arguments directly into a counted, disposable JsonRpcArgumentsBuilder, with one cancellation token for the entire argument set. They use the connection's selected serializer.
To implement multiple RPC interfaces with one proxy, define an annotated composite interface and attach that composite type, or declare interface groups as described below. Attach<IBase>() uses only metadata on IBase; it does not search for composite proxies that happen to implement that base interface. Proxies can also be attached to a batch.
By default, generated proxies send requests under a transformed wire name (trailing Async suffix removed, then camelCased); see Method name transforms for how to customize or bypass this.
Testing remote interface availability
Every newly generated proxy implements IClientProxy, for root services, batches, and RPC-marshalable objects alike.
Its IClientProxy.JsonRpc property exposes the original connection, including for batch and RPC-marshalable proxies.
Generated local brokered-service proxies implement the same interface but return null because they invoke their targets directly.
Accessing the connection does not transfer ownership or change the proxy's configured lifetime.
Use Is(Type) or As<T>(IClientProxy?) to query enabled contract interfaces rather than relying on a CLR cast when the generated proxy may expose more interfaces than the remote object supports.
The generator also emits Is(Type) and As<T>() extension methods directly on the contract interfaces and their optional interfaces, so callers need not cast to IClientProxy.
These extensions use proxy capability metadata when present and ordinary CLR type checking for local implementations; null receivers return false or null.
The extension classes are internal to the contract assembly by default, matching StreamJsonRpc and avoiding conflicting extensions from referenced assemblies.
For a shared contract assembly whose consumers need these extensions, set the MSBuild property PublicRpcMarshalableInterfaceExtensions to true when compiling that assembly; accessors for public interfaces defined in that assembly then become public.
Accessors for interfaces imported from referenced assemblies remain internal to avoid ambiguous extension calls in downstream consumers.
Despite the property's name, it applies to both root and marshalable contract interfaces.
The queries are local: they do not send RPC requests, negotiate capabilities, or guard direct method calls.
For a root service, the code that creates the proxy can supply JsonRpcProxyOptions.EnabledInterfaces using capabilities learned out of band. Generate a composite contract or interface groups containing the potentially available interfaces, then list the interfaces enabled for that remote object when attaching it:
IGroupedCalculator client = rpc.Attach<IGroupedCalculator>(new JsonRpcProxyOptions
{
EnabledInterfaces = [],
});
IGroupedCalculator? service = client.As<IGroupedCalculator>(); // Always enabled.
ICalculator? calculator = client.As<ICalculator>(); // Inherited interfaces are also enabled.
IResettableCalculator? resettable = client.As<IResettableCalculator>(); // Null.
bool canReset = client.Is(typeof(IResettableCalculator)); // False.
The primary contract passed to Attach<T> and its inherited interfaces are always enabled; they do not need to appear in the list.
A null list (the default) enables all contract interfaces implemented by the selected proxy; an empty list enables only the primary contract and its inherited interfaces.
Use additional interface groups rather than inheritance for capabilities that may be unavailable on the remote service.
Each listed interface also enables its inherited interfaces, but listing separate base interfaces does not enable a composite derived interface.
An interface the proxy does not implement is never reported as enabled, even if listed.
The options copy the list when assigned, so later changes to the caller's collection do not affect the proxy.
The same options work with generic and non-generic attachment, including Attach.
RPC-marshalable objects use the peer's advertisement for capability queries independently of the generated proxy's CLR interfaces. An explicit enabled-interface list in JsonRpcOptions.MarshaledProxy can restrict those advertised optional capabilities further, but cannot disable the primary contract or its inherited interfaces. Proxies compiled by an older generator, and application-written proxies, must implement the new interface themselves to support these queries.
Discovering root-service capabilities
An application can define an IDiscovery RPC contract to learn the other peer's capabilities before attaching its main service proxy.
This is an application-level protocol, not a built-in discovery request: both peers agree that discovery and the baseline calculator service are always available.
Use stable, versioned capability identifiers rather than assembly-qualified CLR type names so the protocol can evolve independently of implementation details.
[JsonRpcContract]
[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]
public partial interface IDiscovery
{
ValueTask<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken);
}
public static class CapabilityNames
{
public const string Reset = "calculator.reset.v1";
}
This example uses the IGroupedCalculator and IResettableCalculator contracts from Choosing generated interface groups.
The calculator.reset.v1 identifier means that the peer offers the IResettableCalculator contract.
The serving peer registers the baseline service and discovery target, registering and advertising the optional interface only when its service implements it:
public static void StartServer(JsonRpc rpc, IGroupedCalculator calculator)
{
Requires.NotNull(rpc);
Requires.NotNull(calculator);
List<string> capabilities = [];
rpc.AddRpcTarget<IGroupedCalculator>(calculator);
if (calculator is IResettableCalculator resettable)
{
rpc.AddRpcTarget<IResettableCalculator>(resettable);
capabilities.Add(CapabilityNames.Reset);
}
rpc.AddRpcTarget<IDiscovery>(new DiscoveryService(capabilities.ToArray()));
rpc.Start();
}
The discovery target returns the capability list captured during registration:
private sealed class DiscoveryService(string[] capabilities) : IDiscovery
{
public ValueTask<string[]> GetCapabilitiesAsync(CancellationToken cancellationToken) => new(capabilities);
}
The calling peer first attaches a discovery proxy and starts its connection. It maps recognized capability identifiers to local interface types, and attaches a second proxy on the same connection with JsonRpcProxyOptions.EnabledInterfaces. Unknown capability identifiers are ignored, allowing newer peers to advertise features older clients do not understand.
public static async Task<IGroupedCalculator> ConnectAsync(JsonRpc rpc, CancellationToken cancellationToken)
{
Requires.NotNull(rpc);
IDiscovery discovery = rpc.Attach<IDiscovery>();
rpc.Start();
string[] capabilities = await discovery.GetCapabilitiesAsync(cancellationToken);
List<Type> enabledInterfaces = [];
if (capabilities.Contains(CapabilityNames.Reset, StringComparer.Ordinal))
{
enabledInterfaces.Add(typeof(IResettableCalculator));
}
IGroupedCalculator calculator = rpc.Attach<IGroupedCalculator>(new JsonRpcProxyOptions
{
EnabledInterfaces = enabledInterfaces,
});
if (calculator.As<IResettableCalculator>() is { } resettable)
{
await resettable.ResetAsync(cancellationToken);
}
return calculator;
}
Both methods accept a newly constructed, unstarted connection whose lifetime remains owned by the caller. The discovery call must complete before the configured service proxy can be created; cancellation and RPC failures propagate to the caller rather than being treated as an empty capability list. The enabled interfaces are a snapshot for that proxy, so this protocol assumes the advertised capabilities remain valid for the connection's lifetime. Discovery does not provide authorization or prevent direct calls to unadvertised methods. Either peer can use this pattern to discover the other when it exposes the discovery contract. For services with events, account for notifications that may arrive before the second proxy is attached; see Events as notifications.
Choosing generated interface groups
JsonRpcProxyInterfaceGroupAttribute defines the additional interfaces to include alongside the annotated contract in one generated proxy class. It follows StreamJsonRpc's source-generation policy:
- Without group attributes, generate a base-only proxy and, for a marshalable contract with optional interfaces, one proxy implementing all optional interfaces.
- With group attributes, generate only the distinct groups explicitly declared. An empty group explicitly requests a base-only proxy; no full proxy is implicitly added.
[JsonRpcContract]
[JsonRpcProxyInterfaceGroup]
[JsonRpcProxyInterfaceGroup(typeof(IResettableCalculator))]
[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]
public partial interface IGroupedCalculator : ICalculator
{
}
[GenerateShape(IncludeMethods = MethodShapeFlags.PublicInstance)]
public partial interface IResettableCalculator
{
ValueTask ResetAsync(CancellationToken cancellationToken);
}
For marshalable contracts, each additional interface in a group must be declared with RpcMarshalableOptionalInterfaceAttribute. For ordinary root contracts, groups can add other supported RPC interfaces without marshalable annotations. Each additional interface requires generated PolyType method shapes for its argument and result types, as with a composite contract. Grouped methods and events use their owning contract's shape provider, including when that contract is defined in a referenced assembly. When an additional contract uses witness-generated shapes instead of implementing PolyType's shape-provider interface itself, its members use the primary contract's provider; ensure that provider includes the required shapes.
When attaching a root proxy, the enabled-interface list selects the smallest generated group covering the known enabled interfaces, including inherited capabilities; with a null list, the smallest generated group is selected.
When receiving a marshalable object, the advertised optional interfaces select the smallest covering group instead.
A marshalable group containing a derived optional interface also includes its declared optional base interfaces and their wire identifiers.
An exact match is preferred, but a superset is accepted, so is and direct casts can yield false positives; use the generated capability-query extensions.
If no group covers the required interfaces, proxy creation fails with an error identifying the missing group configuration.
This bounds the default output to two proxy classes rather than generating every combination.