A2A 1.0 Protocol & HTTP+JSON¶
LitestarA2A (litestar_mcp.a2a.LitestarA2A, enabled via litestar-mcp[a2a]) provides a first-party, Litestar-native transport layer for the official Agent-to-Agent Python SDK (a2a-sdk>=1.1.4). It mounts /.well-known/agent-card.json discovery, A2A 1.0 JSON-RPC 2.0 (POST /a2a), and native A2A 1.0 HTTP+JSON REST routes without requiring Starlette, FastAPI, or Uvicorn.
Quickstart with LitestarA2A¶
Pass an official a2a.types.AgentCard and either an a2a.server.request_handlers.RequestHandler (such as DefaultRequestHandler) or an a2a.server.agent_execution.AgentExecutor directly to LitestarA2A:
from uuid import uuid4
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Message, Part, Role
from a2a.utils.errors import UnsupportedOperationError
from litestar import Litestar
from litestar_mcp.a2a import LitestarA2A
class EchoExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
await event_queue.enqueue_event(
Message(message_id=str(uuid4()), role=Role.ROLE_AGENT, parts=[Part(text=context.get_user_input())])
)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise UnsupportedOperationError(message="This agent returns messages without creating tasks")
def build() -> Litestar:
card = AgentCard(
name="Support agent",
description="Answers support questions",
version="1.0.0",
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[AgentSkill(id="echo", name="Echo", description="Repeat a message", tags=["example"])],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(
url="https://example.com/a2a",
protocol_binding="JSONRPC",
protocol_version="1.0",
)
],
)
return Litestar(plugins=[LitestarA2A(card, EchoExecutor())])
When you pass an AgentExecutor instance instead of a RequestHandler, LitestarA2A.__init__ automatically wraps it in DefaultRequestHandler(agent_executor=request_handler, task_store=InMemoryTaskStore(), agent_card=agent_card).
A2AConfig Reference¶
Configure mount paths, transport bindings, route security options, caching, and stream cleanup via A2AConfig (litestar_mcp.a2a.A2AConfig):
| Field | Type | Default | Description |
|---|---|---|---|
path |
str |
"/a2a" |
Base mount path for A2A JSON-RPC and HTTP+JSON routes. Must start with /; trailing slashes are normalized. |
agent_card_path |
str |
"/.well-known/agent-card.json" |
Public route serving the serialized AgentCard. Must start with /. |
guards |
Sequence[Guard] |
() |
Litestar route guards applied to JSON-RPC and HTTP+JSON execution routes (the public agent card route is unguarded by A2AConfig.guards). |
route_opt |
dict[str, Any] |
{} |
Extra Litestar opt entries merged into JSON-RPC and HTTP+JSON routes alongside {"exclude_from_csrf": True}. |
agent_card_route_opt |
dict[str, Any] |
{} |
Extra Litestar opt entries merged into the agent card route alongside {"exclude_from_auth": True, "exclude_from_csrf": True}. |
tenant_header |
str \| None |
"X-A2A-Tenant" |
Fallback HTTP header inspected when neither the request payload nor /{tenant:str} URL path specifies a tenant. |
enable_http_json |
bool |
True |
Mount native A2A 1.0 HTTP+JSON REST routes alongside the JSON-RPC 2.0 endpoint. |
context_builder |
Callable \| None |
None |
Optional (request, context) -> ServerCallContext \| Awaitable[ServerCallContext] callback that authorizes and returns the authoritative ServerCallContext. |
include_in_schema |
bool |
False |
Whether to include A2A transport and card routes in the application's OpenAPI schema. |
agent_card_max_age |
int |
300 |
Cache-Control: public, max-age=<seconds> header value on /.well-known/agent-card.json. Must be >= 0. |
stream_cleanup_timeout |
float |
5.0 |
Positive finite timeout (in seconds) bounding cooperative SSE producer cleanup after a stream response closes. |
Agent Card Discovery (/.well-known/agent-card.json)¶
At application initialization (on_app_init), LitestarA2A validates agent_card and pre-renders its ProtoJSON payload:
- Interface Validation (
_validate_card):agent_card.supported_interfacesmust advertise at least one absolutehttp://orhttps://URL forprotocol_version="1.0"(or a1.0.xpatch version) withprotocol_binding="JSONRPC"or"HTTP+JSON"whose URL path ends withA2AConfig.path. Using a suffix check allows theAgentCardto advertise a public reverse-proxy URL or ASGIroot_pathprefix. - SHA-256
ETag&304 Not Modified:LitestarA2Acomputes a quoted SHA-256ETag("<sha256>") over the serialized card bytes and setsCache-Control: public, max-age={agent_card_max_age}. IncomingGET /.well-known/agent-card.jsonrequests with a matchingIf-None-Matchheader (using RFC 9110 weak comparison, includingW/prefixes and*) receive an empty304 Not Modifiedresponse. - Route Collision Detection: If any existing route on the Litestar application already occupies
A2AConfig.pathorA2AConfig.agent_card_path,on_app_initraisesValueError("A2A route collision: ..."). - Skill Metadata &
agent_card_route_opt: When bridging agents withagent_to_a2a, attachedSkillControllerinstances automatically compile intoAgentSkillentries on the card viaSkillController.to_agent_skill(). When configuringLitestarA2Adirectly, you can tag routes and card handlers viaagent_card_route_opt(for exampleagent_card_route_opt={"a2a_skill": ...}or public security policy markers) while keeping execution routes protected byroute_opt.
Note: An
AgentCard'sAgentSkillentries describe high-level A2A task capabilities advertised to peer agents and are distinct from MCP Agent Skills (SEP-2640SKILL.mdbundles served over/mcpviaskills/listandskills/load).
Dual Transport Bindings: JSON-RPC 2.0 & HTTP+JSON REST¶
Every request to an A2A execution endpoint must include the A2A-Version: 1.0 header (1.0.x patch versions are also accepted). Missing, malformed, or legacy (0.3) version headers are rejected with VersionNotSupportedError.
1. JSON-RPC 2.0 Binding (POST /a2a and POST /a2a/{tenant:str})¶
The JSON-RPC transport dispatches all 11 A2A 1.0 methods using protobuf JSON (ParseDict / MessageToDict):
| A2A 1.0 JSON-RPC Method | Handler Method | Response Type |
|---|---|---|
SendMessage |
handler.on_message_send |
JSON-RPC SendMessageResponse (task or message) |
SendStreamingMessage |
handler.on_message_send_stream |
text/event-stream (ServerSentEvent) |
GetTask |
handler.on_get_task |
JSON-RPC Task (or TaskNotFoundError) |
ListTasks |
handler.on_list_tasks |
JSON-RPC ListTasksResponse (strips artifacts when include_artifacts=False) |
CancelTask |
handler.on_cancel_task |
JSON-RPC Task (or TaskNotFoundError) |
SubscribeToTask |
handler.on_subscribe_to_task |
text/event-stream (ServerSentEvent) |
CreateTaskPushNotificationConfig |
handler.on_create_task_push_notification_config |
JSON-RPC TaskPushNotificationConfig |
GetTaskPushNotificationConfig |
handler.on_get_task_push_notification_config |
JSON-RPC TaskPushNotificationConfig |
ListTaskPushNotificationConfigs |
handler.on_list_task_push_notification_configs |
JSON-RPC ListTaskPushNotificationConfigsResponse |
DeleteTaskPushNotificationConfig |
handler.on_delete_task_push_notification_config |
JSON-RPC null result |
GetExtendedAgentCard |
handler.on_get_extended_agent_card |
JSON-RPC AgentCard |
JSON-RPC notifications (well-formed requests without an "id" member) are declined with HTTP 204 No Content before version checking and never invoke the handler. Requests with an explicit "id": null are treated as correlated requests and receive a JSON-RPC response.
2. Native HTTP+JSON REST Binding (enable_http_json=True)¶
When A2AConfig.enable_http_json is True (the default), LitestarA2A mounts the full A2A 1.0 HTTP+JSON REST surface under A2AConfig.path (and its tenant-prefixed counterpart /{base_prefix}/{tenant:str}...), returning application/a2a+json on unary responses and structured REST error payloads (build_rest_error_payload) on failures:
| HTTP Method & Route Path | Tenant-Prefixed Route Path | A2A Operation |
|---|---|---|
GET /.well-known/agent-card.json |
(public discovery) | Public AgentCard with ETag and Cache-Control |
POST /a2a/message:send |
POST /a2a/{tenant:str}/message:send |
SendMessage (SendMessageRequest JSON body) |
POST /a2a/message:stream |
POST /a2a/{tenant:str}/message:stream |
SendStreamingMessage (SSE stream) |
GET /a2a/tasks |
GET /a2a/{tenant:str}/tasks |
ListTasks (query parameters parsed via proto_utils.parse_params) |
GET /a2a/tasks/{id} |
GET /a2a/{tenant:str}/tasks/{id} |
GetTask |
POST /a2a/tasks/{id}:cancel |
POST /a2a/{tenant:str}/tasks/{id}:cancel |
CancelTask |
GET or POST /a2a/tasks/{id}:subscribe |
GET or POST /a2a/{tenant:str}/tasks/{id}:subscribe |
SubscribeToTask (SSE stream) |
POST /a2a/tasks/{id}/pushNotificationConfigs |
POST /a2a/{tenant:str}/tasks/{id}/pushNotificationConfigs |
CreateTaskPushNotificationConfig |
GET /a2a/tasks/{id}/pushNotificationConfigs |
GET /a2a/{tenant:str}/tasks/{id}/pushNotificationConfigs |
ListTaskPushNotificationConfigs |
GET /a2a/tasks/{id}/pushNotificationConfigs/{push_id} |
GET /a2a/{tenant:str}/tasks/{id}/pushNotificationConfigs/{push_id} |
GetTaskPushNotificationConfig |
DELETE /a2a/tasks/{id}/pushNotificationConfigs/{push_id} |
DELETE /a2a/{tenant:str}/tasks/{id}/pushNotificationConfigs/{push_id} |
DeleteTaskPushNotificationConfig |
GET /a2a/extendedAgentCard |
GET /a2a/{tenant:str}/extendedAgentCard |
GetExtendedAgentCard (authenticated extended card) |
"""Snippet: LitestarA2A with A2A 1.0 HTTP+JSON REST routes, tenant header resolution, and context_builder."""
# start-example
from typing import Any
from uuid import uuid4
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.context import ServerCallContext
from a2a.server.events import EventQueue
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Message, Part, Role
from a2a.utils.errors import UnsupportedOperationError
from litestar import Litestar, Request, get
from litestar.exceptions import PermissionDeniedException
from litestar_mcp.a2a import A2AConfig, LitestarA2A
ALLOWED_TENANTS = frozenset({"acme", "globex"})
@get(
"/tickets/{ticket_id:str}",
opt={
"a2a_skill": {
"id": "ticket_lookup",
"name": "Ticket Lookup",
"description": "Retrieve tenant support ticket status by identifier.",
"tags": ["support", "tickets"],
}
},
)
async def get_ticket(ticket_id: str) -> dict[str, str]:
"""Retrieve a tenant support ticket by identifier."""
return {"ticket_id": ticket_id, "status": "open"}
class TenantSupportExecutor(AgentExecutor):
"""Execute A2A requests within the validated tenant context."""
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Enqueue a tenant-scoped agent response message."""
tenant = context.call_context.tenant if context.call_context is not None else "default"
reply_text = f"[{tenant}] Processed: {context.get_user_input()}"
await event_queue.enqueue_event(
Message(
message_id=str(uuid4()),
role=Role.ROLE_AGENT,
parts=[Part(text=reply_text)],
)
)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Report cancellation as unsupported for stateless message responses."""
raise UnsupportedOperationError(message="Cancellation is not supported for stateless messages")
async def authorize_tenant(request: Request[Any, Any, Any], context: ServerCallContext) -> ServerCallContext:
"""Validate the resolved A2A tenant from URL path or X-A2A-Tenant header."""
del request
if context.tenant not in ALLOWED_TENANTS:
raise PermissionDeniedException(detail="Authorized tenant is required")
context.state["tenant"] = context.tenant
return context
card = AgentCard(
name="Multi-Tenant Support Agent",
description="Handles tenant-isolated support inquiries over JSON-RPC 2.0 and HTTP+JSON REST",
version="1.0.0",
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[
AgentSkill(
id="ticket_lookup",
name="Ticket Lookup",
description="Retrieve tenant support ticket status by identifier.",
tags=["support", "tickets"],
)
],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[
AgentInterface(
url="https://agents.example.com/a2a",
protocol_binding="JSONRPC",
protocol_version="1.0",
),
AgentInterface(
url="https://agents.example.com/a2a",
protocol_binding="HTTP+JSON",
protocol_version="1.0",
),
],
)
config = A2AConfig(
path="/a2a",
enable_http_json=True,
tenant_header="X-A2A-Tenant",
route_opt={"requires_tenant": True},
agent_card_route_opt={"a2a_skill": "public_card"},
context_builder=authorize_tenant,
)
app = Litestar(
route_handlers=[get_ticket],
plugins=[LitestarA2A(card, TenantSupportExecutor(), config)],
)
# end-example
def build() -> Litestar:
"""Return the configured Litestar application for snippet smoke testing."""
return app
Resource Lifetime & Cooperative SSE Cleanup¶
Streaming A2A operations (SendStreamingMessage and SubscribeToTask across both JSON-RPC and HTTP+JSON) run inside a background producer task managed by start_stream, prefetch_stream, and StreamCleanupMiddleware:
- First-Event Prefetch:
prefetch_streamawaits the first event (or early error/header activation) before committing response headers so extension headers and initial setup errors are reflected accurately. - Bounded Backpressure & Disconnect Cleanup: A bounded
anyiomemory object stream (capacity=1) provides backpressure between theRequestHandlerasync generator and the ASGI response writer. When the client disconnects or the stream completes,A2AConfig.stream_cleanup_timeout(default5.0seconds) bounds the wait for the producer'sasync with aclosing(stream):finalizer. - Executor Service Scopes: Because Litestar HTTP route handlers return a
ServerSentEventresponse object before the stream finishes (and because background tasks may continue afterreturnImmediatelyor client disconnect),AgentExecutorimplementations must open their own service or Dishka request scopes from the verifiedcontext.call_contextrather than relying on request-scoped HTTP dependencies. - Application Shutdown Hook:
LitestarA2A.on_shutdownautomatically callsawait request_handler.aclose()(when defined) during Litestar shutdown.
Extension Activation (a2a_activated_extensions)¶
A2A 1.0 extensions declared in AgentCard.capabilities.extensions (AgentExtension(uri=..., required=...)) are negotiated per request via the A2A-Extensions HTTP header:
- Required Extensions: Before invoking
context_builderor the handler,LitestarA2Achecks that everyAgentExtensionwithrequired=Trueis present incontext.requested_extensions. If any required extension URI is missing,LitestarA2AraisesExtensionSupportRequiredError. - Activating Requested Extensions: To activate one or more requested extensions for a call, store a
set[str]of requested, advertised extension URIs incontext.state["a2a_activated_extensions"]insidecontext_builderor inside yourRequestHandler/AgentExecutorbefore returning a unary result or yielding the first SSE stream event: - Response Header Emission:
LitestarA2Avalidates thatcontext.state["a2a_activated_extensions"]is a subset ofrequested & advertisedsafe header URIs and emitsA2A-Extensions: <comma-separated sorted URIs>on the HTTP response.