Multi-Tenant A2A Routing¶
LitestarA2A supports multi-tenant agent deployments out of the box, combining URL path parameters (/a2a/{tenant:str}), payload tenant fields, and the X-A2A-Tenant request header with authoritative context_builder authorization and owner-scoped TaskStore isolation.
Tenant Resolution Precedence¶
On every incoming JSON-RPC or HTTP+JSON request, _JsonRpcTransport._resolve_tenant resolves the initial ServerCallContext.tenant string using a three-tier precedence order:
flowchart LR
Payload["1. Request Payload (params.tenant)"]
Path["2. URL Path (/a2a/{tenant:str}/...)"]
Header["3. HTTP Header (X-A2A-Tenant)"]
Builder["4. A2AConfig.context_builder"]
Store["5. Owner-Scoped TaskStore"]
Payload --> Builder
Path --> Builder
Header --> Builder
Builder --> Store
- Explicit Request Payload (
params.tenant): If the parsed A2A protobuf request message carries a non-emptytenantfield (for exampleSendMessageRequest.tenantorGetTaskRequest.tenant), that value is used first. - URL Path Parameter (
request.path_params["tenant"]): Every route mounted byLitestarA2Aregisters both a base path and a tenant-parameterized path: - JSON-RPC 2.0:
POST /a2aandPOST /a2a/{tenant:str} HTTP+JSONREST:POST /a2a/{tenant:str}/message:send,POST /a2a/{tenant:str}/message:stream,GET /a2a/{tenant:str}/tasks,GET /a2a/{tenant:str}/tasks/{id},POST /a2a/{tenant:str}/tasks/{id}:cancel,POST /a2a/{tenant:str}/tasks/{id}:subscribe, and push notification config routes. You can also configureA2AConfig(path="/{tenant:str}/a2a")if your deployment places the tenant prefix at the root of the URL hierarchy.- Fallback HTTP Header (
A2AConfig.tenant_header): When neither the payload nor the URL path specifies a tenant,LitestarA2Areads the header named byA2AConfig.tenant_header(default"X-A2A-Tenant"). Settenant_header=Noneto disable header fallback.
Authoritative context_builder Validation¶
A tenant string supplied in a URL path, header, or payload is an untrusted claim until verified against the caller's authenticated identity. Configure A2AConfig(context_builder=...) to validate tenant membership and enrich ServerCallContext:
"""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
Prepared ServerCallContext Contract¶
Before invoking context_builder(request, context), LitestarA2A populates context with:
ServerCallContext Property |
Initial Value |
|---|---|
context.user |
_LitestarUser(request.scope.get("user")) wrapping the Litestar principal |
context.tenant |
Candidate tenant string resolved from payload, path_params["tenant"], or X-A2A-Tenant |
context.requested_extensions |
Set of extension URIs parsed from A2A-Extensions request headers |
context.state["auth"] |
request.scope.get("auth") |
context.state["headers"] |
dict(request.headers) |
context.state["litestar_state"] |
request.scope.get("state", {}) |
context.state["method"] |
Canonical A2A 1.0 method name (for example "SendMessage", "GetTask", "CancelTask") |
context.state["request_id"] |
JSON-RPC request ID (or None on HTTP+JSON REST calls) |
context_builder may return the updated ServerCallContext synchronously or asynchronously. The returned ServerCallContext is authoritative: LitestarA2A never overwrites context.tenant, context.user, or context.state with raw request values after context_builder runs. If context_builder raises a Litestar HTTPException (such as NotAuthorizedException or PermissionDeniedException), LitestarA2A propagates the HTTP status and response headers directly to the client.
Principal user_name Resolution & Collision Guard¶
The A2A SDK's owner-scoped stores (InMemoryTaskStore and InMemoryPushNotificationConfigStore) identify callers through context.user.is_authenticated and context.user.user_name.
LitestarA2A wraps request.scope.get("user") in _LitestarUser, which resolves user_name by inspecting the principal (whether an object or a dict) for the first non-null attribute or key in:
idsubusername
If user.is_authenticated is True (which defaults to True whenever request.scope.get("user") is not None, unless the principal defines is_authenticated = False) and none of id, sub, or username is present on the principal, user.user_name would be empty ""—causing all authenticated users to share the anonymous task owner key. To prevent cross-user data leakage, LitestarA2A raises a RuntimeError (InternalError on the wire) unless your context_builder assigns a custom a2a.auth.user.User with a non-empty user_name to context.user.
Owner-Scoped TaskStore Isolation¶
In a multi-tenant deployment, tenant and principal isolation must hold across every task lifecycle operation (SendMessage, GetTask, ListTasks, CancelTask, SubscribeToTask, and push notification config CRUD).
The reference application in docs/examples/a2a_application/main.py demonstrates production-grade tenant and principal scoping with the official a2a-sdk:
1. Authentication Middleware, context_builder, and Collision-Safe owner_resolver¶
@dataclass(frozen=True)
class Principal:
id: str
workspaces: frozenset[str] = frozenset({"alpha", "beta"})
async def authorize_workspace(self, workspace: str) -> str:
"""Replace with an awaited membership lookup in the existing application."""
if workspace not in self.workspaces:
raise PermissionDeniedException(detail="Workspace membership required")
return workspace
class DemoAuthentication(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: "ASGIConnection[Any, Any, Any, Any]") -> AuthenticationResult:
credentials = {"Bearer demo-alice": Principal("alice"), "Bearer demo-bob": Principal("bob")}
principal = credentials.get(connection.headers.get("authorization", ""))
if principal is None:
raise NotAuthorizedException(
detail="Valid demonstration credentials required", headers={"WWW-Authenticate": "Bearer"}
)
return AuthenticationResult(user=principal, auth=None)
async def authorize_context(request: "Request[Principal, Any, Any]", context: ServerCallContext) -> ServerCallContext:
context.tenant = await request.user.authorize_workspace(context.tenant)
if EXTENSION_URI in context.requested_extensions:
context.state["a2a_activated_extensions"] = {EXTENSION_URI}
return context
def resolve_owner(context: ServerCallContext) -> str:
"""Share the same collision-safe owner key between both SDK stores."""
if not context.user.is_authenticated or not context.user.user_name or not context.tenant:
raise NotAuthorizedException(
detail="An authenticated workspace owner is required", headers={"WWW-Authenticate": "Bearer"}
)
return json.dumps([context.tenant, context.user.user_name], separators=(",", ":"))
authorize_context: Callsawait request.user.authorize_workspace(context.tenant)so any request naming an unauthorized workspace fails withPermissionDeniedException(HTTP 403) before reachingDefaultRequestHandler.resolve_owner: Encodes[context.tenant, context.user.user_name]as a compact JSON array (json.dumps([context.tenant, context.user.user_name], separators=(",", ":"))). Using structured JSON serialization prevents delimiter-collision attacks where a tenant named"alpha:alice"and user"bob"could otherwise collide with tenant"alpha"and user"alice:bob".
2. Wiring Scoped Stores and Executor Lifetimes into LitestarA2A¶
def create_app(
service_factory: Callable[[ServerCallContext], AbstractAsyncContextManager[ApplicationService]] = service_scope,
*,
task_store: TaskStore | None = None,
push_store: PushNotificationConfigStore | None = None,
push_sender: PushNotificationSender | None = None,
) -> Litestar:
card = AgentCard(
name="Workspace agent",
description="Authenticated application services through the official A2A SDK",
version="1.0.0",
security_schemes={
"bearerAuth": SecurityScheme(http_auth_security_scheme=HTTPAuthSecurityScheme(scheme="Bearer"))
},
security_requirements=[SecurityRequirement(schemes={"bearerAuth": StringList()})],
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[
AgentSkill(id="answer", name="Answer", description="Answer within an authorized workspace", tags=["demo"])
],
capabilities=AgentCapabilities(
streaming=True,
push_notifications=True,
extended_agent_card=True,
extensions=[
AgentExtension(uri=EXTENSION_URI, description="Prefix the answer with the authorized workspace")
],
),
supported_interfaces=[
AgentInterface(url="http://localhost:8000/a2a", protocol_binding="JSONRPC", protocol_version="1.0")
],
)
if push_store is None:
push_store = InMemoryPushNotificationConfigStore(owner_resolver=resolve_owner)
if task_store is None:
task_store = InMemoryTaskStore(owner_resolver=resolve_owner)
if push_sender is None:
push_sender = RecordingPushSender(push_store)
handler = WorkspaceTaskHandler(
agent_executor=ApplicationExecutor(service_factory),
task_store=task_store,
agent_card=card,
push_config_store=push_store,
push_sender=push_sender,
extended_agent_card=card,
)
return Litestar(
middleware=[DefineMiddleware(DemoAuthentication)],
plugins=[LitestarA2A(card, handler, A2AConfig(context_builder=authorize_context))],
)
app = create_app()
Key architectural invariants for multi-tenant TaskStore implementations:
- Share
owner_resolverAcross Task and Push Stores: Pass the exact sameowner_resolver=resolve_ownerfunction to bothInMemoryTaskStoreandInMemoryPushNotificationConfigStoreso push notification configurations are bound to the same(tenant, user_name)boundary as tasks. - Globally Unique Task IDs & Immutable Ownership: Because
DefaultRequestHandlertracks active in-flight tasks and push dispatch globally bytask_id, custom persistentTaskStorebackends must enforce globally unique task IDs and immutable(tenant, user_name)ownership across all tenants. - Live Cancellation & Subscription Checks: From
a2a-sdk>=1.1.4,DefaultRequestHandlerverifies task existence in the owner-scopedtask_storebefore attaching to an active task queue forCancelTaskorSubscribeToTask, ensuring a caller in Tenant B cannot cancel or subscribe to an in-flight task created by Tenant A.