Dispatchers (JSON-RPC, REST, Axum & gRPC)

Dispatchers translate HTTP/gRPC requests into handler calls. a2a-rust provides five built-in dispatchers: JsonRpcDispatcher, RestDispatcher, A2aRouter (axum feature), WebSocketDispatcher (websocket feature), and GrpcDispatcher (grpc feature).

JsonRpcDispatcher

Routes JSON-RPC 2.0 requests to the handler:

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_sdk::server::JsonRpcDispatcher;
use std::sync::Arc;

let dispatcher = Arc::new(JsonRpcDispatcher::new(handler));
Ok(())
}

Features

  • Single endpoint — All methods go to / as POST requests
  • Agent card — GET /.well-known/agent-card.json returns the agent card (same as REST)
  • Batch support — Handles JSON-RPC batch arrays
  • ID preservation — Echoes back the exact request ID (string, number, float, null). The client validates that response IDs match request IDs.
  • Streaming — SendStreamingMessage and SubscribeToTask return SSE streams
  • CORS — Configurable cross-origin headers
  • Content type — Accepts application/json and application/a2a+json
  • Version validation — Requires the A2A-Version header by default: a request without it (read as v0.3, spec §3.6.2) or with a major version other than 1 is refused with VersionNotSupported (-32009). DispatchConfig::accept_missing_version_header() admits header-less requests

Batch Restrictions

Streaming methods cannot appear in batch requests:

  • SendStreamingMessage in a batch → error response
  • SubscribeToTask in a batch → error response

An empty batch [] is answered with Invalid Request (-32600).

Batch size is limited by DispatchConfig::max_batch_size (default 100). Batches exceeding this limit are rejected with Invalid Request (-32600) before any individual request is dispatched.

DispatchConfig

Both JSON-RPC and REST dispatchers share a DispatchConfig for transport-level limits:

FieldTypeDefaultDescription
max_request_body_sizeusize4 MiBMaximum request body size in bytes
body_read_timeoutDuration30 secondsTimeout for reading the full request body
max_query_string_lengthusize4096Maximum query string length (RestDispatcher only; A2aRouter leaves query parsing to axum)
sse_keep_alive_intervalDuration30 secondsPeriodic : keep-alive comment interval for SSE
sse_channel_capacityusize64Backpressure channel between event reader and HTTP response
max_batch_sizeusize100Maximum requests in a JSON-RPC batch
require_version_headerbooltrueRefuse requests without an A2A-Version header (accept_missing_version_header() turns it off)

RestDispatcher

Routes RESTful HTTP requests to the handler:

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_sdk::server::RestDispatcher;
use std::sync::Arc;

let dispatcher = Arc::new(RestDispatcher::new(handler));
Ok(())
}

Route Table

MethodPathHandler
POST/message:sendSendMessage
POST/message:streamSendStreamingMessage
GET/tasksListTasks
GET/tasks/{id}GetTask
POST/tasks/{id}:cancelCancelTask
GET|POST/tasks/{id}:subscribeSubscribeToTask
GET/extendedAgentCardGetExtendedAgentCard
POST/tasks/{id}/pushNotificationConfigsCreatePushConfig
GET/tasks/{id}/pushNotificationConfigsListPushConfigs
GET/tasks/{id}/pushNotificationConfigs/{cfgId}GetPushConfig
DELETE/tasks/{id}/pushNotificationConfigs/{cfgId}DeletePushConfig
GET/.well-known/agent-card.jsonAgentCard

Multi-Tenancy

Tenant-scoped routes accept two forms — the canonical bare-segment form from the spec proto's google.api.http additional bindings (what official-SDK REST clients send), and this SDK's original explicit prefix:

# Canonical form
GET  /acme-corp/tasks
POST /acme-corp/message:send

# Explicit form
GET  /tenants/acme-corp/tasks
POST /tenants/acme-corp/message:send

In the canonical form, literal route segments always win over the tenant variable (a tenant named like a route head must use the explicit form).

Built-in Security

The REST dispatcher includes automatic protections:

ProtectionBehavior
Path traversal.. in path segments (including %2E%2E, %2e%2e) → 400
Query string sizeOver 4 KiB → 414
Body sizeOver 4 MiB → 413
Content typeAccepts application/json and application/a2a+json

