Skip to content

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.

LitestarA2A Dual Transport Bindings and AgentExecutor Pipeline LitestarA2A serves /.well-known/agent-card.json, JSON-RPC 2.0 at /a2a, and A2A 1.0 HTTP+JSON REST routes (:cancel, :subscribe, /message:send, /message:stream) backed by DefaultRequestHandler and AgentExecutor. 1. Discovery & Clients /.well-known/agent-card.json agent_card_route_opt (public) Advertises JSONRPC & HTTP+JSON Lists AgentSkill capabilities A2A-Version: 1.0 header enable_http_json=True 2. Dual Transport Routes POST /a2a (JSON-RPC 2.0) POST /a2a/message:send POST /a2a/message:stream (SSE) GET /a2a/tasks & /tasks/{id} POST /a2a/tasks/{id}:cancel POST /a2a/tasks/{id}:subscribe 3. Request Handler DefaultRequestHandler Builds ServerCallContext context_builder hook InMemoryTaskStore / custom PushNotificationConfigStore ProtoJSON Event Serializer 4. Executor AgentExecutor execute(ctx, queue) cancel(ctx, queue) Or agent_to_a2a() Emits TaskStatus & ArtifactUpdate Official a2a-sdk 1.0 Wire Compliance Single A2AConfig mounts JSON-RPC 2.0, HTTP+JSON REST (:send, :stream, :cancel, :subscribe), and Push Notifications
Multi-Tenant Resolution and Split Route Security Multi-tenant resolution extracts tenant identity from /{tenant:str}/a2a path parameters or X-A2A-Tenant headers, passes it through context_builder into ServerCallContext.tenant, and separates public agent_card_route_opt from protected route_opt. 1. Tenant Extraction Sources Path Param + Header base_path="/{tenant:str}/a2a" Or Header: X-A2A-Tenant: acme Precedence: path param > header Applies to JSON-RPC & HTTP+JSON Tenant-scoped URLs in AgentCard 2. Context Builder & Auth ServerCallContext.tenant Default builder populates .tenant Custom A2AConfig.context_builder Forwards request.user & request.auth Enforces Litestar router guards context.state["litestar_request"] 3. Split Route Opt Security route_opt vs agent_card_route_opt route_opt={"auth": required(...)} Protects RPC & REST execution routes agent_card_route_opt={"auth": public()} Keeps /.well-known/agent-card.json open First-party litestar-security ready Zero-Leakage Tenant Isolation GET /acme/a2a/tasks?tenant=acme | X-A2A-Tenant: acme -> ServerCallContext(tenant="acme")

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
  1. Explicit Request Payload (params.tenant): If the parsed A2A protobuf request message carries a non-empty tenant field (for example SendMessageRequest.tenant or GetTaskRequest.tenant), that value is used first.
  2. URL Path Parameter (request.path_params["tenant"]): Every route mounted by LitestarA2A registers both a base path and a tenant-parameterized path:
  3. JSON-RPC 2.0: POST /a2a and POST /a2a/{tenant:str}
  4. HTTP+JSON REST: 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 configure A2AConfig(path="/{tenant:str}/a2a") if your deployment places the tenant prefix at the root of the URL hierarchy.
  5. Fallback HTTP Header (A2AConfig.tenant_header): When neither the payload nor the URL path specifies a tenant, LitestarA2A reads the header named by A2AConfig.tenant_header (default "X-A2A-Tenant"). Set tenant_header=None to 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:

docs/examples/snippets/a2a_http_json_multitenant.py
"""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:

  1. id
  2. sub
  3. username

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

docs/examples/a2a_application/main.py
@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: Calls await request.user.authorize_workspace(context.tenant) so any request naming an unauthorized workspace fails with PermissionDeniedException (HTTP 403) before reaching DefaultRequestHandler.
  • 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

docs/examples/a2a_application/main.py
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_resolver Across Task and Push Stores: Pass the exact same owner_resolver=resolve_owner function to both InMemoryTaskStore and InMemoryPushNotificationConfigStore so push notification configurations are bound to the same (tenant, user_name) boundary as tasks.
  • Globally Unique Task IDs & Immutable Ownership: Because DefaultRequestHandler tracks active in-flight tasks and push dispatch globally by task_id, custom persistent TaskStore backends 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, DefaultRequestHandler verifies task existence in the owner-scoped task_store before attaching to an active task queue for CancelTask or SubscribeToTask, ensuring a caller in Tenant B cannot cancel or subscribe to an in-flight task created by Tenant A.