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
11 changes: 7 additions & 4 deletions docs/content/dev/extra/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ Lets a single A2A server serve multiple tenants with different agent behavior

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

> **Note:** The `@Tenant` qualifier itself lives in the core SDK (`server-common` module, package `org.a2aproject.sdk.server.multitenancy`) so that the transport layer can filter tenant-specific beans when resolving the default card. The extras module provides the CDI routers that dispatch requests to the matching `@Tenant`-qualified beans.

### Add Dependency

Expand Down Expand Up @@ -104,16 +106,17 @@ public class MyAgentCards {
return AgentCard.builder().name("Acme Agent")...build();
}

// Tenant-specific public card — no @PublicAgentCard qualifier (see note below)
// Tenant-specific public card — @PublicAgentCard is optional but recommended
@Produces
@Tenant("acme")
@PublicAgentCard
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.
> **Tip:** Tenant-specific public cards may carry `@PublicAgentCard` — the transport layer filters out `@Tenant`-qualified beans when resolving the default card, so there is no CDI ambiguity. Using `@PublicAgentCard` on tenant cards is preferred because it makes the intent explicit and gives them priority over bare `@Tenant` cards in `CdiAgentCardRouter`.

## Routing Rules

Expand All @@ -132,7 +135,7 @@ public class MyAgentCards {
| `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 |
| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme") @PublicAgentCard` card (preferred), or bare `@Tenant("acme")` card, or default |

## Accessing the Tenant in AgentExecutor

Expand Down
2 changes: 1 addition & 1 deletion docs/content/dev/extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Replaces the default `InMemoryQueueManager` with event replication across multip

## [Multi-Tenancy](../extra/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.
Serve multiple tenants from a single A2A server with per-tenant `AgentExecutor` and `AgentCard` routing. The `@Tenant` CDI qualifier is in the core SDK (`server-common`); the extras module (`a2a-java-extras-multitenancy`) provides 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)

Expand Down
2 changes: 1 addition & 1 deletion docs/content/dev/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ layout: page

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.
The `@Tenant` CDI qualifier is in the core SDK (`org.a2aproject.sdk.server.multitenancy`). The `a2a-java-extras-multitenancy` extras module 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)**.
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package org.a2aproject.sdk.extras.multitenancy;