Server Startup

Both dispatchers implement the Dispatcher trait, so you can use the serve() helper to eliminate hyper boilerplate:

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_server::serve::{serve, serve_with_addr};

// Blocking — runs the accept loop on the current task
serve("127.0.0.1:3000", JsonRpcDispatcher::new(handler.clone())).await?;

// Non-blocking — spawns the server and returns the bound address
let addr = serve_with_addr("127.0.0.1:0", RestDispatcher::new(handler)).await?;
println!("Listening on {addr}");
Ok(())
}

Manual wiring (advanced)

Both dispatchers also expose a dispatch method for direct hyper integration (this uses hyper-util with its server-auto feature):

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
use std::sync::Arc;

async fn start_server(
    dispatcher: Arc<JsonRpcDispatcher>,
    addr: &str,
) {
    let listener = tokio::net::TcpListener::bind(addr)
        .await
        .expect("bind");

    loop {
        let (stream, _) = listener.accept().await.expect("accept");
        let io = hyper_util::rt::TokioIo::new(stream);
        let dispatcher = Arc::clone(&dispatcher);

        tokio::spawn(async move {
            let service = hyper::service::service_fn(move |req| {
                let d = Arc::clone(&dispatcher);
                async move {
                    Ok::<_, std::convert::Infallible>(d.dispatch(req).await)
                }
            });

            let _ = hyper_util::server::conn::auto::Builder::new(
                hyper_util::rt::TokioExecutor::new(),
            )
            .serve_connection(io, service)
            .await;
        });
    }
}
}

No web framework required — the dispatchers work directly with hyper's service layer.

WebSocketDispatcher

Provides bidirectional A2A communication over WebSocket. Enable with the websocket feature flag:

a2a-protocol-server = { version = "0.14", features = ["websocket"] }
use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_server::WebSocketDispatcher;
use std::sync::Arc;

let dispatcher = Arc::new(WebSocketDispatcher::new(handler));

// Blocking server
dispatcher.clone().serve("0.0.0.0:3000").await?;

// Non-blocking (returns bound address)
let addr = dispatcher.serve_with_addr("127.0.0.1:0").await?;
Ok(())
}

Protocol

  • Client sends JSON-RPC 2.0 requests as WebSocket text frames
  • Server responds with JSON-RPC 2.0 responses as text frames
  • Streaming methods (SendStreamingMessage, SubscribeToTask) send one frame per event, followed by a final JSON-RPC success response
  • The full A2A method surface is routed — the same method names as JsonRpcDispatcher, including the push-notification-config methods and GetExtendedAgentCard. Of the v0.3 method/verb spellings only message/stream is accepted; the others are refused with MethodNotFound (-32601), as they are over HTTP

Authentication and tenancy

The HTTP headers of the upgrade request (lowercased, plus the request path under ":path") are captured during the handshake and passed to the handler for every request on the connection. Tenant resolvers, strict multi-tenancy, and header-based authentication behave exactly as they do over HTTP — credentials are presented once, at connect time, and apply to the whole connection — except that a refused credential is answered only in the JSON-RPC body (-32600), since interceptors run per message after the upgrade; see Authentication. An upgrade request with no A2A-Version header, or one naming an unsupported major version, is rejected during the handshake with HTTP 400 (accept_missing_version_header() admits the former).

Built-in Limits

LimitValueDescription
Concurrent tasks per connection64Per-connection Semaphore(64) prevents unbounded task spawning
Incoming message size4 MiBOversized WebSocket frames are rejected at the protocol level
Handshake timeout10 s (configurable via with_handshake_timeout)A peer that never completes the upgrade is disconnected instead of pinning a connection
Concurrent connectionsunbounded (with_max_connections)The permit is taken before accept(), so load past the ceiling waits in the kernel's listen backlog rather than as spawned tasks
Idle connectionoff (with_idle_timeout)Closes a connection carrying no traffic in either direction for the given period

Transient accept() errors (per-connection aborts, fd-table exhaustion) never terminate the accept loop — it retries with the same backoff policy as the HTTP serve path.

