Modern, lightweight JSON-RPC 2.0, Model Context Protocol (MCP), and Agent2Agent (A2A) implementations for .NET 8.0 and .NET 10.0
Voltaic gives .NET applications a small, direct way to expose and consume structured agent protocols. Use it when you need JSON-RPC 2.0, MCP tools/resources/prompts, or A2A agents without adopting a larger application framework.
Voltaic v2.3.0 recognizes five MCP protocol revisions (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and the stateless 2026-07-28) and targets A2A protocol version 1.0. The public API and source tree are split into Voltaic.Core, Voltaic.Mcp, and Voltaic.A2A.
An initialize handshake negotiates at most the newest handshake-era revision, 2025-11-25 (configurable with MaximumHandshakeProtocolVersion), because the stateless 2026-07-28 revision defines no initialize and no sessions. Clients reach 2026-07-28 through the stateless request path instead: server/discover followed by per-request MCP-Protocol-Version, Mcp-Method, and _meta signals. That is how current clients such as Claude Code 2.1.x connect, and Voltaic's stateless responses carry the resultType, ttlMs, and cacheScope fields the revision requires. Version selection is driven by the McpProtocol registry and McpVersionResolver, and it behaves identically with or without an AuthenticationHandler. See Protocol version negotiation.
Upgrading? See Upgrading for the changes most likely to matter, UPGRADING.md for every behavior change by version, and CHANGELOG.md for full history.
Voltaic is a protocol library, not an application framework. It provides:
- JSON-RPC 2.0 clients and servers over TCP with LSP-style
Content-Lengthframing (MCP over TCP also accepts newline-delimited JSON) - MCP stdio servers and clients for subprocess-hosted tools
- MCP Streamable HTTP on
/mcpwithMCP-Session-Idsessions and SSE notifications - MCP TCP and WebSocket transports for networked or full-duplex scenarios
- A2A Agent Card discovery, JSON-RPC, HTTP+JSON, gRPC, SSE streaming, task lifecycle, push notification config APIs, and extended Agent Cards
- Shared request/response, notification, lifecycle, and event handling across transports
You bring your business logic. Voltaic handles the protocol surface, message framing, method dispatch, session headers, and transport-specific plumbing.
- Register JSON-RPC methods with synchronous, asynchronous, or cancellation-aware handlers.
- Register MCP tools with input schema metadata, output schema metadata, structured content, annotations, icons, and full
McpToolCallResultreturns. - Validate tool input and structured output against JSON Schema 2020-12 or draft-07 in the declared dialect, with
patternrun on a built-in ECMA-262 regular expression engine; malformed schemas are rejected at registration. - Expose MCP resources, resource templates, prompts, and completion providers.
- Handle MCP
initialize,tools/list,tools/call,resources/*,prompts/*,completion/complete,logging/setLevel, and utility notifications. - Send list-changed, resource-updated, progress, cancellation, and log-message notifications where the transport supports server-to-client notifications.
- Serve
subscriptions/listenfor2026-07-28clients, and bound every transport withMaxMessageSize, per-clientRateLimits, and (on HTTP)MaxSessions/MaxSessionsPerClient. - Host Voltaic's own request/response endpoints (
/rpcand/events) alongside the Streamable HTTP endpoint (/mcp). They are a Voltaic convenience, not the deprecated 2024-11-05 HTTP+SSE transport. - Expose and consume A2A agents through dependency-light
A2AClient,A2AHttpJsonClient,A2AGrpcClient,A2AHttpServer, andA2AGrpcServerclasses without ASP.NET Core. - Answer MCP requests from handshake-era and stateless
2026-07-28clients on the same server, on every transport, including Multi Round-Trip input requests from tool handlers. - Emit OpenTelemetry-compatible metrics and traces for every server, client, tool call, session, limiter, A2A agent run, and push delivery on the
Voltaicmeter and activity source, with W3C trace propagation across every transport and no new dependencies. See Observability. - Run in Native AOT and trimmed applications on .NET 8 and .NET 10: Voltaic serializes from source-generated metadata, and your own types join through
VoltaicJson.AddTypeInfoResolver. See Native AOT and trimming. - Run the same 857-case Touchstone suite through console, xUnit, and NUnit projects under
src/, plus a Native AOT smoke test (src/Test.Aot) across every transport.
MCP uses JSON-RPC method names for protocol endpoints. For Streamable HTTP, those JSON-RPC messages are sent through the HTTP /mcp endpoint. /rpc (plain request/response JSON-RPC) and /events (an SSE stream for a session opened on /rpc) are Voltaic's own endpoints for simple clients and scripts. They are not the 2024-11-05 HTTP+SSE transport: /events sends no endpoint event, and results come back in the POST response.
Every MCP connection starts with the lifecycle methods:
initialize- required first request for handshake-era revisions. The client sends its supported protocol version, capabilities, and client info; the server responds with the negotiated protocol version, capabilities, and server info. See Protocol version negotiation for the version the server answers with.notifications/initialized- required client notification after successful initialization. Normal operation starts after this notification.
Base utility methods:
ping- utility request that either side may send to check liveness. A receiver must respond promptly when it receives one. Voltaic MCP servers answer with the empty result{}.2026-07-28removedping; a stateless request for it gets-32601.
Streamable HTTP transport requirements:
POST /mcpreceives JSON-RPC requests and notifications.GET /mcpopens the SSE stream when the client wants server notifications.- A successful
initializecreates the session and returnsMCP-Session-Id; clients send it on every later request. A request without it (other thaninitialize) gets 400, and an ID the server did not issue, has expired, or was terminated gets 404, which tells the client to re-initialize. See Sessions. - After initialization, HTTP clients send
MCP-Protocol-Versionon subsequent requests. When the header is absent and the session has no negotiated version, the server assumes2025-03-26, as the specification requires. - A JSON-RPC response or error POSTed by the client is accepted with
202and no body.
Server feature endpoints are capability-driven. If your server advertises a capability, it must support the corresponding methods:
toolscapability:tools/listandtools/call.resourcescapability:resources/listandresources/read. If resource templates are exposed, also supportresources/templates/list. Ifresources.subscribeis advertised, also supportresources/subscribe,resources/unsubscribe, andnotifications/resources/updated.promptscapability:prompts/listandprompts/get.completionscapability:completion/completefor prompt argument or resource-template argument suggestions.loggingcapability:logging/setLevelfrom client to server, plus servernotifications/messagewhen logs are emitted.
Voltaic always registers the protocol methods for its MCP server types, including ping. Your application registers the handlers and data behind those methods with RegisterTool, RegisterResource, RegisterResourceTemplate, RegisterPrompt, and RegisterCompletionProvider.
An MCP server publishes only the tools your application registers. tools/list shows exactly those tools, so an AI client's model sees only your product's tools.
- Diagnostic tools are opt-in. Pass
includeDiagnosticTools: trueto a server constructor to also publishechoandgetTime. They are useful while developing a server and are off by default. Voltaic no longer ships agetSessions/getClientstool, because it disclosed other clients' session identifiers. - Tools are invoked only through
tools/call.tools/callvalidates arguments against the tool's input schema before your handler runs. A tool is not callable as a bare JSON-RPC method, so the schema check cannot be skipped. A tool may share a name with a protocol method (for example, a tool namedping) without replacing it. - Tools can be removed.
UnregisterTool(name)removes a tool and returnstrueif it existed. Registering or unregistering a tool, or registering a resource, resource template, or prompt, sendsnotifications/tools/list_changed(or the resources and prompts equivalent) to clients that completedinitialize;NotifyToolsChanged()(HTTP) andNotifyToolsChangedAsync()(stdio, TCP, WebSocket) send it on demand, for example when a tool's behavior changes.
McpHttpServer server = new McpHttpServer("localhost", 8080); // only your tools
McpHttpServer devServer = new McpHttpServer("localhost", 8081, includeDiagnosticTools: true); // plus echo and getTime
server.RegisterTool("lookup", "Looks up a record", schema, handler);
server.UnregisterTool("lookup");
server.NotifyToolsChanged();Tool input schemas are enforced for type, required, nested properties, patternProperties, and additionalProperties. With additionalProperties: false, a tools/call that sends an argument the schema does not declare gets a tool result with isError: true and a message naming the property; the handler does not run.
A handler that throws is reported as a tool execution error (a result with isError: true), as the specification prescribes for API and business-logic failures, so the model can react. Because tool outputs must be sanitized, the text is a generic Tool '<name>' failed because of an internal error. by default, and the exception type and message go to the server's Log event. To tell the model what went wrong, throw McpToolException("City 'Atlantis' was not found."): its message is used as the result text. Set IncludeToolExceptionMessages = true to send Tool '<name>' failed: <message> for every exception (only when messages never contain secrets). To send a JSON-RPC protocol error instead, throw McpProtocolException (for example McpProtocolException.InvalidParams(...)). A result that violates the tool's own outputSchema is a server fault and gets -32603. Cancellation of the request still propagates.
A tool can ask stateless HTTP clients to mirror an argument into an Mcp-Param-{Name} header, so gateways can route on it without parsing the body. Add x-mcp-header to the property's schema:
server.RegisterTool("execute_sql", "Runs SQL in a region", JsonDocument.Parse("""
{
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" },
"query": { "type": "string" }
},
"required": ["region", "query"]
}
""").RootElement, handler);RegisterTool rejects annotations the specification forbids: on anything other than a string, integer, or boolean property (a nullable form such as ["string", "null"] is allowed); on a property not reachable from the root through properties alone (inside items, oneOf, $defs, and so on); duplicated case-insensitively; empty or not an HTTP token; or on an integer whose bounds exceed the JavaScript safe range. On a stateless tools/call, McpHttpServer requires the header whenever the argument has a value, decodes =?base64?...?= values, compares integers numerically, and answers a missing, extra, malformed, or different header, or an integer outside the JavaScript safe range, with HTTP 400 and -32020 (HeaderMismatch) before the tool runs. An argument of the wrong type is left to input validation (an isError result). A malformed Mcp-Name value is also rejected with -32020. Handshake-era requests and non-HTTP transports ignore the annotation. McpHttpClient sends the headers automatically (see MCP Client (Streamable HTTP)).
MCP revisions fall into two eras. The handshake era (2024-11-05 through 2025-11-25) opens every connection with initialize and, on Streamable HTTP, an MCP-Session-Id. The stateless era (2026-07-28) has no initialize and no sessions: each request carries its own version in the MCP-Protocol-Version header and _meta, plus an Mcp-Method routing header, and clients learn what the server offers from server/discover.
Because initialize belongs to the handshake era, every Voltaic MCP server (HTTP, stdio, TCP, and WebSocket) answers it as follows:
Client requests in initialize |
Server answers |
|---|---|
| No version | -32602 (protocolVersion is required) |
| A handshake-era version at or below the cap | That version |
| A handshake-era version above the cap | The cap |
A stateless-era version (2026-07-28) |
The cap |
| An unknown version (older, newer, or malformed) | The cap, as the specification requires ("respond with another protocol version it supports"); the client disconnects if it cannot use it |
The cap is MaximumHandshakeProtocolVersion. It defaults to McpProtocol.NewestHandshakeProtocolVersion (2025-11-25), accepts only handshake-era revisions, and throws ArgumentException for anything else. Lower it to pin clients to an older revision:
McpHttpServer server = new McpHttpServer("localhost", 8080);
server.MaximumHandshakeProtocolVersion = McpProtocol.ProtocolVersion20250618;Every Voltaic MCP server also serves 2026-07-28 without a handshake. On McpHttpServer that is the stateless HTTP path. On stdio, TCP, and WebSocket, a request is treated as 2026-07-28 when its params._meta["io.modelcontextprotocol/protocolVersion"] names that revision; server/discover is available, and an unknown version in _meta gets -32022 with the supported list. Requests without that _meta field, and initialize, keep the handshake-era behavior, so one server serves both eras, deciding per request. Under that revision every result carries resultType: complete for a final result or input_required for a Multi Round-Trip result (task belongs to the tasks extension, which Voltaic does not implement). Cacheable results (tools/list, resources/list, resources/templates/list, prompts/list, resources/read, and server/discover) also carry ttlMs and cacheScope. Voltaic fills these in on the way out, on the serialized response, so a result object a handler shares between requests is never changed, and results of handlers registered with RegisterMethod for those methods get them too. Values a handler sets itself are kept, as are ListCacheTtlMs and ListCacheScope when configured. Otherwise the defaults are ttlMs: 0 and cacheScope: "private", which mean "do not cache; the result is specific to the caller". Handshake-era responses never include these fields.
Under 2026-07-28, change notifications are delivered through subscriptions/listen, which every Voltaic server serves on every transport, and server/discover advertises listChanged and resources.subscribe. The request opens a long-lived stream: the server first sends notifications/subscriptions/acknowledged with the types it agreed to send (toolsListChanged, promptsListChanged, resourcesListChanged, and resourceSubscriptions), then only those notifications, each carrying the subscription ID (the request's JSON-RPC ID) in _meta["io.modelcontextprotocol/subscriptionId"]. The client ends a subscription by cancelling it (notifications/cancelled on stdio, TCP, and WebSocket, or closing the HTTP response stream); a server that stops answers each open subscription with a completion result first. McpHttpClient.ListenAsync(filter, token) opens one and raises the acknowledgment and notifications through NotificationReceived:
Task<JsonRpcResponse> listening = client.ListenAsync(new McpSubscriptionFilter { ToolsListChanged = true, ResourceSubscriptions = new List<string> { "file:///project/config.json" } }, stop.Token);Handshake-era sessions receive change notifications as before.
A method you register yourself with RegisterMethod gets the same treatment when it returns an McpResult subclass (for example McpEmptyResult or McpToolCallResult). Any other object result, such as an anonymous type, also gets resultType and the server identity in _meta. A result must be a JSON object on every revision: a handler that returns a string, number, or array produces -32603, and one that returns null answers {}.
A tool handler reports progress and log entries through McpToolCallContext.Current, and they reach only the client that made the call:
server.RegisterTool("import", "Imports files", new { type = "object" }, async (RpcParameters? args, CancellationToken token) =>
{
McpToolCallContext call = McpToolCallContext.Current!;
for (int i = 1; i <= 10; i++)
{
await ImportBatchAsync(i, token);
await call.ReportProgressAsync(i, 10, $"batch {i} of 10", token);
}
await call.LogAsync("info", new { imported = 10 }, "import", token);
return "done";
});Progress is sent only when the request carried a progressToken, and each value must be larger than the last. Log entries are sent when they meet the level the client set with logging/setLevel (all entries until it sets one; or, on 2026-07-28, the io.modelcontextprotocol/logLevel in the request's _meta; without it no log entries are sent). Over Streamable HTTP, the POST response becomes an SSE stream when the first notification is sent. A cancelled request (notifications/cancelled, or a closed 2026-07-28 response stream) cancels token.
Under 2026-07-28 a tool can ask the client for more input, such as a confirmation, instead of finishing. The handler returns McpInputRequiredResult; the client gathers the answers and calls the tool again with inputResponses and the requestState the handler returned. McpToolCallContext.Current gives the handler the current call:
| Member | Meaning |
|---|---|
ToolName |
The tool being called |
InputResponses |
The client's answers, keyed like the handler's InputRequests (empty on the first call) |
RequestState |
The state the handler returned with its input request, echoed by the client (null on the first call) |
IsRetry |
True when the call carries input responses or request state |
CanRequestInput |
True when the request uses 2026-07-28, so an McpInputRequiredResult can reach the client |
server.RegisterTool("delete_file", "Deletes a file after confirmation", schema, args =>
{
McpToolCallContext call = McpToolCallContext.Current!;
string path = args?.GetString("path") ?? "";
// The answer counts only for the request this call made: the state must name this path, and the answer must
// be an explicit accept. requestState comes back from the client, so it is never proof of anything by itself.
if (call.RequestState == "delete:" + path
&& call.InputResponses.TryGetValue("confirm", out JsonElement answer)
&& answer.ValueKind == JsonValueKind.Object
&& answer.TryGetProperty("action", out JsonElement action)
&& action.GetString() == "accept")
{
File.Delete(path);
return McpToolCallResult.FromText($"Deleted {path}.");
}
if (!call.CanRequestInput)
{
McpToolCallResult unsupported = McpToolCallResult.FromText("Confirmation needs MCP 2026-07-28.");
unsupported.IsError = true;
return unsupported;
}
return new McpInputRequiredResult
{
InputRequests = new Dictionary<string, McpInputRequest>
{
["confirm"] = new McpInputRequest
{
Method = "elicitation/create",
Params = new { mode = "form", message = $"Delete {path}?", requestedSchema = new { type = "object", properties = new { } } }
}
},
RequestState = "delete:" + path
};
});requestState and inputResponses are supplied by the client on the retry: the client can change them, replay them, or send them without a prior input request. Treat both as untrusted input. Do not put secrets or authorization decisions in requestState; if the handler depends on it being the value it issued, protect its integrity (for example with an HMAC over the state and the call's arguments, or a server-side lookup keyed by a random token), and re-check the caller's permissions on every call.
Handshake-era clients cannot receive an input request. If a handler returns McpInputRequiredResult for one anyway, Voltaic answers with a tool result with isError: true explaining that the tool needs 2026-07-28, rather than sending a result the client cannot parse. McpToolCallContext.Current is null outside a tool call and is scoped to the call, including its awaited continuations. McpHttpClient.CallToolStatelessAsync drives the client side of the exchange; declare the kinds of input it can answer first (for example client.ClientCapabilities["elicitation"] = new { };), because a server may request only declared kinds and answers -32021 otherwise.
A2A support lives in the Voltaic.A2A namespace. It follows the A2A v1.0 JSON wire shape used by the official a2a-dotnet SDK while keeping Voltaic's direct, dependency-light style.
- Public Agent Card discovery:
GET /.well-known/agent-card.json. - Version header sent by Voltaic clients:
A2A-Version: 1.0. - JSON-RPC endpoint: configurable, default
/a2a. - JSON-RPC methods:
SendMessage,SendStreamingMessage,GetTask,ListTasks,CancelTask,SubscribeToTask, push notification config CRUD (CreateTaskPushNotificationConfig,GetTaskPushNotificationConfig,ListTaskPushNotificationConfigs,DeleteTaskPushNotificationConfig; the older singularListTaskPushNotificationConfigis also accepted), andGetExtendedAgentCard. - HTTP+JSON routes:
POST /message:send,POST /message:stream,GET /tasks/{id},GET /tasks,POST /tasks/{id}:cancel,POST /tasks/{id}:subscribe,/tasks/{id}/pushNotificationConfigs, andGET /extendedAgentCard. - gRPC service:
lf.a2a.v1.A2AServiceover HTTP/2, with unary and server-streaming RPCs matching the A2A v1 service shape. - Streaming methods use SSE with
data:events. JSON-RPC streaming sends JSON-RPC response envelopes; HTTP+JSON streaming sends directStreamResponsepayloads.
A2AHttpServer is built on HttpListener, not ASP.NET Core. It hosts Agent Card discovery, JSON-RPC, HTTP+JSON, SSE streams, task projection, in-memory task storage, push notification delivery, CORS, and an optional authentication hook. Applications provide agent behavior through IA2AAgentHandler.
A2AGrpcServer is built on the Watson HTTP/2 server package, not ASP.NET Core. It applies the same origin and loopback checks as A2AHttpServer, serves only the public Agent Card without authentication, and reports internal errors to clients generically (details go to its Log event). Its push notification settings (PushNotificationUrlValidator, PushNotificationTimeoutMs, PushNotificationMaxAttempts) match A2AHttpServer. A2AGrpcClient uses plain HttpClient with gRPC framing and protobuf messages.
A host name of localhost makes A2AGrpcServer listen on both 127.0.0.1 and ::1 (and */+ on both 0.0.0.0 and ::). HttpClient and most other clients try ::1 first for localhost; with only an IPv4 listener each new connection would wait for the refused IPv6 attempt, about two seconds on Windows. The IPv6 listener is best effort: if it cannot bind, the server logs that and serves IPv4 only. An explicit address such as 127.0.0.1 gets that address only, so point clients at the same address.
JSON-RPC errors are returned with HTTP 200 and the JSON-RPC error body, the JSON-RPC-over-HTTP convention. A2AClient reads the JSON-RPC error body whatever the HTTP status, so it also works with servers that send 4xx. HTTP+JSON errors are a google.rpc.Status object, as A2A v1.0 specifies, with the A2A status mapping (404 for task not found, 500 for internal errors, 400 otherwise):
{"error":{"code":404,"status":"NOT_FOUND","message":"Task not found","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"TASK_NOT_FOUND","domain":"a2a-protocol.org","metadata":{}}]}}A2AHttpJsonClient maps the ErrorInfo reason back to the A2AErrorCode, and still reads the JSON-RPC-style error body that Voltaic 2.1.2 and earlier servers sent.
When the Agent Card advertises Capabilities.PushNotifications = true, A2AHttpServer (and A2AGrpcServer, which shares its task engine) delivers every event of a task to each webhook registered for it, either through the push notification config operations or through SendMessageConfiguration.PushNotificationConfig (taskPushNotificationConfig on the wire). Each delivery is an HTTP POST with a StreamResponse body (Content-Type: application/a2a+json), Authorization: {scheme} {credentials} from the config's authentication, and X-A2A-Notification-Token when the config has a token. Deliveries to one webhook are sent in order, time out after PushNotificationTimeoutMs (default 10 seconds), and are retried with exponential backoff up to PushNotificationMaxAttempts (default 3).
Push notification configs use the flat A2A v1.0 shape on the wire (id, taskId, url, token, authentication, and tenant when set), and get/delete requests name the configuration with id. Voltaic also reads the nested pushNotificationConfig/config objects and the configId field that 2.1.2 and earlier wrote.
Webhook URLs are protected against server-side request forgery, as the A2A specification recommends. By default a URL must be http or https without user information, must not name localhost or a loopback, private, link-local, or carrier-grade NAT address, and is re-checked when the server connects, so a host name that resolves only to such addresses (including through DNS rebinding) is refused. Redirects are not followed and no proxy is used. Replace the policy with PushNotificationUrlValidator, for example to allow a list of webhook hosts, or loopback during local development:
server.PushNotificationUrlValidator = uri => uri.Host == "hooks.example.com" || uri.IsLoopback;- Small API surface: Register handlers and start a transport; avoid framework-level ceremony.
- Current MCP coverage: Tools, resources, prompts, completions, Streamable HTTP, sessions, and utility notifications are first-class.
- A2A coverage: Agent Cards, JSON-RPC, HTTP+JSON, gRPC, task lifecycle, streaming, push config APIs, and extended Agent Cards are first-class.
- Transport choice: Use stdio for local MCP servers, Streamable HTTP for MCP clients and inspectors, TCP for service-to-service RPC, WebSockets for full-duplex web-facing systems, or HTTP/SSE/gRPC for A2A agents.
- Plain .NET: Works with normal C# delegates,
System.Text.Json,Task,CancellationToken, andIDisposable. - Testable behavior: Protocol behavior is covered by shared Touchstone descriptors and adapter-backed test projects.
Voltaic is designed for developers building:
- AI assistant integrations that need to expose MCP tools, resources, prompts, or completions from .NET.
- Services that need structured JSON-RPC calls, REST-style HTTP+JSON routes, or a small gRPC binding without adopting a large hosting framework.
- Local agents, CLIs, and desktop tools that launch MCP subprocesses over stdio.
- Language-server-style protocols that use
Content-Lengthmessage framing. - Web integrations that need Streamable HTTP, SSE notifications, or WebSocket communication.
- Libraries and products that need protocol primitives without handing control to a large host framework.
dotnet add package Voltaicusing Voltaic.A2A;
string baseUrl = "http://localhost:8080";
AgentCard card = new AgentCard
{
Name = "Echo Agent",
Description = "A simple Voltaic A2A agent.",
Version = "1.0.0",
SupportedInterfaces = new List<AgentInterface>
{
new AgentInterface { Url = baseUrl + "/a2a", ProtocolBinding = "JSONRPC" },
new AgentInterface { Url = baseUrl, ProtocolBinding = "HTTP+JSON" },
new AgentInterface { Url = "http://localhost:8081", ProtocolBinding = "GRPC" }
},
Capabilities = new AgentCapabilities
{
Streaming = true,
PushNotifications = true,
StateTransitionHistory = true,
ExtendedAgentCard = true
},
Skills = new List<AgentSkill>
{
new AgentSkill { Id = "echo", Name = "Echo", Description = "Echoes text." }
},
DefaultInputModes = new List<string> { "text/plain" },
DefaultOutputModes = new List<string> { "text/plain" }
};
using A2AHttpServer server = new A2AHttpServer("localhost", 8080, card, new EchoAgent())
{
ExtendedAgentCard = card
};
using A2AGrpcServer grpcServer = new A2AGrpcServer("localhost", 8081, card, new EchoAgent())
{
ExtendedAgentCard = card
};
await server.StartAsync();
await grpcServer.StartAsync();
Console.WriteLine("A2A server listening on http://localhost:8080");
Console.WriteLine("A2A gRPC listening on http://localhost:8081");
await Task.Delay(Timeout.Infinite);
sealed class EchoAgent : IA2AAgentHandler
{
public async Task ExecuteAsync(A2ARequestContext context, A2AAgentEventQueue eventQueue, CancellationToken token)
{
A2ATaskUpdater updater = new A2ATaskUpdater(eventQueue, context.TaskId, context.ContextId);
await updater.SubmitAsync(token: token);
await updater.StartAsync(token: token);
string text = context.Message.Parts.FirstOrDefault()?.Text ?? string.Empty;
Message response = new Message
{
Role = Role.Agent,
MessageId = Guid.NewGuid().ToString("N"),
TaskId = context.TaskId,
ContextId = context.ContextId,
Parts = new List<Part> { Part.FromText("echo: " + text) }
};
await updater.CompleteAsync(response, token);
}
}using Voltaic.A2A;
using HttpClient http = new HttpClient();
using A2ACardResolver resolver = new A2ACardResolver(http);
AgentCard card = await resolver.GetAgentCardAsync("http://localhost:8080");
AgentInterface jsonRpc = card.SupportedInterfaces.First(item => item.ProtocolBinding == "JSONRPC");
using A2AClient client = new A2AClient(jsonRpc.Url, http);
SendMessageRequest request = new SendMessageRequest
{
Message = new Message
{
Role = Role.User,
MessageId = Guid.NewGuid().ToString("N"),
Parts = new List<Part> { Part.FromText("hello") }
}
};
SendMessageResponse response = await client.SendMessageAsync(request);
Console.WriteLine(response.Task?.Status.State);
await foreach (StreamResponse item in client.SendStreamingMessageAsync(request))
{
Console.WriteLine(item.StatusUpdate?.Status.State);
}
AgentInterface httpJson = card.SupportedInterfaces.First(item => item.ProtocolBinding == "HTTP+JSON");
using A2AHttpJsonClient restClient = new A2AHttpJsonClient(httpJson.Url, http);
SendMessageResponse restResponse = await restClient.SendMessageAsync(request);
Console.WriteLine(restResponse.Task?.Id);
AgentInterface? grpc = card.SupportedInterfaces.FirstOrDefault(item => item.ProtocolBinding == "GRPC");
if (grpc != null)
{
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
using HttpClient grpcHttp = new HttpClient(new SocketsHttpHandler { EnableMultipleHttp2Connections = true });
using A2AGrpcClient grpcClient = new A2AGrpcClient(grpc.Url, grpcHttp);
SendMessageResponse grpcResponse = await grpcClient.SendMessageAsync(request);
Console.WriteLine(grpcResponse.Task?.Id);
}This example creates a small MCP HTTP server with a tool, resource, resource template, prompt, and completion provider. The client connects with McpHttpClient, performs the MCP initialization flow, and calls the endpoint families those handlers power.
Create the server:
dotnet new console -n CalculatorServer
cd CalculatorServer
dotnet add package VoltaicReplace Program.cs:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Voltaic.Core;
using Voltaic.Mcp;
using McpHttpServer server = new McpHttpServer("localhost", 8080)
{
ServerName = "CalculatorServer",
ServerVersion = "1.0.0"
};
server.RegisterTool(
"add",
"Adds two numbers",
new
{
type = "object",
properties = new
{
a = new { type = "number", description = "First number" },
b = new { type = "number", description = "Second number" }
},
required = new[] { "a", "b" }
},
(RpcParameters? args) =>
{
double a = args?.GetDouble("a") ?? 0;
double b = args?.GetDouble("b") ?? 0;
return (object)(a + b);
});
server.RegisterResource(
"voltaic://calculator/status",
"status",
"text/plain",
() => new McpReadResourceResult
{
Contents = new List<object>
{
new McpTextResourceContents
{
Uri = "voltaic://calculator/status",
MimeType = "text/plain",
Text = "Calculator server is running."
}
}
});
server.RegisterResourceTemplate(
"voltaic://calculator/help/{topic}",
"help-topic",
"text/plain",
uri => new McpReadResourceResult
{
Contents = new List<object>
{
new McpTextResourceContents
{
Uri = uri,
MimeType = "text/plain",
Text = $"Help content for {uri}."
}
}
});
server.RegisterPrompt(
"explain",
"Creates an explanation prompt",
new[]
{
new McpPromptArgument
{
Name = "topic",
Description = "Topic to explain",
Required = true
}
},
args =>
{
string topic = args?.GetString("topic") ?? "the topic";
return new McpGetPromptResult
{
Messages = new List<McpPromptMessage>
{
new McpPromptMessage
{
Role = "user",
Content = new McpTextContent
{
Text = $"Explain {topic} with a calculator example."
}
}
}
};
});
server.RegisterCompletionProvider(
"ref/prompt",
"explain",
"topic",
(request, token) => Task.FromResult(new McpCompleteResult
{
Completion = new McpCompletion
{
Values = new List<string> { "addition", "subtraction", "multiplication", "division" }
.Where(value => value.StartsWith(request.Argument.Value, StringComparison.OrdinalIgnoreCase))
.Take(100)
.ToList()
}
}));
await server.StartAsync();
Console.WriteLine("MCP server listening at http://localhost:8080/mcp");
await Task.Delay(Timeout.Infinite);Run it:
dotnet runCreate the client in a second terminal:
dotnet new console -n CalculatorClient
cd CalculatorClient
dotnet add package VoltaicReplace Program.cs:
using Voltaic.Core;
using Voltaic.Mcp;
using McpHttpClient client = new McpHttpClient();
client.ClientName = "CalculatorClient";
client.ClientVersion = "1.0.0";
// Sends initialize and notifications/initialized, and stores the session.
await client.ConnectStreamableAsync("http://localhost:8080");
JsonRpcResponse tools = await client.CallAsync("tools/list");
Console.WriteLine(tools.Result);
JsonRpcResponse resources = await client.CallAsync("resources/list");
Console.WriteLine(resources.Result);
JsonRpcResponse templates = await client.CallAsync("resources/templates/list");
Console.WriteLine(templates.Result);
JsonRpcResponse prompts = await client.CallAsync("prompts/list");
Console.WriteLine(prompts.Result);
JsonRpcResponse sum = await client.CallAsync("tools/call", new
{
name = "add",
arguments = new
{
a = 2,
b = 3
}
});
Console.WriteLine(sum.Result);
JsonRpcResponse resource = await client.CallAsync("resources/read", new
{
uri = "voltaic://calculator/status"
});
Console.WriteLine(resource.Result);
JsonRpcResponse prompt = await client.CallAsync("prompts/get", new
{
name = "explain",
arguments = new
{
topic = "addition"
}
});
Console.WriteLine(prompt.Result);
JsonRpcResponse completion = await client.CallAsync("completion/complete", new
{
@ref = new
{
type = "ref/prompt",
name = "explain"
},
argument = new
{
name = "topic",
value = "ad"
}
});
Console.WriteLine(completion.Result);
await client.CallAsync("logging/setLevel", new { level = "info" });
await client.CallAsync("resources/subscribe", new { uri = "voltaic://calculator/status" });
await client.CallAsync("resources/unsubscribe", new { uri = "voltaic://calculator/status" });Run it:
dotnet runMcpHttpClient automatically uses the required Streamable HTTP headers, including Accept: application/json, text/event-stream and the MCP-Session-Id returned by the server. Call StartSseAsync() after ConnectStreamableAsync() if the client also needs server-sent notifications.
The server registrations above are the handlers behind the MCP endpoints:
RegisterTool(...)backstools/listandtools/call.RegisterResource(...)backsresources/listandresources/read.RegisterResourceTemplate(...)backsresources/templates/listand template-basedresources/read.RegisterPrompt(...)backsprompts/listandprompts/get.RegisterCompletionProvider(...)backscompletion/complete.initialize,notifications/initialized,notifications/cancelled,resources/subscribe,resources/unsubscribe,logging/setLevel, andpingare always registered.
using System.Net;
using System.Text.Json;
using Voltaic.Core;
using Voltaic.Mcp;
// Exposes only the methods you register. Pass includeDiagnosticMethods: true to also
// register the ping, echo, getTime, and add diagnostic methods.
JsonRpcServer server = new JsonRpcServer(IPAddress.Any, 8080);
// Subscribe to events
server.ClientConnected += (sender, client) =>
Console.WriteLine($"Client connected: {client.SessionId}");
server.RequestReceived += (sender, e) =>
Console.WriteLine($"Request: {e.Method} from {e.Client.SessionId}");
server.ResponseSent += (sender, e) =>
Console.WriteLine($"Response: {e.Method} took {e.Duration.TotalMilliseconds}ms");
// Register a synchronous method
server.RegisterMethod("greet", (RpcParameters? args) =>
{
string? name = args?.GetString("name") ?? "World";
return $"Hello, {name}!";
});
// Register an asynchronous method (for I/O-bound work like DB queries, HTTP calls, etc.)
server.RegisterMethod("fetchData", async (RpcParameters? args) =>
{
// Async handlers avoid blocking the thread pool
await Task.Delay(100); // Simulate async work
return (object)"async result";
});
// Register an async method with cancellation support
server.RegisterMethod("longRunningTask", async (RpcParameters? args, CancellationToken token) =>
{
// The token is the server's connection processing token
await Task.Delay(5000, token); // Cancels if client disconnects
return (object)"completed";
});
// Start the server
await server.StartAsync();
Console.WriteLine("Server running on port 8080");
// Keep it running
await Task.Delay(Timeout.Infinite, server.TokenSource.Token);using Voltaic.Core;
using Voltaic.Mcp;
JsonRpcClient client = new JsonRpcClient();
// Subscribe to notification events from server
client.NotificationReceived += (sender, request) =>
Console.WriteLine($"Server notification: {request.Method}");
await client.ConnectAsync("localhost", 8080);
// Call a method with typed response
string greeting = await client.CallAsync<string>("greet", new { name = "Developer" });
Console.WriteLine(greeting); // "Hello, Developer!"
// Send a notification (no response expected)
await client.NotifyAsync("logEvent", new { level = "info", message = "User logged in" });Use the same registration pattern with McpServer, McpHttpServer, McpTcpServer, and McpWebsocketsServer. Voltaic registers the protocol methods described in MCP Endpoint Requirements; your handlers supply the application data returned by the capability-driven endpoint families. The snippet uses System, System.Collections.Generic, System.Linq, System.Text.Json, and System.Threading.Tasks.
// tools/list and tools/call
server.RegisterTool(
"add",
"Adds two numbers",
new
{
type = "object",
properties = new
{
a = new { type = "number" },
b = new { type = "number" }
},
required = new[] { "a", "b" }
},
args =>
{
double a = args?.GetDouble("a") ?? 0;
double b = args?.GetDouble("b") ?? 0;
return (object)(a + b);
});
// resources/list and resources/read
server.RegisterResource("voltaic://example/status", "status", "text/plain",
() => new McpReadResourceResult
{
Contents = new List<object>
{
new McpTextResourceContents
{
Uri = "voltaic://example/status",
MimeType = "text/plain",
Text = "Service is running."
}
}
});
// resources/templates/list and template-based resources/read
server.RegisterResourceTemplate("voltaic://example/{name}", "example-item", "text/plain",
uri => new McpReadResourceResult
{
Contents = new List<object>
{
new McpTextResourceContents
{
Uri = uri,
MimeType = "text/plain",
Text = $"Dynamic resource for {uri}."
}
}
});
// prompts/list and prompts/get
server.RegisterPrompt("summarize", "Creates a summary prompt",
new[] { new McpPromptArgument { Name = "topic", Required = true } },
args => new McpGetPromptResult
{
Messages = new List<McpPromptMessage>
{
new McpPromptMessage
{
Role = "user",
Content = new McpTextContent { Text = "Summarize the requested topic." }
}
}
});
// completion/complete
server.RegisterCompletionProvider("ref/prompt", "summarize", "topic",
(request, token) => Task.FromResult(new McpCompleteResult
{
Completion = new McpCompletion
{
Values = new List<string> { "Voltaic", "MCP", "JSON-RPC" }
.Where(value => value.StartsWith(request.Argument.Value, StringComparison.OrdinalIgnoreCase))
.Take(100)
.ToList()
}
}));initialize, notifications/initialized, notifications/cancelled, resources/subscribe, resources/unsubscribe, logging/setLevel, and ping are always registered.
using System.Text.Json;
using Voltaic.Core;
using Voltaic.Mcp;
McpServer server = new McpServer();
// Customize server identity (optional)
server.ServerName = "MyMcpServer";
server.ServerVersion = "2.0.0";
// Register a tool with metadata for MCP tool discovery
server.RegisterTool("add",
"Adds two numbers",
new
{
type = "object",
properties = new
{
a = new { type = "number", description = "First number" },
b = new { type = "number", description = "Second number" }
},
required = new[] { "a", "b" }
},
(RpcParameters? args) =>
{
double a = args?.GetDouble("a") ?? 0;
double b = args?.GetDouble("b") ?? 0;
return (object)(a + b);
});
// Protocol methods are always registered:
// - initialize (returns capabilities and serverInfo)
// - ping (returns {})
// - tools/list (returns all registered tools)
// - tools/call (invokes a tool by name)
// - resources/list, resources/templates/list, resources/read
// - resources/subscribe, resources/unsubscribe
// - prompts/list, prompts/get
// - completion/complete
// - logging/setLevel
// - notifications/initialized (handles client init notification)
// - notifications/cancelled
//
// tools/list returns only the tools registered above. Construct with
// new McpServer(includeDiagnosticTools: true) to also publish echo and getTime.
// Run the server (reads from stdin, writes to stdout)
await server.RunAsync();using Voltaic.Core;
using Voltaic.Mcp;
McpClient client = new McpClient();
// Launch an MCP server as a subprocess; the client sends initialize and notifications/initialized
// (set AutoInitialize = false to do it yourself with InitializeAsync)
await client.LaunchServerAsync("dotnet", new[] { "run", "--project", "MyMcpServer" });
// Call methods on the server
JsonRpcResponse response = await client.CallAsync("tools/list");
Console.WriteLine(response.Result);using System.Net;
using System.Text.Json;
using Voltaic.Core;
using Voltaic.Mcp;
McpTcpServer server = new McpTcpServer(IPAddress.Any, 8080);
// Subscribe to events
server.ClientConnected += (sender, client) =>
Console.WriteLine($"Client connected: {client.SessionId}");
server.ClientDisconnected += (sender, client) =>
Console.WriteLine($"Client disconnected: {client.SessionId}");
server.RegisterTool(
"add",
"Adds two numbers",
new
{
type = "object",
properties = new
{
a = new { type = "number", description = "First number" },
b = new { type = "number", description = "Second number" }
},
required = new[] { "a", "b" }
},
(RpcParameters? args) =>
{
double a = args?.GetDouble("a") ?? 0;
double b = args?.GetDouble("b") ?? 0;
return (object)(a + b);
});
// Start the server
await server.StartAsync();
Console.WriteLine("MCP server running on port 8080");
await Task.Delay(Timeout.Infinite, server.TokenSource.Token);using Voltaic.Core;
using Voltaic.Mcp;
McpTcpClient client = new McpTcpClient();
// Subscribe to server notifications
client.NotificationReceived += (sender, request) =>
Console.WriteLine($"Server notification: {request.Method}");
// Connect to the TCP server; the client sends initialize and notifications/initialized.
// Messages are newline-delimited JSON; set NewlineDelimited = false for Voltaic servers before 2.1.5.
await client.ConnectAsync("localhost", 8080);
// Call methods on the server
object? tools = await client.CallAsync<object>("tools/list");
Console.WriteLine(tools);using System.Text.Json;
using Voltaic.Core;
using Voltaic.Mcp;
McpHttpServer server = new McpHttpServer("localhost", 8080);
// Subscribe to events
server.ClientConnected += (sender, client) =>
Console.WriteLine($"Session started: {client.SessionId}");
server.RequestReceived += (sender, e) =>
Console.WriteLine($"Request: {e.Method} from session {e.Client.SessionId}");
// Register a tool (automatically added to tools/list and tools/call)
server.RegisterTool("add",
"Adds two numbers",
new
{
type = "object",
properties = new
{
a = new { type = "number", description = "First number" },
b = new { type = "number", description = "Second number" }
},
required = new[] { "a", "b" }
},
(RpcParameters? args) =>
{
double a = args?.GetDouble("a") ?? 0;
double b = args?.GetDouble("b") ?? 0;
return (object)(a + b);
});
// Start the server
await server.StartAsync();
Console.WriteLine("MCP HTTP server running on http://localhost:8080");
await Task.Delay(Timeout.Infinite, server.TokenSource.Token);The default McpHttpServer listens on all three HTTP endpoints:
/mcpfor MCP Streamable HTTP (the specification's transport; use this for MCP clients)/rpcfor Voltaic's plain request/response JSON-RPC (scripts,curl,McpHttpClient.ConnectAsync)/eventsfor a Voltaic SSE notification stream tied to a session opened withinitializeon/rpc
/rpc and /events are not the deprecated 2024-11-05 HTTP+SSE transport, so clients built for that transport cannot connect to them. Clients speaking 2024-11-05 connect over Streamable HTTP (/mcp), stdio, TCP, or WebSocket.
Set mcpPath: null in the constructor if you want to disable the Streamable HTTP endpoint.
The same /mcp endpoint serves both handshake-era clients (initialize plus a session) and stateless 2026-07-28 clients (server/discover plus per-request headers), such as Claude Code 2.1.x. No configuration is needed for either; see Protocol version negotiation.
using Voltaic.Core;
using Voltaic.Mcp;
McpHttpClient client = new McpHttpClient();
// Connect to the HTTP server
await client.ConnectAsync("http://localhost:8080");
// Start SSE connection for server notifications
await client.StartSseAsync();
// Call methods on the server
object? result = await client.CallAsync<object>("tools/list");
Console.WriteLine(result);using Voltaic.Core;
using Voltaic.Mcp;
McpHttpClient client = new McpHttpClient();
// Establish the RPC/session side on POST /mcp
await client.ConnectStreamableAsync("http://localhost:8080");
// Open the SSE side on GET /mcp for notifications
await client.StartSseAsync();
// Call methods on the same session
object? result = await client.CallAsync<object>("tools/list");
Console.WriteLine(result);ConnectStreamableAsync() performs the MCP handshake: it sends initialize (requesting client.ProtocolVersion and reporting ClientName/ClientVersion), stores the session ID and the negotiated protocol version, and sends notifications/initialized. ConnectAsync() does the same on /rpc, and the session it receives is the one /events streams to. Call StartSseAsync() when you want the SSE notification stream to become active. Connecting fails (returns false) when initialize returns an error.
A Streamable HTTP server may answer a POST with application/json or with an SSE stream (text/event-stream), and the specification requires clients to accept both. McpHttpClient reads SSE responses as they arrive: notifications on the stream are raised through NotificationReceived, and the call completes with the response whose id matches the request. Server-to-client requests on the stream are answered like any other (see Answering requests from the server); in stateless mode they are input requests, answered through CallToolStatelessAsync.
On the handshake-era transport, McpHttpClient keeps SSE streams going the way the specification describes: it skips empty priming events, remembers each event ID, and when the server closes a GET stream (or the connection drops) it reopens it with Last-Event-ID after the server's retry interval, or SseReconnectDelayMs (default 1000) when the server sent none. An HTTP 4xx answer, such as 404 for an expired session or 405 from a server without a GET stream, stops reconnecting, as does SseMaxReconnectAttempts (default 5) consecutive failures. A POST response stream that ends after an event ID but before the response is resumed with a GET carrying Last-Event-ID. Set AutoReconnectSse = false to turn this off.
In stateless mode, McpHttpClient also handles x-mcp-header tool parameters: each tools/list result is checked, tool definitions with invalid annotations are removed from the result (a warning naming the tool goes to Log), and tools/call mirrors the annotated arguments into Mcp-Param-{Name} headers, Base64-encoding values that are not plain ASCII or have leading or trailing whitespace. If a server rejects a call with -32020, the client lists the tools again and retries once. Mcp-Name is taken from params.name or params.uri when no name is passed.
When McpHttpClient talks to a server over the stateless 2026-07-28 path, it treats a result with resultType: "input_required" as a Multi Round-Trip request and any other result as final. A result with no resultType is also treated as final, so the client keeps working against servers that predate the field.
On the handshake-era revisions, MCP servers may send requests to clients: ping at any time, and roots/list, sampling/createMessage, and elicitation/create. A 2026-07-28 server sends no requests; it asks for input through Multi Round-Trip results instead. Every Voltaic client (McpClient, McpTcpClient, McpWebsocketsClient, and McpHttpClient) answers them, as JSON-RPC requires: ping with {}, methods you register with RegisterRequestHandler with the handler's result, and anything else with -32601 (method not found).
McpHttpClient client = new McpHttpClient();
client.RegisterRequestHandler("roots/list", (args, token) =>
Task.FromResult<object?>(new { roots = new[] { new { uri = "file:///home/me/project", name = "project" } } }));
await client.ConnectStreamableAsync("http://localhost:8080"); // declares the roots capability
await client.StartSseAsync(); // server requests arrive on the GET streamRegistering roots/list, sampling/createMessage, or elicitation/create on McpHttpClient declares the matching capability in initialize and in the clientCapabilities of every stateless request, so register handlers before connecting. A handler that throws McpProtocolException sends that error; any other exception sends -32603 without its message. ping is reserved and always answered by the client. McpHttpClient sends each answer as a POST on the session. In stateless 2026-07-28 mode, where servers must use Multi Round-Trip input requests instead, requests on a stream are ignored. The plain JsonRpcClient answers server requests the same way but has no built-in ping.
using System.Text.Json;
using Voltaic.Core;
using Voltaic.Mcp;
McpWebsocketsServer server = new McpWebsocketsServer("localhost", 8080);
// Subscribe to events
server.ClientConnected += (sender, client) =>
Console.WriteLine($"WebSocket client connected: {client.SessionId}");
server.ResponseSent += (sender, e) =>
Console.WriteLine($"Sent response for {e.Method} in {e.Duration.TotalMilliseconds}ms");
server.RegisterTool(
"add",
"Adds two numbers",
new
{
type = "object",
properties = new
{
a = new { type = "number", description = "First number" },
b = new { type = "number", description = "Second number" }
},
required = new[] { "a", "b" }
},
(RpcParameters? args) =>
{
double a = args?.GetDouble("a") ?? 0;
double b = args?.GetDouble("b") ?? 0;
return (object)(a + b);
});
// Start the server
await server.StartAsync();
Console.WriteLine("MCP WebSocket server running on ws://localhost:8080/mcp");
await Task.Delay(Timeout.Infinite, server.TokenSource.Token);MaxMessageSize (default 1 MB, on McpWebsocketsServer and McpWebsocketsClient) bounds one received message, however it is fragmented; a larger message closes the connection with status 1009 (message too big).
using Voltaic.Core;
using Voltaic.Mcp;
McpWebsocketsClient client = new McpWebsocketsClient();
// Subscribe to server notifications
client.NotificationReceived += (sender, request) =>
Console.WriteLine($"Server notification: {request.Method}");
// Optional: credentials for a server with an AuthenticationHandler (sent on the upgrade request)
client.SetRequestHeader("Authorization", "Bearer " + token);
// Connect to the WebSocket server; the client sends initialize and notifications/initialized
await client.ConnectAsync("ws://localhost:8080/mcp");
// Call methods on the server
object? result = await client.CallAsync<object>("tools/list");
Console.WriteLine(result);Voltaic measures itself through the .NET base class library: a Meter and an ActivitySource, both named Voltaic. It adds no package dependency and opens no connection; your host subscribes and exports. With nothing subscribed, instrumentation costs a listener check per operation.
// OpenTelemetry SDK
Sdk.CreateMeterProviderBuilder().AddMeter("Voltaic").AddOtlpExporter().Build();
Sdk.CreateTracerProviderBuilder().AddSource("Voltaic").AddOtlpExporter().Build();
// or Radiant
RadiantSettings settings = new RadiantSettings("my-service");
settings.Sources.AddMeter("Voltaic");
settings.Sources.AddActivitySource("Voltaic");
using RadiantHost host = RadiantHost.Start(settings);What you get:
- Every request and call:
voltaic.rpc.server.durationandvoltaic.rpc.client.durationby protocol (jsonrpc,mcp,a2a), transport, method, outcome, anderror.type, with aServerorClientspan per request. - MCP tool calls by stage:
voltaic.mcp.tool.durationby tool and outcome (tool_error,invalid_arguments,rate_limited,handler_exception, ...) andstage:validate_input,stage:execute,stage:validate_outputspans and histograms. - HTTP, sessions, and gates: the HTTP layer of
McpHttpServerandA2AHttpServerby route template and status, sessions opened and closed by reason againstMaxSessions, access denials by reason, rate-limit decisions, queue drops, pings, notifications. - A2A: agent runs, task transitions, and the push notification pipeline (
queuedanddeliverstages, attempts, outcomes, last success, SSRF refusals).A2AGrpcServerkeeps Watson's own HTTP/2 telemetry on and nests its spans under it. - One trace across processes:
traceparenttravels in HTTP headers and, on MCP stdio, TCP, and WebSocket, inparams._meta.
Labels are bounded (no IDs, paths, or free text), spans never carry payloads, exception messages, or credentials, and a failing listener never affects a request. VoltaicTelemetry.Enabled and VoltaicTelemetry.PropagateTraceContext switch it off. TELEMETRY.md has the full metrics and spans catalog, PromQL alerts, and a dashboard layout.
Voltaic is trim-safe and Native AOT-compatible (IsAotCompatible) on .NET 8 and .NET 10. Every protocol model, on every transport, is serialized from source-generated metadata, and the library build fails on any trimming or AOT warning. src/Test.Aot publishes as a Native AOT binary and makes real calls over JSON-RPC TCP, MCP stdio, TCP, WebSocket, Streamable HTTP, and stateless HTTP, and A2A JSON-RPC, HTTP+JSON, and gRPC.
The values you give Voltaic as object (tool results, structured content, call parameters, error data) and the types you read with CallAsync<T> or RpcParameters.Deserialize<T>() are your own types, so under Native AOT their metadata has to come from you. Put them in a JsonSerializerContext and add it once, at startup:
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Voltaic.Core;
using Voltaic.Mcp;
[JsonSerializable(typeof(AddArguments))]
[JsonSerializable(typeof(SumResult))]
internal partial class AppJsonContext : JsonSerializerContext { }
// Before creating servers or clients.
VoltaicJson.AddTypeInfoResolver(AppJsonContext.Default);
JsonObject schema = new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject { ["a"] = new JsonObject { ["type"] = "number" }, ["b"] = new JsonObject { ["type"] = "number" } },
["required"] = new JsonArray("a", "b")
};
server.RegisterTool("add", "Adds two numbers", schema, args =>
{
AddArguments input = args?.Deserialize<AddArguments>() ?? new AddArguments();
return McpToolCallResult.FromStructured(new SumResult { Total = input.A + input.B });
});
SumResult sum = await jsonRpcClient.CallAsync<SumResult>("add", new AddArguments { A = 1, B = 2 });- Anonymous types cannot be source-generated. Under Native AOT, write schemas and ad-hoc payloads as
JsonObject/JsonNode,JsonElement, orDictionary<string, object?>. These types, the primitives, arrays, lists, and dictionaries of them, and every Voltaic model are already covered. - Without Native AOT nothing changes. Reflection stays the last resolver, so anonymous types and unregistered classes keep working, and the JSON Voltaic writes is identical to v2.2.1.
- A type no resolver knows fails only its own call. Under Native AOT the request gets
-32603 Internal error, the server'sLogevent names the type, and the connection keeps working. - Order:
VoltaicJson.TypeInfoResolverasks Voltaic's metadata first, then each resolver you added in the order added, then reflection when it is enabled. Use it as theTypeInfoResolverof your ownJsonSerializerOptionsto read Voltaic models from raw JSON.A2AJson.DefaultOptionsuses it too.
Voltaic servers usually run on a developer workstation or next to data they expose, often without authentication. Since v2.1.0 the defaults assume a web page in the user's browser, or another host on the network, may try to reach them.
| Protection | Applies to | Default | How to change it |
|---|---|---|---|
Browser Origin validation (MCP spec requirement; blocks cross-site calls and DNS rebinding) |
McpHttpServer, McpWebsocketsServer, A2AHttpServer |
Requests without Origin and loopback origins (http(s)://localhost, 127.0.0.0/8, [::1], any port) are allowed; every other origin gets 403 before preflight or authentication |
server.OriginPolicy.AllowedOrigins.Add("https://app.example.com"), AllowLoopbackOrigins, or a custom OriginValidator |
Browser Origin validation |
A2AGrpcServer |
Same rules; only the public Agent Card skips authentication | OriginPolicy |
| CORS | McpHttpServer, A2AHttpServer |
The allowed origin is echoed (never *) with Vary: Origin. A preflight gets back the header names it asks for (valid tokens only), so custom headers such as X-API-Key or Mcp-Param-* work; otherwise an explicit Access-Control-Allow-Headers list that includes Authorization is sent. WWW-Authenticate is exposed so browser clients can read an OAuth challenge |
EnableCors, CorsHeaders |
| Loopback-only clients for loopback binds | McpHttpServer, McpWebsocketsServer, A2AHttpServer, A2AGrpcServer |
On when the host name is localhost, 127.x.x.x, or ::1: a request from any other address gets 403. On Windows, HttpListener serves a localhost prefix on every interface and routes by the spoofable Host header, so this check is what keeps it local |
Bind to +/* or a specific address, or set RestrictToLoopbackClients = false |
JSON bodies on POST /mcp |
McpHttpServer |
A Content-Type other than application/json gets 415. A request with no Content-Type is accepted only without an Origin header (a non-browser client), because a browser can send such a body without a CORS preflight. A missing Accept header counts as */* |
None needed |
| Webhook targets | A2AHttpServer, A2AGrpcServer |
Push notification URLs must not target loopback, private, or link-local addresses; checked when the config is created and again when connecting | PushNotificationUrlValidator |
| Initialize-only sessions, principal binding | McpHttpServer |
See Sessions | None (since v2.1.4 RequireInitializedSessions is obsolete and ignored) |
| Strict framing | JsonRpcServer, McpTcpServer, JsonRpcClient, McpTcpClient |
Only Content-Length and Content-Type header lines (1024 bytes at most) are accepted, and McpTcpServer also accepts newline-delimited JSON but closes the connection on a line that is not JSON, so an HTTP request from a browser fetch() is dropped before anything runs |
None; Voltaic, LSP-style, and newline-delimited MCP clients send only those |
McpHttpServer server = new McpHttpServer("localhost", 8080);
// Allow a browser app served from another origin (loopback origins are already allowed)
server.OriginPolicy.AllowedOrigins.Add("https://tools.example.com");
// Serve other machines: bind to all interfaces (requires a URL ACL or admin rights on Windows)
McpHttpServer lanServer = new McpHttpServer("+", 8080);The TCP and stdio transports stay unauthenticated by design; strict framing is what stops browsers from talking to them.
On the handshake-era Streamable HTTP path, session IDs are always generated by the server:
- A successful
initialize(on/mcpor/rpc) creates the session, raisesClientConnected, and returnsMCP-Session-Id. A rejectedinitializecreates nothing and returns no header. POST /mcpwithoutMCP-Session-Idgets 400 (-32600), exceptinitialize. This includesping(since v2.1.4); use the health check (GET /) to probe connectivity without a session. Stateless2026-07-28requests have no sessions and are unaffected.POST /rpcwithout a session runs on a temporary connection and returns no session header, so plain request/response callers (scripts,curl) keep working without leaving sessions behind.- An
MCP-Session-Idthe server did not issue, has expired, or was terminated gets 404 (-32001) on every endpoint; it is never adopted. The client should sendinitializeagain. - With an
AuthenticationHandler, a session belongs to the principal that created it. Another principal presenting that ID gets 404. - Every request on a session counts as activity, and a session with a request still running is never expired, so
SessionTimeoutSecondsexpires only idle sessions. Notifications the server queues for a session are not client activity: a session nobody uses expires even while the server has notifications for it. - The
?session=query parameter is accepted only on the Voltaic-specific/eventsstream, for browserEventSourceclients that cannot set headers;GET /mcpreads theMCP-Session-Idheader only. GET /mcpstreams are resumable. On sessions that negotiated2025-11-25or later each starts with a priming event (an event ID and an emptydatafield, plusretry: SseRetryIntervalMs, default 1000); earlier revisions define none, so their streams start with a comment, and every message carries an ID of the form{streamId}-{n}. A client that reconnects withLast-Event-IDgets the messages it missed on that stream (the lastSseReplayBufferSizeper stream, default 100; the last 8 streams per session), and the old connection stops taking messages. IDs from another session or an unknown stream open a new stream, and messages beyond the replay buffer are not recovered. Streams sendX-Accel-Buffering: noso reverse proxies do not buffer them. The Voltaic-specific/eventsstream is not resumable.
Clients built on Voltaic 2.0.0 or earlier opened their session with ping instead of initialize and must be upgraded: since v2.1.4 RequireInitializedSessions is obsolete, always reads true, and setting it has no effect.
McpHttpServer supports an optional async authentication handler that runs before request processing. When set, every request that passes the loopback and origin checks (see Security defaults) is passed through the handler, except the exceptions listed below. The handler receives the full HttpListenerRequest and returns an AuthenticationResult. If authentication fails, the server returns the result's status code, headers, and error message without processing the request. When no handler is set, requests are not authenticated, but the loopback and origin checks still apply.
using System.Net;
using Voltaic.Core;
using Voltaic.Mcp;
McpHttpServer server = new McpHttpServer("localhost", 8080);
server.AuthenticationHandler = async (HttpListenerRequest request) =>
{
string? token = request.Headers["Authorization"];
if (string.IsNullOrEmpty(token) || !token.StartsWith("Bearer "))
{
return new AuthenticationResult
{
IsAuthenticated = false,
StatusCode = 401,
ErrorMessage = "Missing or invalid Authorization header"
};
}
// Validate the token with your JWT validator, database, identity provider, etc.
bool isValid = await ValidateTokenAsync(token.Substring("Bearer ".Length));
return new AuthenticationResult
{
IsAuthenticated = isValid,
Principal = "my-user",
Claims = new Dictionary<string, string> { { "role", "admin" } }
};
};
await server.StartAsync();The following requests are served without calling the handler, so infrastructure can validate connectivity and clients can discover how to authenticate. Origin validation and the loopback check still apply to them:
- Health check (
GET /, unless an endpoint is mounted at/) - returns{"status":"Ok"}for load balancer probes. The handler is not called. - Protected resource metadata (
GETat the well-known URL derived fromProtectedResourceMetadata.Resource, for example/.well-known/oauth-protected-resource/mcpforhttps://host/mcp) - served whenProtectedResourceMetadatais set. The handler is not called. - CORS preflight (
OPTIONSrequests) - returns204with CORS headers for an allowed origin. The handler is not called.
Every other request must authenticate, including the MCP ping: the MCP authorization specification requires a 401 for a missing or invalid token on every request. Before v2.1.4 a ping that failed authentication was answered anyway.
To shape the rejection, add headers to the failed result. AuthenticationResult.BearerChallenge() builds the RFC 6750 challenge MCP clients use to discover your authorization server:
server.AuthenticationHandler = request =>
{
if (request.Headers["Authorization"] != "Bearer " + expectedToken)
{
// 401 with WWW-Authenticate: Bearer resource_metadata="...", error="invalid_token"
return Task.FromResult(AuthenticationResult.BearerChallenge(
"https://api.example.com/.well-known/oauth-protected-resource", "invalid_token"));
}
return Task.FromResult(new AuthenticationResult { IsAuthenticated = true, Principal = "user" });
};
// Any other header works too, for example Retry-After on a throttled rejection:
// result.Headers["Retry-After"] = "30";McpHttpServer, McpWebsocketsServer, and A2AHttpServer write AuthenticationResult.Headers on every rejection.
Setting an AuthenticationHandler never changes protocol behavior. Once a request is authenticated, it runs through exactly the same MCP pipeline as on a server without a handler: version resolution, stateless 2026-07-28 routing, the batching rules, session tracking, and the resultType/cache fields on stateless results. Before v1.1.0, authenticated requests took a separate path that skipped most of these, and stateless clients such as Claude Code could not list tools on an authenticated server.
The MCP authorization specification makes the MCP server an OAuth 2.1 resource server: it must publish OAuth 2.0 Protected Resource Metadata (RFC 9728) naming its authorization server, answer unauthenticated requests with 401 and a WWW-Authenticate challenge, and validate every access token, including that the token was issued for this server (its audience). Voltaic covers the resource-server plumbing:
McpHttpServer server = new McpHttpServer("+", 8443);
// Served without authentication at the URL derived from Resource (RFC 9728):
// https://mcp.example.com/.well-known/oauth-protected-resource/mcp
server.ProtectedResourceMetadata = new McpProtectedResourceMetadata
{
Resource = "https://mcp.example.com/mcp",
AuthorizationServers = new List<string> { "https://auth.example.com" },
ScopesSupported = new List<string> { "tools:read", "tools:write" }
};
server.AuthenticationHandler = async request =>
{
string? header = request.Headers["Authorization"];
AccessToken? token = header != null && header.StartsWith("Bearer ")
? await ValidateJwtAsync(header.Substring(7), expectedAudience: "https://mcp.example.com/mcp")
: null;
if (token == null)
{
// Leave the URL null: Voltaic adds resource_metadata naming the URL above to every Bearer challenge.
return AuthenticationResult.BearerChallenge(null, "invalid_token");
}
return new AuthenticationResult { IsAuthenticated = true, Principal = token.Subject };
};The authorization server itself, token issuance, and token validation (signature, expiry, audience, scopes) remain the application's responsibility; ValidateJwtAsync above stands for your validator. Serve OAuth-protected servers over HTTPS in production, for example behind a TLS-terminating reverse proxy.
McpHttpClient.SetRequestHeader(name, value) attaches a header to every request the client sends — the JSON-RPC POST requests and the SSE GET stream, including the initial connection handshake. Use it to send a bearer token or a custom API-key header to an authenticated server. Set the header before connecting so it is present on the handshake. Passing a null or empty value removes a previously set header, and header names are matched case-insensitively.
using Voltaic.Mcp;
using McpHttpClient client = new McpHttpClient();
// Bearer token: Authorization: Bearer <token>
client.SetRequestHeader("Authorization", "Bearer " + token);
// Or a custom API-key header instead:
// client.SetRequestHeader("X-API-Key", apiKey);
await client.ConnectStreamableAsync("http://localhost:8080");This pairs with the server-side AuthenticationHandler above: the client sends the credential and the handler validates it. The header is present on the initialize handshake, so the session the server creates belongs to the authenticated principal.
McpWebsocketsServer.AuthenticationHandler takes the same delegate as McpHttpServer and runs on the upgrade request, after the origin and loopback checks. A failure refuses the upgrade with the result's status code, headers, and message. On success, the caller is stored in ClientConnection.Caller (visible to ClientConnected subscribers) and is the ambient RpcCallContext.Current for every request on that socket. On the client, McpWebsocketsClient.SetRequestHeader sends credentials on the upgrade request.
McpWebsocketsServer server = new McpWebsocketsServer("localhost", 8080);
server.AuthenticationHandler = request => Task.FromResult(
request.Headers["Authorization"] == "Bearer " + expectedToken
? new AuthenticationResult { IsAuthenticated = true, Principal = "user" }
: AuthenticationResult.BearerChallenge());
McpWebsocketsClient client = new McpWebsocketsClient();
client.SetRequestHeader("Authorization", "Bearer " + expectedToken);
await client.ConnectAsync("ws://localhost:8080/mcp");AuthenticationHandler decides whether a request is allowed in; to make per-caller authorization decisions inside a tool or method handler (scope reads to a tenant, gate writes by role, and so on) the handler needs the caller's identity. Voltaic carries it there for you.
After a successful AuthenticationResult, McpHttpServer (per request) and McpWebsocketsServer (per socket) publish the caller's Principal and Claims as an ambient Voltaic.Core.RpcCallContext. Because it flows on the request's async call chain, any handler can read RpcCallContext.Current without changing its signature. Current is null when no AuthenticationHandler is configured, and on transports that do not authenticate (TCP and stdio). A request that fails authentication never reaches a handler.
using Voltaic.Core;
using Voltaic.Mcp;
server.RegisterTool("list_orders", "List the caller's orders", inputSchema, async (RpcParameters? args, CancellationToken token) =>
{
RpcCallContext? caller = RpcCallContext.Current;
if (caller == null) throw new InvalidOperationException("Unauthenticated.");
string tenantId = caller.Claims.TryGetValue("tenantId", out string? t) ? t : throw new InvalidOperationException("No tenant.");
bool isAdmin = caller.Claims.TryGetValue("isAdmin", out string? a) && a == "true";
return await LoadOrdersAsync(tenantId, caller.Principal, isAdmin, token);
});If you prefer the caller passed in explicitly rather than read from an ambient, every RegisterMethod/RegisterTool surface also offers an overload that receives an RpcCallContext? parameter (it simply forwards RpcCallContext.Current):
server.RegisterTool("list_orders", "List the caller's orders", inputSchema,
async (RpcParameters? args, RpcCallContext? caller, CancellationToken token) =>
{
// caller == RpcCallContext.Current
return await LoadOrdersAsync(caller, token);
});Both forms are additive: existing handlers that ignore the caller compile and behave exactly as before.
The A2A servers carry the caller the same way, but through the context object the handler already receives rather than an ambient. When A2AHttpServer or A2AGrpcServer authenticates a request, it copies the AuthenticationResult's Principal and Claims onto the A2ARequestContext before your IA2AAgentHandler runs — so the identity is available even though A2A agents execute on a background task and may stream results after the request returns. Principal/Claims are null when no AuthenticationHandler is configured or for public Agent Card requests.
public async Task ExecuteAsync(A2ARequestContext context, A2AAgentEventQueue eventQueue, CancellationToken token)
{
string? principal = context.Principal; // who is calling
string? tenantId = context.Claims != null && context.Claims.TryGetValue("tenantId", out string? t) ? t : null;
// ... scope the agent's work to the authenticated caller ...
}Voltaic implements the MCP revisions 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and 2026-07-28, and meets their MUST, MUST NOT, SHOULD, and SHOULD NOT requirements on every transport (stdio, TCP, WebSocket, and Streamable HTTP). COMPATIBILITY.md has the full per-revision matrix, updated with every release. This section lists the optional features Voltaic does not implement, the places where it deliberately accepts input the specification says a client should not send, and the places where it is stricter than required.
Optional features not implemented.
| Area | Voltaic behavior | Specification |
|---|---|---|
| HTTP+SSE transport (2024-11-05) | Not implemented, and McpHttpClient does not fall back to it; /rpc and /events are different, Voltaic-specific endpoints (EnableLegacyEndpoints = false turns them off) |
Deprecated since 2025-03-26. 2024-11-05 itself is supported over Streamable HTTP, stdio, TCP, and WebSocket. |
2026-07-28 in the stdio, TCP, and WebSocket clients |
McpClient, McpTcpClient, and McpWebsocketsClient speak the handshake revisions; McpHttpClient speaks 2026-07-28 (ConnectStatelessAsync). All servers serve 2026-07-28 on every transport. |
A client chooses the revisions it supports. |
| Server-to-client requests (sampling, elicitation, roots) on handshake-era sessions | Not sent by Voltaic servers (only ping) |
Optional. On 2026-07-28, tool handlers can return McpInputRequiredResult (see Multi Round-Trip Requests); a handshake-era caller gets an isError result instead. Voltaic clients answer such requests from other servers through RegisterRequestHandler. |
Multi Round-Trip input for resources/read and prompts/get (2026-07-28) |
Only tools/call handlers can request input |
Optional. |
| Tasks (2025-11-25 and the 2026-07-28 extension) | Models only; the server does not run tools as tasks | Optional. |
| OAuth authorization | Resource-server plumbing only: ProtectedResourceMetadata, BearerChallenge, InsufficientScope, McpInsufficientScopeException, and AuthenticationHandler; McpHttpClient sends the credentials you give it but runs no OAuth flow |
Authorization is optional. The authorization server (including the 2025-03-26 metadata and fallback endpoints /authorize, /token, and /register), token validation, and audience checks are application responsibilities. See OAuth and protected resource metadata. |
| HTTPS listening | McpHttpServer listens on HTTP; terminate TLS in a reverse proxy |
The transport requires HTTPS in production deployments, which a proxy provides. |
Deliberate leniency. Each of these accepts input the specification says a client should not send, without weakening any security check:
- A missing
Acceptheader counts as*/*, and media ranges such asapplication/*match. POST /mcpwithoutContent-Typeis accepted when there is noOriginheader (a non-browser client). Browser requests without it get 415.- On
2026-07-28, notification POSTs need no routing headers, and unknown notifications get 202. Onlynotifications/*methods are notifications: a request method sent without anidis never run, and gets 400. - Tool arguments that fail the input schema produce a tool result with
isError: trueon every revision (the model can correct them); older revisions allowed either this or-32602. - The TCP server accepts newline-delimited JSON (the stdio framing) as well as
Content-Lengthframing, and closes a connection that sends a line that is not JSON (a cross-protocol request). - JSON-RPC batches are accepted on
2024-11-05sessions as well as2025-03-26(JSON-RPC 2.0 allows them;2024-11-05does not mention them). - Invalid UTF-8 in a message is decoded with replacement characters rather than rejected.
- The Voltaic-specific
/rpcand/eventsendpoints accept the session in?session=(on/events) and do not checkAcceptorContent-Type; the Streamable HTTP endpoint does. - A JSON Schema
patternalso accepts three forms that only ECMA-262's non-Unicode syntax (Annex B) allows, because they are common and unambiguous: an identity escape of any character that is not an ASCII letter or digit (\_), a literal{,}, or]that does not form a quantifier (a complete{n}with nothing to repeat is still an error), and a class escape at either end of a class range ([\w-.], where the hyphen is literal). - An error response to a message whose ID cannot be read (or whose ID is not a string or an integer) carries
"id": nullunder2024-11-05to2025-06-18, as JSON-RPC 2.0 requires, and omitsidunder2025-11-25and2026-07-28, whose schemas make it optional and do not allow null. On HTTP the revision is theMCP-Protocol-Versionheader, else the session's, else2025-03-26. - A request that carries
MCP-Protocol-Version: 2026-07-28is served statelessly under that revision's rules even when it also carries a session ID; the session plays no part in it.
Stricter than required. RegisterTool requires a description (optional in the specification) and rejects tool names outside 1-128 characters of letters, digits, _, -, and . (a SHOULD in the specification), so every published tool is portable. It also rejects an x-mcp-header integer whose schema bounds exceed the JavaScript safe range (the specification constrains the values); McpHttpClient applies only the specification's rules to other servers' tools. A pattern that uses a Unicode property other than a general category, Any, ASCII, or Assigned (for example \p{Script=Greek} or \p{Alphabetic}) is rejected when the tool is registered, as is anything that is not ECMA-262 syntax (ES2025, including modifiers and duplicate named groups) and a pattern whose groups nest more than 500 deep. A tool argument string or property name containing an unpaired UTF-16 surrogate (not valid Unicode; I-JSON forbids it) fails input validation wherever it appears, also where the schema does not look.
Status codes and disconnects on HTTP. The HTTP status is sent with the first byte of a response. A POST response is a single JSON body with its exact status, including 400 for a -32021 and 403 for an McpInsufficientScopeException raised late in a tool, with two exceptions. A request whose handler sent a notification before failing (progress for the request's token, or a log message the client's log level allows; on handshake-era sessions every level passes until the client sets one with logging/setLevel) already streams SSE with status 200, so the error is its final event. And a 2026-07-28 request whose handler runs silently for longer than ResponseKeepAliveMs (default 15 seconds) gets keep-alive comments on an SSE stream (status 200), because HttpListener notices a closed connection only by writing and that revision defines a closed stream as cancellation: the client's disconnect cancels the handler within that interval. Set ResponseKeepAliveMs = 0 to keep exact statuses for slow handlers too, at the cost of noticing a disconnect only at the handler's next write.
Behavior that follows the specification, for reference:
- Lifecycle:
initializemust come first (other requests get-32600, exceptping), on every transport including/rpc; it must carryprotocolVersion,capabilities, andclientInfo(-32602) and runs once per session, even when two arrive together. A version the server does not know is answered withMaximumHandshakeProtocolVersion. Every Voltaic client initializes on connect (AutoInitialize), declares only the capabilities the requested revision defines, and requests only handshake versions;InitializeTimeoutMs(default 30 seconds) andInitializeAsync(timeoutMs)bound the handshake. The stdio client reads and writes UTF-8 whatever the console code page, and shuts the server down by closing its input, then SIGTERM (Linux, macOS), then killing it. - JSON-RPC:
idmust be a string or an integer (nulland fractions are-32600),jsonrpcmust be"2.0", and MCPparamsmust be an object (members of the wrong type are-32602). A notification method sent with anidis-32601. Every result is a JSON object (null answers{}, anything else is-32603). Batches are rejected on2025-06-18and later (an empty batch is-32600, andinitializeor a2026-07-28request may not be batched, on HTTP too). Clients follow the same rules for what servers send them: batches are answered on2024-11-05and2025-03-26(an empty batch gets one-32600, and each element that is not a request, notification, or response gets-32600) and refused with one-32600on later revisions, a request with anullID gets-32600, and aRegisterRequestHandlerresult that is not an object gets-32603. - Concurrency, cancellation, and health: requests on one connection run concurrently, so
pingis answered while a tool runs.notifications/cancelledcancels the handler's token and suppresses its response and any further progress or log messages. A cancellation for an ID the receiver does not know (one that has not arrived, or was already answered or rejected) is ignored, as the specification asks, so it never affects a later request with that ID. A cancellation read while a batch is still being answered drops the responses of the elements it names that had not finished (including elements the batch refuses, which count as unfinished until the batch response is assembled); an element whose handler already finished is answered, as the specification allows. Clients track the requests servers send them the same way. Both sides log cancellation reasons. Clients sendnotifications/cancelledwhen a call times out or is cancelled, and honor it for requests the server sent them. A cancellation that follows its request always applies, also when a2026-07-28client reuses an answered ID: request IDs are reserved in message order before the request starts. A request method sent without anidis never run (a notification is only anotifications/*method). Clients, and stdio, TCP, and WebSocket servers, ping the other side everyPingIntervalMs(default 30 seconds) afterinitialize, and treat an unanswered ping as a connection failure (PingFailureThreshold, default 1): the stdio server'sRunAsyncreturns even while a read is pending. A server whose ping times out sendsnotifications/cancelledfor it. Tool calls, completions, and log messages are rate-limited per client (RateLimits: 100 tool calls and 100 completions per second, 200 log messages per second; a client is its authenticated principal and remote address, so every HTTP session and request and every WebSocket connection of one client shares the limits; on TCP, the remote address; on stdio, the process): a tool call over the limit gets anisErrorresult, a completion gets no suggestions, and a log message is not sent. Progress is rate-limited on both sides: servers send at most one update perProgressIntervalMs(default 20 ms) per request and clients deliver progress only for the tokens of their requests in flight, at the same bound; an update held back by the limit is replaced by later ones, and the latest one is sent (or delivered) just before the response, so the last update always arrives. - Notifications:
list_changedis sent automatically when tools are registered or unregistered and when resources, templates, or prompts are registered, and goes only to initialized sessions, never ahead of theinitializeresponse;resources/updatedonly to sessions subscribed to that URI;notifications/messageonly at or above the session'slogging/setLevellevel (or the2026-07-28_metalog level); progress only for a request in flight that carries the token, with increasing values, on the request's own response stream.BroadcastNotificationAsyncreaches initialized sessions only. Notifications and results are downgraded to the revision that governs them (the negotiated one, or2026-07-28for a stateless request on any connection). - Schemas: tool schemas are validated in their declared dialect with that dialect's keywords only: JSON Schema 2020-12 (the default;
prefixItems,dependentRequired,dependentSchemas,unevaluatedProperties,unevaluatedItems,$anchor, and$dynamicRefwith dynamic scope) or draft-07 (arrayitemswithadditionalItems,dependencies, fragment$idanchors, and$refoverriding its siblings). References resolve within the resource they appear in; only applicator keywords hold subschemas, so a property namedenumordefaultis a schema and the contents of unknown keywords are not.patternruns on Voltaic's own ECMA-262 engine with the semantics of theuflag that JSON Schema recommends: matching works on code points (an emoji or a supplementary-plane letter is one character to., classes,\S, and\p{L}),$matches only at the end,.excludes line terminators,\dand\ware ASCII,\sis the ECMA-262 white space set,\p{...}takes general categories by short or long name, backreferences (including forward references) and lookbehind follow ECMA-262, captures are reset on every repetition, group names follow ECMA-262 identifier rules, the ES2025 additions are supported (pattern modifiers such as(?i:...),(?m:...), and(?s-i:...), with case-insensitive matching by Unicode simple case folding, and the same group name in different alternatives), and a step budget stops catastrophic backtracking: all the pattern matches of one value share 10,000,000 steps plus 200 per character of the value plus 4 per character squared (at most 200,000,000 of that last part), so a value split into many strings costs no more than one string of the same size. Simple patterns (up to about 200 steps per character) always fit, and quadratic work fits on values up to about 7,000 characters; the most expensive patterns measured used up the budget in about 3 seconds for a 1 MB value (up to about 11 seconds for case-insensitive Unicode classes over supplementary characters). When the budget runs out the whole validation fails, even insidenot,oneOf, orif. General categories and the characters allowed in group names come from the running .NET version's Unicode data (Unicode 15.0 on .NET 8, 16.0 on .NET 10); case folding uses Unicode 16.0 on both. A schema evaluation that exceeds its own step or depth budget rejects the value the same way. Each embedded resource ($id) is validated in the dialect its own$schemadeclares, and a relative root$idresolves. Numbers are compared exactly, whatever their precision: significant digits are kept as text, so parsing and comparing take time linear in the number's length andmultipleOfwith a divisor of up to 18 digits is fast on any value. A number whose written exponent has more than 1,000 digits (a magnitude beyond 10 to the power 10^1000) fails validation, and a schema with such a bound, or with amultipleOfof more than 1,000 significant digits, is rejected at registration. Received messages may nest up to 256 levels.requiredanddependentRequiredtreat the empty property name like any other. In draft-07,$refhides its sibling keywords, but references in its sibling containers are still checked, and"items": []is allowed; each dialect checks only its own definitions keyword ($defsordefinitions). Another dialect, a keyword form the dialect does not allow, a malformed keyword value (an unknown type name, a non-numeric bound, a negative count, a pattern that is not a regular expression, a subschema that is not an object or boolean), or a reference that cannot be resolved within the schema is rejected when the tool is registered; unknown keywords are ignored. - Features: input schemas are objects; an output schema may have any shape, and older sessions (whose schema requires an object) receive it as an object schema or not at all, and on the sessions that receive output schemas (2025-06-18 and 2025-11-25) a tool whose typeless output schema is advertised as an object schema must return an object (
-32603otherwise); before2026-07-28, boolean property subschemas are sent as{}and{"not": {}}; a tool with anoutputSchemamust returnstructuredContent(JSONnullincluded, on2026-07-28), andstructuredContentthat is not an object is removed before2026-07-28(the text block keeps it); resource-not-found is-32002on handshake-era revisions and-32602on2026-07-28; resource templates are matched by their RFC 6570 expressions, including{&var}query continuations,:nprefixes (counted in decoded characters, in query expressions too; a variable with both a prefix and*is not a valid template), and empty values (query parameters the template does not name are ignored); a prompt argument or any other parameter string that is not valid Unicode text (an unpaired UTF-16 surrogate) is-32602; completions return at most 100 values with the fulltotal; cursors are opaque and stable across changes. - Limits: every transport bounds one received message with
MaxMessageSize(default 16 MiB on stdio, TCP, and HTTP, 1 MB on WebSocket, on servers and clients). A server skips an oversized newline-delimited message and answers-32600, closes a Content-Length framed connection, answers an oversized HTTP body with 413 (a declaredContent-Lengthis checked before the body is read, also before authentication), and closes a WebSocket with 1009; a client ends the connection or fails the call.McpHttpServerholds at mostMaxSessionssessions (default 10,000; aninitializebeyond it gets 503) andMaxSessionsPerClientper client (default 100; the client's least recently active session ends); setClientIdentifierto name clients behind a reverse proxy. A binary WebSocket message closes the connection with 1003. stdio and newline-delimited TCP split messages only at LF. - HTTP: invalid
Origingets 403; sessions come only from a successfulinitialize; a missing session gets 400 and an unknown or terminated one 404, and a terminated session's streams end, including/events;DELETEends a session and only its owner may send it;MCP-Protocol-Versionmust name a version the server negotiates and, on a session, the session's version; a notification or response the server cannot accept (a wrongjsonrpcmember, non-objectparams, or anything else it cannot process), and a request method without anid, gets 400, also when a batch holds nothing else; an MCP endpoint may be mounted at/;ProtectedResourceMetadata.Resourcemust be an absolute http(s) URL without a fragment;McpHttpClientresumes an interrupted POST response stream with or without a session;McpHttpClientstarts a new session after any 404 (requests, notifications, its GET stream, resumed streams; anotifications/cancelledthat gets 404 starts the new session but is not sent again, since its request belonged to the old one) and sendsDELETEwhen it disconnects. POST response streams on a session carry event IDs (with a priming event from2025-11-25, as on GET streams) and can be resumed withGETandLast-Event-ID; each session keeps its most recentMaxResumableStreamsPerSessionstreams (default 8) for resumption. A 401 always carriesWWW-Authenticate, every Bearer challenge namesresource_metadata(derived from the configured resource identifier, so it is correct behind a proxy), the metadata is served only at that URL (RFC 9728 section 3.3), and missing scopes get 403insufficient_scope, also for a batch. 2026-07-28: the body is a single JSON-RPC request or notification (not JSON is 400-32700; batches and client responses are 400-32600);_metamust carry the protocol version andclientCapabilities(-32602, also on stdio, TCP, and WebSocket when_metahas the client fields but no string version); every complete result of a cacheable method carriesttlMsandcacheScope, added to the serialized response so shared result objects are never changed; an elicitation mode other thanformorurlis-32603; ontools/call(the only method whose handlers can request input), aninputResponsesvalue recognizable as an elicitation result (it hasaction), a roots result (roots), or a sampling result (roleormodel) must match that result's schema, else-32602, and any other value is ignored as information the server does not recognize (the handler sees only the recognized responses); onlytools/call,resources/read, andprompts/getmay return an input-required result, it must carryinputRequestsorrequestState, and aresultTypemust becomplete,input_required, ortaskwith the tasks extension advertised (anything else is-32603); application errors are brought into the revision's shape (-32021getsdata.requiredCapabilities,-32022getsdata.supportedanddata.requested, and-32042and the reserved-32000to-32019become-32603);McpHttpClient.CallToolStatelessAsyncwithout a callback answers input requests with the handlers registered throughRegisterRequestHandler; results carryresultTypeandserverInfoin_meta(andMcpHttpClienttreats a result withoutresultTypeas complete, as the revision specifies);ping,logging/setLevel, andresources/subscribeare not served; input requests need the matching client capability, includingelicitation.url,sampling.tools, andsampling.context(-32021, whose data always carriesrequiredCapabilities, also when an application throwsMcpProtocolException(-32021, ...)without data);server/discoverrequires the_metaprotocol version;MCP-Protocol-Version,Mcp-Method,Mcp-Name(fromparams.namefor tools and prompts,params.uriforresources/read), andMcp-Param-{Name}headers are validated (400-32020), whatever the argument's JSON type (numbers compare exactly and numerically, so4.2e1matches42but42.00000000000000000000000000001does not); an unsupported version gets 400-32022with the versions this server accepts.McpHttpClientin stateless mode sends the version header and_metaon every request and notification (includingCallAsync,CallAsync<T>, andNotifyAsync), honors each call'stimeoutMs, re-issues a request once with a new ID when its response stream or body breaks off, retries any request after-32022with a listed version, and leaves stateless mode when it disconnects. No error code from the reserved legacy range is used (McpInsufficientScopeExceptionuses 403).
Most upgrades within 2.x need no code changes; code that follows the MCP specification keeps working. UPGRADING.md lists every behavior change by version, with who is affected and what to do. The ones most likely to matter:
- From v1.x (v2.0.0, breaking): demo tools (
echo,getTime) are opt-in throughincludeDiagnosticTools,pingreturns{}, tools are callable only throughtools/call, andadditionalPropertiesis enforced. See MIGRATE_V1_TO_V2.md. - v2.1.0 security defaults: foreign browser origins get 403,
localhostservers serve loopback clients only, and Streamable HTTP sessions come only frominitialize. See Security defaults. - Tool errors (v2.1.2, v2.1.4): invalid arguments and handler exceptions return
isErrorresults rather than JSON-RPC errors; throwMcpToolExceptionfor text meant for the model. - Handshake and liveness (v2.1.5, v2.1.7): stream clients initialize on connect (
AutoInitialize), and both sides ping every 30 seconds (PingIntervalMs,PingFailureThreshold). - Schemas (v2.1.7 to v2.1.11): tool schemas are checked at
RegisterToolin their declared dialect (2020-12 or draft-07), andpatternuses ECMA-262 syntax and semantics. - Limits (v2.1.11, v2.1.13):
RateLimitsper client,MaxMessageSizeon every transport, andMaxSessions/MaxSessionsPerClientonMcpHttpServer(setClientIdentifierbehind a reverse proxy). - Native AOT (v2.3.0): Voltaic is
IsAotCompatible. Nothing changes without Native AOT; under Native AOT, add aJsonSerializerContextfor your own types withVoltaicJson.AddTypeInfoResolverand replace anonymous types. See Native AOT and trimming. - Telemetry (v2.2.0): Voltaic emits metrics and traces on the
Voltaicmeter and activity source. Nothing changes until a collector subscribes. MCP stream clients addtraceparenttoparams._metawhile a span is recorded; setVoltaicTelemetry.PropagateTraceContext = falseto stop it. See TELEMETRY.md.
Voltaic might not be the right fit if you need:
- Advanced gRPC Ecosystem Features: If you need code-first service hosting, interceptors, advanced load balancing, or broad gRPC framework integration, use a dedicated gRPC stack
- REST Conventions: If you need resource-oriented APIs with standard HTTP verbs, use web APIs or a REST microservice
- High-level Abstractions: Voltaic is a protocol library, not a framework; you'll write your own business logic
All server and client classes implement IDisposable using the full Dispose pattern (protected virtual void Dispose(bool disposing)) with double-disposal protection. Use using statements or call Dispose() to ensure proper resource cleanup:
// Recommended: using statement ensures cleanup
using McpHttpServer server = new McpHttpServer("localhost", 8080);
await server.StartAsync();
// Or manually dispose
McpHttpServer server2 = new McpHttpServer("localhost", 8081);
try
{
await server2.StartAsync();
}
finally
{
server2.Dispose();
}Key points:
Dispose()is safe to call multiple times; subsequent calls are no-ops- For servers,
Dispose()callsStop()internally, disconnecting all clients and releasing the listening port - For clients,
Dispose()callsDisconnect()internally, cancelling pending requests Disconnect()/Stop()manage connection state only;Dispose()releases underlying resources (sockets, listeners, cancellation tokens)- All classes support the
protected virtual void Dispose(bool disposing)pattern for subclass extensibility
Check out the src/Test.* projects for working examples:
- Test.JsonRpcServer / Test.JsonRpcClient: Interactive JSON-RPC demos over TCP
- Test.McpServer / Test.McpClient: MCP stdio examples
- Test.McpHttpServer / Test.McpHttpClient: MCP HTTP with SSE examples
- Test.McpWebsocketsServer / Test.McpWebsocketsClient: MCP WebSocket examples
- Sample.McpServer: MCP tool, structured-output, resource, template, and prompt sample
- Sample.A2AServer: A2A Agent Card, JSON-RPC, HTTP+JSON, gRPC, streaming, push config, and extended-card sample
- Test.A2AServer: Manual A2A server harness with JSON-RPC, HTTP+JSON, gRPC, task inspection, and push config commands
- Test.A2AClient: Manual A2A client for Agent Card discovery, JSON-RPC, HTTP+JSON, gRPC, streaming, and push config calls
- Test.Aot: Native AOT smoke test with a
JsonSerializerContext, covering every transport - Test.Shared: Shared Touchstone descriptors and the central 857-case API/protocol matrix
- Test.Automated: Touchstone console runner
- Test.Xunit / Test.Nunit: Touchstone adapter projects for
dotnet test
Run examples:
# JSON-RPC Server (TCP)
dotnet run --project src/Test.JsonRpcServer/Test.JsonRpcServer.csproj -- 8080
# JSON-RPC Client (TCP)
dotnet run --project src/Test.JsonRpcClient/Test.JsonRpcClient.csproj -- 8080
# MCP Stdio Client (launches server subprocess)
dotnet run --project src/Test.McpClient/Test.McpClient.csproj
# MCP HTTP Server
dotnet run --project src/Test.McpHttpServer/Test.McpHttpServer.csproj -- 8080
# MCP HTTP Client
dotnet run --project src/Test.McpHttpClient/Test.McpHttpClient.csproj -- 8080
# MCP WebSocket Server
dotnet run --project src/Test.McpWebsocketsServer/Test.McpWebsocketsServer.csproj -- 8080
# MCP WebSocket Client
dotnet run --project src/Test.McpWebsocketsClient/Test.McpWebsocketsClient.csproj -- 8080
# A2A sample server
# Starts JSON-RPC and HTTP+JSON on the selected port, and gRPC on port + 1
dotnet run --project src/Sample.A2AServer/Sample.A2AServer.csproj -- 8080
# A2A manual server
# Starts JSON-RPC and HTTP+JSON on the selected port, and gRPC on port + 1
dotnet run --project src/Test.A2AServer/Test.A2AServer.csproj -- 8080
# A2A manual client
dotnet run --project src/Test.A2AClient/Test.A2AClient.csproj -- http://localhost:8080 "hello from A2A"The MCP Inspector is a visual tool for testing and debugging MCP servers. To connect MCP Inspector to a Voltaic MCP HTTP server:
-
Start your MCP HTTP server:
dotnet run --project src/Test.McpHttpServer/Test.McpHttpServer.csproj -- 8080
-
Open MCP Inspector in your web browser
-
Configure the connection:
- Transport Type: Select
Streamable HTTP - URL: Enter
http://{hostname}:{port}/mcp- For example:
http://localhost:8080/mcp - If you specified a custom
mcpPathwhen creating the server, use that instead of/mcp
- For example:
- Transport Type: Select
-
Click Connect
-
Verify the connection: The inspector should display the list of registered tools and allow you to call them interactively
Note: Use the Streamable HTTP transport in MCP Inspector for Voltaic's /mcp endpoint. For other Voltaic transports (TCP, WebSocket, stdio), use the corresponding client implementations or command-line tools.
# Build everything
dotnet build src/Voltaic.sln
# Build the library
dotnet build src/Voltaic/Voltaic.csproj
# Run Touchstone console tests
dotnet run --project src/Test.Automated/Test.Automated.csproj --framework net8.0
# The shared suite currently projects 857 cases through the console, xUnit, and NUnit runners
# Export Touchstone JSON results
dotnet run --project src/Test.Automated/Test.Automated.csproj --framework net8.0 -- --results artifacts/test-results/voltaic-touchstone.json
# Filter by descriptor tag
dotnet run --project src/Test.Automated/Test.Automated.csproj --framework net8.0 -- --tag mcp
dotnet run --project src/Test.Automated/Test.Automated.csproj --framework net8.0 -- --tag a2a
dotnet run --project src/Test.Automated/Test.Automated.csproj --framework net8.0 -- --tag compatibility
# Run adapter-backed tests
dotnet test src/Test.Xunit/Test.Xunit.csproj --framework net8.0
dotnet test src/Test.Nunit/Test.Nunit.csproj --framework net8.0
# Cross-target the console runner
dotnet run --project src/Test.Automated/Test.Automated.csproj --framework net10.0
# Native AOT smoke test: publish and run the native binary (use your runtime identifier, e.g. linux-x64, win-x64)
dotnet publish src/Test.Aot/Test.Aot.csproj -c Release -f net10.0 -r osx-arm64 -o artifacts/aot
./artifacts/aot/Test.Aot
# The same checks without publishing (reflection-based JSON is off in this project either way)
dotnet run --project src/Test.Aot/Test.Aot.csproj -c Release -f net10.0Continuous integration runs on every commit to any branch and on every pull request, on Linux, Windows, and macOS for net8.0 and net10.0:
- Tests: builds the solution, then runs the shared suite through the console, xUnit, and NUnit runners and the
Test.Aotchecks under the JIT. Test results are uploaded as artifacts. - Native AOT: publishes
Test.Aotas a native binary (linux-x64,win-x64,osx-arm64; trimming and AOT warnings are errors) and runs it.
Public types are grouped by protocol namespace:
Voltaic.Core:JsonRpcServer,JsonRpcClient, JSON-RPC request/response/error models, TCP framing, connection models, shared authentication/error helpers, theOriginPolicyandLoopbackAddressesaccess helpers,VoltaicTelemetry, andVoltaicJson(JSON metadata for trimming and Native AOT).Voltaic.Mcp: MCP stdio, HTTP, TCP, and WebSocket clients/servers plus MCP tools, resources, prompts, completions, capabilities, and utility models.Voltaic.A2A: A2A Agent Cards, task/message/artifact models,A2AClient,A2AHttpJsonClient,A2ACardResolver,A2AHttpServer, task storage, event queue, updater, and protocol errors.
The library source mirrors those namespaces:
src/Voltaic/Core: shared JSON-RPC, framing, connection, authentication, and event types.src/Voltaic/Mcp: MCP protocol models, endpoint infrastructure, clients, and servers.src/Voltaic/A2A: A2A protocol models, clients, servers, task infrastructure, JSON helpers, and gRPC wire support.src/Voltaic/A2A/Protos: the A2A protobuf contract used by the internal gRPC binding.
The core server pattern is the same across protocols: configure identity, register handlers or capabilities, subscribe to lifecycle events if needed, then start the server. Clients connect, call protocol methods, stream SSE events where supported, and dispose when finished.
For exact overloads and model types, use your IDE's IntelliSense, the generated XML documentation in src/Voltaic/Voltaic.xml, and the sample/test projects listed above. src/Test.Shared/API_COVERAGE.md tracks the public API areas covered by the Touchstone suite.
Voltaic is released under the MIT License. Use it freely in your projects, commercial or otherwise.
Need help or found a bug?
- Issues: Report bugs or request features at github.com/jchristn/voltaic/issues
- Discussions: Ask questions and share ideas at github.com/jchristn/voltaic/discussions