import java.lang.annotation.Annotation;
import java.util.Set;
import java.util.ArrayList;
import java.util.List;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
Expand All @@ -12,6 +13,7 @@
import org.a2aproject.sdk.server.ExtendedAgentCard;
import org.a2aproject.sdk.server.PublicAgentCard;
import org.a2aproject.sdk.server.multitenancy.AgentCardRouter;
import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.a2aproject.sdk.server.util.CdiUtils;
import org.a2aproject.sdk.spec.AgentCard;
import org.jspecify.annotations.Nullable;
Expand All @@ -20,10 +22,18 @@
* CDI-based {@link AgentCardRouter} that resolves tenant-specific {@link AgentCard} beans.
* <p>
* Extended cards are resolved via {@code @Tenant("x") @ExtendedAgentCard}-qualified beans.
* Tenant-specific public cards are resolved via {@code @Tenant("x")}-qualified beans that
* carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard}. The {@code @PublicAgentCard}
* qualifier must NOT be used on tenant-specific public cards because it would cause CDI
* ambiguity on injection points requesting the default public card.
* Tenant-specific public cards are resolved by matching beans that carry the
* {@code @Tenant("x")} qualifier and either:
* <ul>
* <li>carry {@code @PublicAgentCard} (preferred — these cards are resolved before bare
* tenant cards, even if they also carry {@code @ExtendedAgentCard}), or</li>
* <li>carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard} (bare
* tenant card).</li>
* </ul>
* Using {@code @Tenant("x") @PublicAgentCard} on a tenant-specific bean is safe because
* both the router's default-card lookup and the transport handler injection points use
* {@link org.a2aproject.sdk.server.util.CdiUtils#resolveDefault} to exclude
* {@code @Tenant}-qualified beans when resolving the default card.
* <p>
* Returns the default public card from {@link #resolvePublicCard} when the tenant is
* {@code null} or blank. Returns {@code null} when a non-blank tenant does not match
Expand Down Expand Up @@ -57,6 +67,9 @@ void init() {
if (selected.isResolvable()) {
return selected.get();
}
if (selected.isAmbiguous()) {
throw duplicateTenantBean("@ExtendedAgentCard", tenant);
}
return defaultExtendedCard;
}

Expand All @@ -65,16 +78,53 @@ void init() {
if (tenant == null || tenant.isBlank()) {
return defaultPublicCard;
}
for (Instance.Handle<AgentCard> handle : allCards.handles()) {
Set<Annotation> qualifiers = handle.getBean().getQualifiers();
boolean matchesTenant = qualifiers.stream()
.anyMatch(a -> a instanceof Tenant t && tenant.equals(t.value()));
if (matchesTenant
&& qualifiers.stream().noneMatch(ExtendedAgentCard.class::isInstance)
&& qualifiers.stream().noneMatch(PublicAgentCard.class::isInstance)) {
return handle.get();
Instance.Handle<AgentCard> publicHandle = null;
Instance.Handle<AgentCard> bareHandle = null;
List<Instance.Handle<AgentCard>> allHandles = new ArrayList<>();
try {
for (Instance.Handle<AgentCard> handle : allCards.handles()) {
allHandles.add(handle);
boolean matchesTenant = false;
boolean hasPublic = false;
boolean hasExtended = false;
for (Annotation q : handle.getBean().getQualifiers()) {
if (q instanceof Tenant t && tenant.equals(t.value())) {
matchesTenant = true;
} else if (q instanceof PublicAgentCard) {
hasPublic = true;
} else if (q instanceof ExtendedAgentCard) {
hasExtended = true;
}
}
if (!matchesTenant) {
continue;
}
if (hasPublic) {
if (publicHandle != null) {
throw duplicateTenantBean("@PublicAgentCard", tenant);
}
publicHandle = handle;
} else if (!hasExtended) {
if (bareHandle != null) {
throw duplicateTenantBean("bare public", tenant);
}
bareHandle = handle;
}
}
Instance.Handle<AgentCard> selected = publicHandle != null ? publicHandle : bareHandle;
return selected != null ? selected.get() : null;
} finally {
Instance.Handle<AgentCard> kept = publicHandle != null ? publicHandle : bareHandle;
for (Instance.Handle<AgentCard> h : allHandles) {
if (h != kept) {
h.close();
}
}
}
return null;
}

private static IllegalStateException duplicateTenantBean(String cardType, String tenant) {
return new IllegalStateException(
String.format("Multiple tenant %s cards detected for tenant '%s'", cardType, tenant));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import org.a2aproject.sdk.server.agentexecution.AgentExecutor;
import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter;
import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.a2aproject.sdk.server.util.CdiUtils;
import org.jspecify.annotations.Nullable;

Expand Down Expand Up @@ -36,12 +37,26 @@ void init() {
@Override
public AgentExecutor resolve(@Nullable String tenant) {
if (tenant == null || tenant.isBlank()) {
return defaultExecutor;
return requireDefaultExecutor();
}
Instance<AgentExecutor> selected = allExecutors.select(new Tenant.Literal(tenant));
if (selected.isResolvable()) {
return selected.get();
}
return requireDefaultExecutor();
}

/**
* Returns the default executor, throwing if none was discovered at startup.
*
* @return the default executor, never {@code null}
* @throws IllegalStateException if no default {@link AgentExecutor} bean exists
*/
private AgentExecutor requireDefaultExecutor() {
if (defaultExecutor == null) {
throw new IllegalStateException(
"No default AgentExecutor bean found — provide an AgentExecutor bean without @Tenant qualifier");
}
return defaultExecutor;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@NullMarked
package org.a2aproject.sdk.extras.multitenancy;

import org.jspecify.annotations.NullMarked;

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.Collections;
import java.util.List;
Expand All @@ -12,6 +13,7 @@

import org.a2aproject.sdk.server.ExtendedAgentCard;
import org.a2aproject.sdk.server.PublicAgentCard;
import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.a2aproject.sdk.spec.AgentCapabilities;
import org.a2aproject.sdk.spec.AgentCard;
import org.a2aproject.sdk.spec.AgentInterface;
Expand Down Expand Up @@ -113,6 +115,58 @@ void publicCardNoDefaultReturnsNull() {
assertNull(router.resolvePublicCard("unknown"));
}

@Test
void publicCardWithPublicQualifierResolvesToTenantSpecific() {
startContainer(DefaultPublicAndExtendedCardProducer.class, PublicQualifiedTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
assertEquals("acme-public-qualified", router.resolvePublicCard("acme").name());
}

@Test
void publicCardWithBothPublicAndExtendedQualifierResolvesToTenantSpecific() {
startContainer(DefaultPublicAndExtendedCardProducer.class, PublicAndExtendedTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
assertEquals("acme-both", router.resolvePublicCard("acme").name());
}

@Test
void explicitPublicCardTakesPrecedenceOverBareTenantCard() {
startContainer(DefaultPublicAndExtendedCardProducer.class, BareAndPublicQualifiedTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
assertEquals("acme-public-qualified", router.resolvePublicCard("acme").name());
}

@Test
void duplicatePublicQualifiedCardsThrows() {
startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicatePublicQualifiedTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
assertThrows(IllegalStateException.class, () -> router.resolvePublicCard("acme"));
}

@Test
void duplicateBareTenantCardsThrows() {
startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicateBareTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
assertThrows(IllegalStateException.class, () -> router.resolvePublicCard("acme"));
}

@Test
void duplicateExtendedTenantCardsThrows() {
startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicateExtendedTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
assertThrows(IllegalStateException.class, () -> router.resolveExtendedCard("acme"));
}

@Test
void dualQualifiedCardResolvedByBothPublicAndExtended() {
startContainer(DefaultPublicAndExtendedCardProducer.class, PublicAndExtendedTenantCardProducer.class);
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
AgentCard publicCard = router.resolvePublicCard("acme");
AgentCard extendedCard = router.resolveExtendedCard("acme");
assertEquals("acme-both", publicCard.name());
assertEquals("acme-both", extendedCard.name());
}

private static AgentCard buildCard(String name) {
return AgentCard.builder()
.name(name)
Expand Down Expand Up @@ -179,4 +233,105 @@ AgentCard acmeExtendedCard() {
return buildCard("acme-extended");
}
}

static class DefaultPublicAndExtendedCardProducer {

@Produces
@PublicAgentCard
AgentCard defaultPublicCard() {
return buildCard("default-public");
}

@Produces
@ExtendedAgentCard
AgentCard defaultExtendedCard() {
return buildCard("default-extended");
}
}

static class PublicQualifiedTenantCardProducer {

@Produces
@Tenant("acme")
@PublicAgentCard
AgentCard acmePublicCard() {
return buildCard("acme-public-qualified");
}
}

static class PublicAndExtendedTenantCardProducer {

@Produces
@Tenant("acme")
@PublicAgentCard
@ExtendedAgentCard
AgentCard acmeBothCard() {
return buildCard("acme-both");
}
}

static class BareAndPublicQualifiedTenantCardProducer {

@Produces
@Tenant("acme")
AgentCard acmeBareCard() {
return buildCard("acme-bare");
}

@Produces
@Tenant("acme")
@PublicAgentCard
AgentCard acmePublicCard() {
return buildCard("acme-public-qualified");
}
}

static class DuplicatePublicQualifiedTenantCardProducer {

@Produces
@Tenant("acme")
@PublicAgentCard
AgentCard acmePublicCard1() {
return buildCard("acme-public-1");
}

@Produces
@Tenant("acme")
@PublicAgentCard
AgentCard acmePublicCard2() {
return buildCard("acme-public-2");
}
}

static class DuplicateBareTenantCardProducer {

@Produces
@Tenant("acme")
AgentCard acmeBareCard1() {
return buildCard("acme-bare-1");
}

@Produces
@Tenant("acme")
AgentCard acmeBareCard2() {
return buildCard("acme-bare-2");
}
}

static class DuplicateExtendedTenantCardProducer {

@Produces
@Tenant("acme")
@ExtendedAgentCard
AgentCard acmeExtendedCard1() {
return buildCard("acme-extended-1");
}

@Produces
@Tenant("acme")
@ExtendedAgentCard
AgentCard acmeExtendedCard2() {
return buildCard("acme-extended-2");
}
}
}
Loading
Loading