Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions boms/extras/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@
<artifactId>a2a-java-queue-manager-replication-mp-reactive</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>a2a-java-extras-multitenancy</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

Expand Down
4 changes: 4 additions & 0 deletions boms/extras/src/it/extras-usage-test/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-queue-manager-replication-mp-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-extras-multitenancy</artifactId>
</dependency>

<!-- Core SDK modules (inherited from SDK BOM via Extras BOM) -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import org.a2aproject.sdk.spec.TaskIdParams;
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
import org.a2aproject.sdk.spec.TaskQueryParams;
import org.a2aproject.sdk.spec.util.Utils;
import io.grpc.Channel;
import io.grpc.Metadata;
import io.grpc.StatusException;
Expand Down Expand Up @@ -84,6 +85,9 @@ public GrpcTransport(Channel channel, AgentCard agentCard, @Nullable String agen
this.blockingStub = A2AServiceGrpc.newBlockingV2Stub(channel);
this.agentCard = agentCard;
this.interceptors = interceptors;
if (agentTenant != null && !agentTenant.isBlank()) {
Utils.validateTenant(agentTenant);
}
this.agentTenant = agentTenant == null || agentTenant.isBlank() ? "" : agentTenant;
}

Expand Down Expand Up @@ -352,11 +356,9 @@ private MessageSendParams createRequestWithTenant(MessageSendParams request) {

@Override
public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullable ClientCallContext context) throws A2AClientException {
GetExtendedAgentCardRequest.Builder builder = GetExtendedAgentCardRequest.newBuilder();
if (params.tenant() != null) {
builder.setTenant(params.tenant());
}
GetExtendedAgentCardRequest request = builder.build();
GetExtendedAgentCardRequest request = GetExtendedAgentCardRequest.newBuilder()
.setTenant(resolveTenant(params.tenant()))
.build();
PayloadAndHeaders payloadAndHeaders = applyInterceptors(GET_EXTENDED_AGENT_CARD_METHOD, request, agentCard, context);

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallCont
agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SEND_MESSAGE_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, SEND_MESSAGE_METHOD);
SendMessageResponse response = unmarshalResponse(httpResponseBody, SEND_MESSAGE_METHOD);
return response.getResult();
} catch (A2AClientException e) {
Expand All @@ -120,7 +120,7 @@ public void sendMessageStreaming(MessageSendParams request, Consumer<StreamingEv
SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer);

try {
A2AHttpClient.PostBuilder builder = createPostBuilder(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SEND_STREAMING_MESSAGE_METHOD);
A2AHttpClient.PostBuilder builder = createPostBuilder(agentInterface.url(), payloadAndHeaders, SEND_STREAMING_MESSAGE_METHOD);
ref.set(builder.postAsyncSSE(
event -> sseEventListener.onMessage(event, ref.get()),
throwable -> sseEventListener.onError(throwable, ref.get()),
Expand All @@ -144,7 +144,7 @@ public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context
agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, GET_TASK_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_TASK_METHOD);
GetTaskResponse response = unmarshalResponse(httpResponseBody, GET_TASK_METHOD);
return response.getResult();
} catch (A2AClientException e) {
Expand All @@ -161,7 +161,7 @@ public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext con
agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, CANCEL_TASK_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, CANCEL_TASK_METHOD);
CancelTaskResponse response = unmarshalResponse(httpResponseBody, CANCEL_TASK_METHOD);
return response.getResult();
} catch (A2AClientException e) {
Expand All @@ -177,7 +177,7 @@ public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallCo
PayloadAndHeaders payloadAndHeaders = applyInterceptors(LIST_TASK_METHOD, ProtoUtils.ToProto.listTasksParams(request),
agentCard, context);
try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, LIST_TASK_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, LIST_TASK_METHOD);
ListTasksResponse response = unmarshalResponse(httpResponseBody, LIST_TASK_METHOD);
return response.getResult();
} catch (IOException | InterruptedException | JsonProcessingException e) {
Expand All @@ -193,7 +193,7 @@ public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPu
ProtoUtils.ToProto.createTaskPushNotificationConfigRequest(request), agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
CreateTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
return response.getResult();
} catch (A2AClientException e) {
Expand All @@ -211,7 +211,7 @@ public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPu
ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(request), agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
GetTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
return response.getResult();
} catch (A2AClientException e) {
Expand All @@ -230,7 +230,7 @@ public ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurati
ProtoUtils.ToProto.listTaskPushNotificationConfigsRequest(request), agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
ListTaskPushNotificationConfigsResponse response = unmarshalResponse(httpResponseBody, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
return response.getResult();
} catch (A2AClientException e) {
Expand All @@ -248,7 +248,7 @@ public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationC
ProtoUtils.ToProto.deleteTaskPushNotificationConfigRequest(request), agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
DeleteTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
// Response validated (no error), but no result to return
} catch (A2AClientException e) {
Expand All @@ -270,7 +270,7 @@ public void subscribeToTask(TaskIdParams request, Consumer<StreamingEventKind> e
SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer);

try {
A2AHttpClient.PostBuilder builder = createPostBuilder(Utils.buildBaseUrl(agentInterface, request.tenant()), payloadAndHeaders, SUBSCRIBE_TO_TASK_METHOD);
A2AHttpClient.PostBuilder builder = createPostBuilder(agentInterface.url(), payloadAndHeaders, SUBSCRIBE_TO_TASK_METHOD);
ref.set(builder.postAsyncSSE(
event -> sseEventListener.onMessage(event, ref.get()),
throwable -> sseEventListener.onError(throwable, ref.get()),
Expand All @@ -294,7 +294,7 @@ public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullab
ProtoUtils.ToProto.extendedAgentCard(params), agentCard, context);

try {
String httpResponseBody = sendPostRequest(Utils.buildBaseUrl(agentInterface, params.tenant()), payloadAndHeaders, GET_EXTENDED_AGENT_CARD_METHOD);
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_EXTENDED_AGENT_CARD_METHOD);
GetExtendedAgentCardResponse response = unmarshalResponse(httpResponseBody, GET_EXTENDED_AGENT_CARD_METHOD);
return response.getResult();
} catch (IOException | InterruptedException | JsonProcessingException e) {
Expand Down
4 changes: 4 additions & 0 deletions docs/content/dev/extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ JPA-backed replacements for the in-memory stores, providing database persistence

Replaces the default `InMemoryQueueManager` with event replication across multiple A2A server instances via message brokers. Required for multi-instance deployments. The core module (`a2a-java-queue-manager-replicated-core`) pairs with a MicroProfile Reactive Messaging strategy (`a2a-java-queue-manager-replication-mp-reactive`) supporting Apache Kafka, Pulsar, or AMQP. You can also write your own `ReplicationStrategy`.

## [Multi-Tenancy](multi-tenancy)

Serve multiple tenants from a single A2A server with per-tenant `AgentExecutor` and `AgentCard` routing. The module (`a2a-java-extras-multitenancy`) provides a `@Tenant` CDI qualifier and automatic routing — requests are dispatched to tenant-specific beans based on the `tenant` field in the request payload, with fallback to the default beans for unknown tenants.

## [OpenTelemetry](../extra/opentelemetry)

Distributed tracing, metrics, and context propagation for A2A servers and clients using OpenTelemetry. The server module (`a2a-java-sdk-opentelemetry-server`) adds automatic span creation for all protocol methods with context propagation across async boundaries. Client modules (`a2a-java-sdk-opentelemetry-client`, `a2a-java-sdk-opentelemetry-client-propagation`) instrument A2A client operations.
135 changes: 135 additions & 0 deletions docs/content/dev/multi-tenancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
title: Multi-Tenancy
description: Serve multiple tenants from a single A2A server — per-tenant AgentExecutor and AgentCard routing with CDI qualifiers.
layout: page
---

# Multi-Tenancy

Multi-tenancy lets a single A2A server provide different agent behavior per tenant. Each tenant can have its own `AgentExecutor` (business logic) and extended `AgentCard` (capabilities, skills, metadata). Requests without a tenant — or with an unknown tenant — fall back to the default beans automatically.

## Setup

Add the multitenancy extras module:

```xml
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-extras-multitenancy</artifactId>
</dependency>
```

> **Tip:** Use the [extras BOM](boms) to manage the version.

No code changes are needed in the server itself — the module activates automatically via CDI when present on the classpath.

## Declaring Per-Tenant Beans

Use the `@Tenant` qualifier to declare tenant-specific producers:

```java
@ApplicationScoped
public class MultiTenantConfig {

// Default executor — used for unknown tenants and requests with no tenant
@Produces
public AgentExecutor defaultExecutor() {
return new DefaultAgentExecutor();
}

// Tenant-specific executor — used when params.tenant == "acme"
@Produces
@Tenant("acme")
public AgentExecutor acmeExecutor() {
return new AcmeAgentExecutor();
}

// Default extended agent card (no @Tenant qualifier)
@Produces
@ExtendedAgentCard
public AgentCard defaultExtendedCard() {
return AgentCard.builder()
.name("Default Agent")
.description("Default agent for all tenants")
// ...
.build();
}

// Tenant-specific extended agent card
@Produces
@Tenant("acme")
@ExtendedAgentCard
public AgentCard acmeExtendedCard() {
return AgentCard.builder()
.name("Acme Agent")
.description("Specialized agent for Acme Corp")
// ...
.build();
}
}
```

## Fallback Behavior

When a request arrives with a `tenant` value:

1. The router looks for a bean qualified with `@Tenant("value")`
2. If found, that tenant-specific bean is used
3. If not found, the **unqualified default** bean is used

A `null`, blank, or missing tenant always resolves to the default bean. Unknown tenants also fall back to the default — they do not produce an error.

This means single-tenant deployments (no `@Tenant` beans) continue to work unchanged when the module is on the classpath.

## Tenant Source

The tenant is read from the **request payload** — the `tenant` field in JSON-RPC params (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`) and protobuf request messages. For the REST transport, the tenant can also be extracted from the URL path (e.g. `/\\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present.

A tenant must be a **simple identifier** — only `a-zA-Z0-9_-.` characters are allowed. Path elements like `/` and `?` are rejected.

Both the execute and cancel flows propagate the tenant to the `RequestContext`, so `AgentExecutor` implementations can access it via `context.getTenant()`.

## Per-Tenant Agent Cards

### Extended Agent Card

The `getExtendedAgentCard` method is tenant-aware. When the multitenancy module is present:

- A request with `tenant: "acme"` returns the `@Tenant("acme") @ExtendedAgentCard` card
- A request with no tenant or an unknown tenant returns the default `@ExtendedAgentCard` card

This works across all transports (JSON-RPC, gRPC, REST).

### Public Agent Card

The public agent card is also tenant-aware via the URL path `/.well-known/\\{tenant}/agent-card.json`. To provide a tenant-specific public card, declare a producer with only the `@Tenant` qualifier (do **not** add `@PublicAgentCard` — that would cause CDI ambiguity):

```java
// Tenant-specific public agent card
@Produces
@Tenant("acme")
public AgentCard acmePublicCard() {
return AgentCard.builder()
.name("Acme Agent")
.description("Acme-specific public card")
// ...
.build();
}
```

- `GET /.well-known/agent-card.json` always returns the default `@PublicAgentCard` card
- `GET /.well-known/acme/agent-card.json` returns the `@Tenant("acme")` card, or falls back to the default if none is configured

## Without the Module

When the `extras/multitenancy` module is **not** on the classpath, no routers are registered. The server behaves exactly as a single-tenant deployment:

- The default `AgentExecutor` handles all requests
- The default `@ExtendedAgentCard` card is returned for `getExtendedAgentCard`
- The `tenant` field in request payloads is silently ignored

## Limitations

- **TaskStore and QueueManager are shared** across tenants — tasks are keyed by UUID, not partitioned by tenant
- **Per-tenant TaskAuthorizationProvider** is not yet supported — the single `TaskAuthorizationProvider` applies to all tenants
- **Public agent card** — Tenant-specific public cards use `@Tenant` without `@PublicAgentCard` due to CDI qualifier matching constraints (see [Per-Tenant Agent Cards](#per-tenant-agent-cards))
4 changes: 4 additions & 0 deletions docs/content/dev/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ public class CloseStreamsHook implements TaskStreamLifecycleHook {

See the [`examples/stream-lifecycle`](https://github.com/a2aproject/a2a-java/tree/main/examples/stream-lifecycle) directory for a complete working example with server, client, and integration tests for all three transports.

## Multi-Tenancy (Optional)

Serve multiple tenants from a single server with per-tenant `AgentExecutor` and `AgentCard` beans. See [Multi-Tenancy](multi-tenancy) for setup and configuration.

## Observability (Optional)

Add distributed tracing to your server with the [OpenTelemetry extras module](extra/opentelemetry). It decorates the request handler to create spans for every A2A protocol method, with automatic error tracking and optional request/response extraction.
Expand Down
3 changes: 3 additions & 0 deletions docs/data/versions/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ menu:
- title: "Compatibility"
path: "/compatibility"
icon: "fa-solid fa-code-branch"
- title: "Multi-Tenancy"
path: "/multi-tenancy"
icon: "fa-solid fa-building"
- title: "Extras"
path: "/extras"
icon: "fa-solid fa-puzzle-piece"
Expand Down
Loading
Loading