Skip to content
Merged
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
167 changes: 167 additions & 0 deletions docs/content/dev/extra/multi-tenancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
title: Multi-Tenancy
description: CDI-based multitenancy support for A2A Java SDK servers — per-tenant AgentExecutor and AgentCard routing with the @Tenant qualifier.
layout: page
---

# Multi-Tenancy

Lets a single A2A server serve multiple tenants with different agent behavior — each tenant gets its own `AgentExecutor` and `AgentCard`, while requests without a recognized tenant automatically fall back to the default beans.

## Module

| Artifact ID | Description |
|-------------|-------------|
| `a2a-java-extras-multitenancy` | `@Tenant` qualifier, `CdiAgentExecutorRouter`, `CdiAgentCardRouter` |

### Add Dependency

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

> **Tip:** Use the [extras BOM](../boms) to manage the version without specifying it explicitly.

The module activates automatically via CDI when present on the classpath — no additional server configuration is required.

## Architecture

<pre class="mermaid">
flowchart TD
A["Incoming Request\ntenant field"] --> B["CdiAgentExecutorRouter"]
B -->|"@Tenant match found"| C["@Tenant AgentExecutor"]
B -->|"null / blank / unknown"| D["Default AgentExecutor"]
C --> E["RequestContext.getTenant()"]
D --> E

F["getExtendedAgentCard\npublic card URL"] --> G["CdiAgentCardRouter"]
G -->|"@Tenant match found"| H["@Tenant AgentCard"]
G -->|"null / blank / unknown"| I["Default AgentCard"]
</pre>

Two CDI routers are registered automatically:

- **`CdiAgentExecutorRouter`** — looks up an `AgentExecutor` bean qualified with `@Tenant(value)` for each request. Falls back to the unqualified default executor when no match is found.
- **`CdiAgentCardRouter`** — resolves the appropriate `AgentCard` for `getExtendedAgentCard` and tenant-specific public card endpoints.

## Declaring Per-Tenant Beans

Use the `@Tenant` qualifier on CDI producer methods:

### AgentExecutor

```java
@ApplicationScoped
public class MyExecutors {

@Produces
public AgentExecutor defaultExecutor() {
return new DefaultAgentExecutor();
}

@Produces
@Tenant("acme")
public AgentExecutor acmeExecutor() {
return new AcmeAgentExecutor();
}

@Produces
@Tenant("beta")
public AgentExecutor betaExecutor() {
return new BetaAgentExecutor();
}
}
```

### AgentCard

```java
@ApplicationScoped
public class MyAgentCards {

// Default public card — used by /.well-known/agent-card.json
@Produces
@PublicAgentCard
public AgentCard publicCard() {
return AgentCard.builder().name("My Agent")...build();
}

// Default extended card — used when no tenant match
@Produces
@ExtendedAgentCard
public AgentCard defaultExtendedCard() {
return AgentCard.builder().name("My Agent (extended)")...build();
}

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

// Tenant-specific public card — no @PublicAgentCard qualifier (see note below)
@Produces
@Tenant("acme")
public AgentCard acmePublicCard() {
return AgentCard.builder().name("Acme Agent")...build();
}
}
```

> **Note:** Tenant-specific public cards must **not** carry `@PublicAgentCard` — that qualifier is reserved for the single default public card. Adding it to a `@Tenant` bean causes CDI ambiguity.

## Routing Rules

### AgentExecutor routing

| Request tenant | Result |
|----------------|--------|
| `null` or blank | Default (unqualified) executor |
| Known tenant (e.g. `"acme"`) | `@Tenant("acme")` executor |
| Unknown tenant | Default (unqualified) executor |

### AgentCard routing

| Request / URL | Card returned |
|---------------|---------------|
| `getExtendedAgentCard` with no tenant | Default `@ExtendedAgentCard` |
| `getExtendedAgentCard` with `tenant: "acme"` | `@Tenant("acme") @ExtendedAgentCard`, or default |
| `GET /.well-known/agent-card.json` | Default `@PublicAgentCard` |
| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme")` card (no `@PublicAgentCard`), or default |

## Accessing the Tenant in AgentExecutor

The resolved tenant is available in `RequestContext`:

```java
@Override
public void execute(RequestContext context, AgentEmitter emitter) throws A2AError {
String tenant = context.getTenant(); // null for the default tenant
// ...
}
```

## Tenant Source

The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). For the REST transport the tenant can also come from the URL path (e.g. `/\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present.

Tenant identifiers are restricted to `a-zA-Z0-9_-.` characters — path segments containing `/` or `?` are rejected.

## Limitations

- **TaskStore and QueueManager are shared** across all tenants — tasks are keyed by UUID, not partitioned per tenant.
- **Per-tenant TaskAuthorizationProvider** is not yet supported — a single provider applies to all tenants.

## Without the Module

When `a2a-java-extras-multitenancy` is **not** on the classpath, the server behaves as a single-tenant deployment: the default `AgentExecutor` handles all requests, the default cards are returned, and the `tenant` field in request payloads is silently ignored. Existing single-tenant code requires no changes when the module is added.

## See Also

- [Multi-Tenancy concept page](../../multi-tenancy) — conceptual overview and setup guide
- [Extras BOM](../boms) — version management for all extras modules
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](../extra/multi-tenancy)
Comment thread
ehsavoie marked this conversation as resolved.

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.
13 changes: 13 additions & 0 deletions docs/content/dev/multi-tenancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
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 `AgentCard` (capabilities, skills, metadata). Requests without a recognized tenant automatically fall back to the default beans.

This feature is provided by the `a2a-java-extras-multitenancy` extras module, which registers two CDI routers — `CdiAgentExecutorRouter` and `CdiAgentCardRouter` — that dispatch each request to the matching `@Tenant`-qualified bean.

For setup instructions, configuration reference, and code examples, see the **[Multi-Tenancy extras page](../extra/multi-tenancy)**.
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
{#bundle /}
{#search-script /}

<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
</script>

{#if site.data.containsKey("analytics")}
{#ga4 tag=site.data.getJsonObject('analytics').getString("ga4", "") /}
{/if}
Expand Down
Loading
Loading