Bounding a connection after the handshake

The handshake timeout only covers the part before the upgrade completes. A peer that completes it and then goes silent held a task, a socket and a file descriptor for the life of the process. Two knobs bound that, both opt-in:

use a2a_protocol_sdk::prelude::*;
use a2a_protocol_server::WebSocketDispatcher;
use std::sync::Arc;
use std::time::Duration;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() {
let handler = Arc::new(RequestHandlerBuilder::new(MyAgent).build().expect("handler"));
let dispatcher = Arc::new(
    WebSocketDispatcher::new(handler)
        .with_max_connections(1024)
        .with_idle_timeout(Duration::from_secs(75)),
);
}

with_idle_timeout is off by default, unlike the equivalent on ServeConfig for the HTTP bindings, and the difference is deliberate. On HTTP, silence means nothing is happening. On a WebSocket it may mean a subscription is waiting for its next event, which is a legitimate thing to do for hours — a default that closed those would be a knob nobody enables.

What makes it safe to enable: at the halfway point of the budget the server sends a WebSocket Ping. Every conformant client library answers it automatically, and that Pong is traffic. So the timeout closes peers that are unresponsive, not peers that are merely quiet. Outbound frames count too, so a stream pushing events to a silent consumer keeps its own connection alive.

GrpcDispatcher

Routes gRPC requests to the handler via tonic. Enable with the grpc feature flag (plaintext listener) or grpc-tls to serve TLS on the listener itself with with_tls — see Transport Layers:

a2a-protocol-server = { version = "0.14", features = ["grpc"] }
use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_server::{GrpcDispatcher, GrpcConfig};
use std::sync::Arc;

let config = GrpcConfig::default()
    .with_max_message_size(8 * 1024 * 1024)
    .with_concurrency_limit(128);

// Blocking server (`serve` consumes the dispatcher)
GrpcDispatcher::new(Arc::clone(&handler), config.clone())
    .serve("0.0.0.0:50051")
    .await?;

// Non-blocking (returns bound address)
let addr = GrpcDispatcher::new(Arc::clone(&handler), config.clone())
    .serve_with_addr("127.0.0.1:0")
    .await?;
println!("gRPC listening on {addr}");

// Pre-bind pattern (when you need the address before building the handler)
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
// ... build handler using addr for agent card URL ...
let dispatcher = GrpcDispatcher::new(handler, config);
let bound = dispatcher.serve_with_listener(listener)?;
Ok(())
}

GrpcConfig

FieldTypeDefaultDescription
max_message_sizeusize4 MiBMaximum inbound/outbound message size
concurrency_limitusize256Maximum concurrent gRPC requests per connection
stream_channel_capacityusize64Bounded channel for streaming responses
require_version_headerbooltrueRefuse a request whose a2a-version metadata is absent or empty (with_require_version_header(false) admits it)

Bounding an idle gRPC connection

GrpcConfig bounds message size and per-connection concurrency. Neither bounds a connection that simply exists: measured, 400 TCP connections opened and left silent were all accepted, and the oldest was still alive twelve seconds later.

use a2a_protocol_sdk::prelude::*;
use a2a_protocol_server::dispatch::grpc::{GrpcConfig, GrpcDispatcher};
use std::sync::Arc;
use std::time::Duration;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() {
let handler = Arc::new(RequestHandlerBuilder::new(MyAgent).build().expect("handler"));
let dispatcher = GrpcDispatcher::new(handler, GrpcConfig::default())
    .with_http2_keepalive(Duration::from_secs(30), Duration::from_secs(10))
    .with_max_connection_age(Duration::from_secs(600))
    .with_max_connections(1024);
}

All three are opt-in. with_max_connections is the ceiling the other two do not provide — keepalive closes a peer that stops answering, and nothing bounded how many peers could be answering at once. As on the WebSocket dispatcher, the permit is taken before accept(), so load past the ceiling waits in the kernel's listen backlog and is refused by the kernel when that fills, rather than being accepted and then dropped. The other two: HTTP/2 keepalive PINGs an idle connection and closes it if the peer does not answer — and a conformant client's HTTP/2 stack answers without the application being involved, so this closes peers that are unresponsive, not peers that are merely quiet. A streaming RPC waiting for its next event is left alone. with_max_connection_age is the different question: it bounds a peer that answers perfectly and never leaves, which is what makes a fleet behind a load balancer drift into imbalance. tonic sends GOAWAY and drains in-flight RPCs, so it is a reconnect rather than a failure.

Protocol

All 11 A2A methods are served on the canonical lf.a2a.v1.A2AService with fully-typed protobuf messages generated from the A2A specification's schema — wire-compatible with the official Go, Python, and Java SDKs. Requests and responses convert to and from the serde domain types through a fallible TryFrom layer (ProtoJSON semantics; see ADR 0009).

Streaming methods (SendStreamingMessage, SubscribeToTask) use gRPC server streaming. The pre-0.7 JSON-in-bytes tunnel (a2a.v1.A2aService), served alongside the canonical service through 0.7 behind the off-by-default grpc-legacy-json feature, was removed in 0.8. The canonical service is the only gRPC surface.

Custom Server Setup

For advanced scenarios, use into_service() to get a tonic service:

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
let dispatcher = a2a_protocol_server::GrpcDispatcher::new(handler, a2a_protocol_server::GrpcConfig::default());
let addr: std::net::SocketAddr = "127.0.0.1:50051".parse()?;
let svc = dispatcher.into_service();
tonic::transport::Server::builder()
    .add_service(svc)
    .serve(addr)
    .await?;
Ok(())
}

A2aRouter (Axum)

For projects already using Axum, the axum feature provides A2aRouter — an idiomatic adapter that wraps RequestHandler as an axum::Router:

a2a-protocol-server = { version = "0.14", features = ["axum"] }
use a2a_protocol_sdk::prelude::*;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let card = AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000"));
use a2a_protocol_server::A2aRouter;
use std::sync::Arc;

let handler = Arc::new(
    RequestHandlerBuilder::new(MyExecutor)
        .with_agent_card(card)
        .build()
        .unwrap(),
);

let app = A2aRouter::new(handler).into_router();

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}

Composability

The returned Router can be merged with other Axum routes and middleware:

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
async fn custom_handler() -> &'static str { "ok" }
fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_server::A2aRouter;
let app = axum::Router::new()
    .merge(A2aRouter::new(handler).into_router())
    .route("/custom", axum::routing::get(custom_handler));
Ok(())
}

Routes

All 11 A2A REST methods are mapped, plus health check and agent card discovery. Streaming methods return SSE responses. The router delegates entirely to RequestHandler — zero business logic duplication.

Running Multiple Transports

Serve JSON-RPC and REST on different ports with the same handler:

use a2a_protocol_sdk::prelude::*;
use std::sync::Arc;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
fn make_agent_card(jsonrpc_url: &str, rest_url: &str) -> AgentCard {
    AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc(jsonrpc_url))
        .with_interface(AgentInterface::rest(rest_url))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use a2a_protocol_server::serve::serve_with_addr;

let handler = Arc::new(
    RequestHandlerBuilder::new(MyExecutor)
        .with_agent_card(make_agent_card("http://localhost:3000", "http://localhost:3001"))
        .build()
        .unwrap(),
);

// JSON-RPC on port 3000
let jsonrpc_addr = serve_with_addr("127.0.0.1:3000", JsonRpcDispatcher::new(Arc::clone(&handler))).await?;

// REST on port 3001
let rest_addr = serve_with_addr("127.0.0.1:3001", RestDispatcher::new(handler)).await?;
Ok(())
}

CORS Configuration

Both dispatchers support CORS for browser-based clients. It is off until configured: without with_cors, no response carries CORS headers — an OPTIONS preflight included — so a browser refuses the cross-origin call.

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() -> Result<(), Box<dyn std::error::Error>> {
let handler = std::sync::Arc::new(RequestHandlerBuilder::new(MyAgent).build()?);
use a2a_protocol_sdk::server::CorsConfig;

// Answers OPTIONS preflights, and adds the CORS headers to every response.
let dispatcher = JsonRpcDispatcher::new(handler)
    .with_cors(CorsConfig::new("https://my-app.example.com"));
Ok(())
}

Next Steps