diff --git a/docs/content/dev/extra/multi-tenancy.md b/docs/content/dev/extra/multi-tenancy.md
index b7ccf2b0d..04f836c2b 100644
--- a/docs/content/dev/extra/multi-tenancy.md
+++ b/docs/content/dev/extra/multi-tenancy.md
@@ -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
@@ -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
@@ -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
diff --git a/docs/content/dev/extras.md b/docs/content/dev/extras.md
index ff379350b..c8daf3470 100644
--- a/docs/content/dev/extras.md
+++ b/docs/content/dev/extras.md
@@ -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)
diff --git a/docs/content/dev/multi-tenancy.md b/docs/content/dev/multi-tenancy.md
index ac462b3ab..308d25f6b 100644
--- a/docs/content/dev/multi-tenancy.md
+++ b/docs/content/dev/multi-tenancy.md
@@ -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)**.
diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java
index c7b99d764..50e16fa4d 100644
--- a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java
+++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java
@@ -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;
@@ -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;
@@ -20,10 +22,18 @@
* CDI-based {@link AgentCardRouter} that resolves tenant-specific {@link AgentCard} beans.
*
* 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:
+ *
+ * - carry {@code @PublicAgentCard} (preferred — these cards are resolved before bare
+ * tenant cards, even if they also carry {@code @ExtendedAgentCard}), or
+ * - carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard} (bare
+ * tenant card).
+ *
+ * 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.
*
* 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
@@ -57,6 +67,9 @@ void init() {
if (selected.isResolvable()) {
return selected.get();
}
+ if (selected.isAmbiguous()) {
+ throw duplicateTenantBean("@ExtendedAgentCard", tenant);
+ }
return defaultExtendedCard;
}
@@ -65,16 +78,53 @@ void init() {
if (tenant == null || tenant.isBlank()) {
return defaultPublicCard;
}
- for (Instance.Handle handle : allCards.handles()) {
- Set 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 publicHandle = null;
+ Instance.Handle bareHandle = null;
+ List> allHandles = new ArrayList<>();
+ try {
+ for (Instance.Handle 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 selected = publicHandle != null ? publicHandle : bareHandle;
+ return selected != null ? selected.get() : null;
+ } finally {
+ Instance.Handle kept = publicHandle != null ? publicHandle : bareHandle;
+ for (Instance.Handle 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));
}
}
diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java
index cfbc21818..dd4cb1a23 100644
--- a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java
+++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java
@@ -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;
@@ -36,12 +37,26 @@ void init() {
@Override
public AgentExecutor resolve(@Nullable String tenant) {
if (tenant == null || tenant.isBlank()) {
- return defaultExecutor;
+ return requireDefaultExecutor();
}
Instance 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;
}
}
diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java
new file mode 100644
index 000000000..8b08494af
--- /dev/null
+++ b/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/package-info.java
@@ -0,0 +1,5 @@
+@NullMarked
+package org.a2aproject.sdk.extras.multitenancy;
+
+import org.jspecify.annotations.NullMarked;
+
diff --git a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java
index 0d8b770e2..ddcec2afd 100644
--- a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java
+++ b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java
@@ -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;
@@ -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;
@@ -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)
@@ -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");
+ }
+ }
}
diff --git a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java
index 1e7c78157..4799952c6 100644
--- a/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java
+++ b/extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouterTest.java
@@ -1,6 +1,7 @@
package org.a2aproject.sdk.extras.multitenancy;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
@@ -8,34 +9,39 @@
import jakarta.enterprise.inject.se.SeContainer;
import jakarta.enterprise.inject.se.SeContainerInitializer;
+import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.a2aproject.sdk.server.agentexecution.AgentExecutor;
import org.a2aproject.sdk.server.agentexecution.RequestContext;
import org.a2aproject.sdk.server.tasks.AgentEmitter;
import org.a2aproject.sdk.spec.A2AError;
import org.a2aproject.sdk.spec.TextPart;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class CdiAgentExecutorRouterTest {
private SeContainer container;
- @BeforeEach
- void startContainer() {
- container = SeContainerInitializer.newInstance()
+ private void startContainer(Class>... beanClasses) {
+ SeContainerInitializer initializer = SeContainerInitializer.newInstance()
.disableDiscovery()
- .addBeanClasses(CdiAgentExecutorRouter.class, ExecutorProducer.class)
- .initialize();
+ .addBeanClasses(CdiAgentExecutorRouter.class);
+ for (Class> beanClass : beanClasses) {
+ initializer.addBeanClasses(beanClass);
+ }
+ container = initializer.initialize();
}
@AfterEach
void closeContainer() {
- container.close();
+ if (container != null) {
+ container.close();
+ }
}
@Test
void knownTenantResolvesToTenantSpecificExecutor() {
+ startContainer(ExecutorProducer.class);
CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get();
AgentExecutor resolved = router.resolve("acme");
assertSame(ExecutorProducer.ACME, resolved);
@@ -43,23 +49,40 @@ void knownTenantResolvesToTenantSpecificExecutor() {
@Test
void unknownTenantFallsBackToDefault() {
+ startContainer(ExecutorProducer.class);
CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get();
assertSame(ExecutorProducer.DEFAULT, router.resolve("unknown"));
}
@Test
void nullTenantReturnsDefault() {
+ startContainer(ExecutorProducer.class);
CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get();
assertSame(ExecutorProducer.DEFAULT, router.resolve(null));
}
@Test
void blankTenantReturnsDefault() {
+ startContainer(ExecutorProducer.class);
CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get();
assertSame(ExecutorProducer.DEFAULT, router.resolve(""));
assertSame(ExecutorProducer.DEFAULT, router.resolve(" "));
}
+ @Test
+ void noDefaultExecutorThrowsOnNullTenant() {
+ startContainer(TenantOnlyExecutorProducer.class);
+ CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get();
+ assertThrows(IllegalStateException.class, () -> router.resolve(null));
+ }
+
+ @Test
+ void noDefaultExecutorThrowsOnUnknownTenant() {
+ startContainer(TenantOnlyExecutorProducer.class);
+ CdiAgentExecutorRouter router = container.select(CdiAgentExecutorRouter.class).get();
+ assertThrows(IllegalStateException.class, () -> router.resolve("unknown"));
+ }
+
static class ExecutorProducer {
static final AgentExecutor DEFAULT = new LabelExecutor("default");
@@ -77,6 +100,15 @@ AgentExecutor acmeExecutor() {
}
}
+ static class TenantOnlyExecutorProducer {
+
+ @Produces
+ @Tenant("acme")
+ AgentExecutor acmeExecutor() {
+ return new LabelExecutor("acme");
+ }
+ }
+
static class LabelExecutor implements AgentExecutor {
private final String label;
diff --git a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java
index f4d57be44..fcd45ae21 100644
--- a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java
+++ b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentCardProducer.java
@@ -9,7 +9,7 @@
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
-import org.a2aproject.sdk.extras.multitenancy.Tenant;
+import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.a2aproject.sdk.server.ExtendedAgentCard;
import org.a2aproject.sdk.server.PublicAgentCard;
import org.a2aproject.sdk.spec.AgentCapabilities;
@@ -23,8 +23,9 @@
* The transport interface stamped onto every card is chosen from the {@code preferred-transport}
* key in {@code /a2a-requesthandler-test.properties}, so one producer serves every transport module.
*
- * Router contract: per-tenant public cards carry {@code @Tenant} only (never {@code @PublicAgentCard}),
- * per-tenant extended cards carry {@code @Tenant @ExtendedAgentCard}.
+ *
Router contract: per-tenant public cards carry either {@code @Tenant} alone or
+ * {@code @Tenant @PublicAgentCard}; explicitly public cards take precedence. Per-tenant extended
+ * cards carry {@code @Tenant @ExtendedAgentCard}.
*/
@Singleton
public class MultiTenantAgentCardProducer {
diff --git a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java
index 4e17bb76d..b1b4f63bc 100644
--- a/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java
+++ b/extras/multitenancy/tests/test-common/src/main/java/org/a2aproject/sdk/extras/multitenancy/tests/MultiTenantAgentExecutorProducer.java
@@ -5,7 +5,7 @@
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
-import org.a2aproject.sdk.extras.multitenancy.Tenant;
+import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.a2aproject.sdk.server.agentexecution.AgentExecutor;
import org.a2aproject.sdk.spec.TextPart;
diff --git a/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java b/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java
index 7fcb1256c..5485f0cce 100644
--- a/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java
+++ b/reference/grpc/src/main/java/org/a2aproject/sdk/server/grpc/quarkus/QuarkusGrpcHandler.java
@@ -134,18 +134,12 @@ protected RequestHandler getRequestHandler() {
@Override
protected @Nullable AgentCard getAgentCard() {
- if (!agentCard.isResolvable()) {
- return null;
- }
- return agentCard.get();
+ return CdiUtils.resolveDefault(agentCard);
}
@Override
protected AgentCard getExtendedAgentCard() {
- if (extendedAgentCard != null && extendedAgentCard.isResolvable()) {
- return extendedAgentCard.get();
- }
- return null;
+ return CdiUtils.resolveDefault(extendedAgentCard);
}
@Override
diff --git a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java
index fe3ff53f7..4c5d4ce5e 100644
--- a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java
+++ b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java
@@ -263,7 +263,8 @@ void setupRoutes(@Observes Router router) {
ctx.response().setStatusCode(404).putHeader(CONTENT_TYPE, "text/plain").end(e.getResponseMessage());
} catch (IllegalArgumentException e) {
ctx.response().setStatusCode(400).putHeader(CONTENT_TYPE, "text/plain").end(e.getMessage());
- } catch (JsonProcessingException e) {
+ } catch (Exception e) {
+ LOGGER.error("Internal error while resolving agent card", e);
ctx.response().setStatusCode(500).putHeader(CONTENT_TYPE, "text/plain").end("Internal Server Error");
}
});
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java
index 561dcff94..183cedfdd 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardCacheMetadata.java
@@ -93,9 +93,7 @@ void init() {
// 2. Direct constructor: agentCard and config already set
if (agentCard == null && agentCardInstance != null) {
- // CDI path — in multitenancy setups, multiple @PublicAgentCard beans may exist,
- // making the injection point ambiguous; getIfResolvable returns null in that case.
- this.agentCard = CdiUtils.getIfResolvable(agentCardInstance);
+ this.agentCard = CdiUtils.resolveDefault(agentCardInstance);
this.config = CdiUtils.getIfResolvable(configInstance);
if (this.agentCard == null || this.config == null) {
return;
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java
index c61cc8acc..856f13328 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java
@@ -13,6 +13,7 @@
import jakarta.enterprise.inject.Instance;
+import org.a2aproject.sdk.server.util.CdiUtils;
import org.a2aproject.sdk.spec.AgentCard;
import org.jspecify.annotations.Nullable;
import org.a2aproject.sdk.spec.AgentInterface;
@@ -88,11 +89,15 @@ public static AgentCard resolveAndValidateOnce(Supplier agentCardSupp
public static AgentCard resolveWithFallback(Instance publicCard,
@Nullable Instance extendedCard,
AtomicBoolean transportValidated) {
- if (publicCard.isResolvable()) {
- return resolveAndValidateOnce(publicCard, transportValidated);
+ AgentCard resolved = CdiUtils.resolveDefault(publicCard);
+ if (resolved != null) {
+ return resolveAndValidateOnce(() -> resolved, transportValidated,
+ AgentCardValidator::validateTransportConfiguration);
}
- if (extendedCard != null && extendedCard.isResolvable()) {
- return resolveAndValidateOnce(extendedCard, transportValidated);
+ AgentCard extResolved = CdiUtils.resolveDefault(extendedCard);
+ if (extResolved != null) {
+ return resolveAndValidateOnce(() -> extResolved, transportValidated,
+ AgentCardValidator::validateTransportConfiguration);
}
throw new IllegalStateException(NO_AGENT_CARD_MESSAGE);
}
diff --git a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/Tenant.java
similarity index 97%
rename from extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java
rename to server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/Tenant.java
index f79182bfc..c920ef0ef 100644
--- a/extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/Tenant.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/Tenant.java
@@ -1,4 +1,4 @@
-package org.a2aproject.sdk.extras.multitenancy;
+package org.a2aproject.sdk.server.multitenancy;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java
new file mode 100644
index 000000000..e2c367866
--- /dev/null
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.a2aproject.sdk.server.multitenancy;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java b/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java
index c42e2db9d..059bc0a22 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/util/CdiUtils.java
@@ -9,6 +9,7 @@
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Singleton;
+import org.a2aproject.sdk.server.multitenancy.Tenant;
import org.jspecify.annotations.Nullable;
/**
@@ -35,6 +36,45 @@ private CdiUtils() {
return null;
}
+ /**
+ * Resolves the default bean from a potentially ambiguous {@link Instance}.
+ *
+ * When the instance is resolvable (exactly one match), returns it directly.
+ * When ambiguous, iterates over all matching beans and returns the single bean
+ * that does not carry a {@link Tenant} qualifier. This allows the default bean
+ * to be resolved even when tenant-specific beans make the injection point ambiguous.
+ *
+ * @param instance the CDI instance to resolve from
+ * @param the bean type
+ * @return the resolved default bean, or {@code null} if none was found
+ * @throws IllegalStateException if multiple non-tenant beans are found
+ */
+ public static @Nullable T resolveDefault(@Nullable Instance instance) {
+ if (instance == null) {
+ return null;
+ }
+ if (instance.isResolvable()) {
+ return instance.get();
+ }
+ if (!instance.isAmbiguous()) {
+ return null;
+ }
+ T defaultBean = null;
+ for (Instance.Handle handle : instance.handles()) {
+ boolean hasTenant = handle.getBean().getQualifiers().stream()
+ .anyMatch(Tenant.class::isInstance);
+ if (hasTenant) {
+ continue;
+ }
+ if (defaultBean != null) {
+ throw new IllegalStateException(
+ "Multiple default beans detected — ensure only one bean without @Tenant qualifier exists");
+ }
+ defaultBean = handle.get();
+ }
+ return defaultBean;
+ }
+
/**
* Finds the single default bean of a given type, excluding beans that carry a specific
* qualifier and optionally requiring another. Warns on duplicate defaults and validates
diff --git a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java
index cdc2f0eb7..2f51a748a 100644
--- a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java
+++ b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java
@@ -772,10 +772,12 @@ private void validateVersionAndExtensions(ServerCallContext context) throws A2AE
}
LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'");
}
- if (!agentCardInstance.isResolvable()) {
+ AgentCard card = CdiUtils.resolveDefault(agentCardInstance);
+ if (card == null) {
return null;
}
- return AgentCardValidator.resolveAndValidateOnce(agentCardInstance, transportValidated);
+ return AgentCardValidator.resolveAndValidateOnce(() -> card, transportValidated,
+ AgentCardValidator::validateTransportConfiguration);
}
private AgentCard resolveAgentCard() {
diff --git a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java
index b35beb8c2..2b5c338c8 100644
--- a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java
+++ b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java
@@ -860,10 +860,11 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, @Nullabl
}
return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card));
}
- if (extendedAgentCard == null || !extendedAgentCard.isResolvable()) {
+ AgentCard extCard = CdiUtils.resolveDefault(extendedAgentCard);
+ if (extCard == null) {
throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null);
}
- return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(extendedAgentCard.get()));
+ return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(extCard));
} catch (A2AError e) {
return createErrorResponse(e);
} catch (Throwable t) {
@@ -940,11 +941,13 @@ public HTTPRestResponse getAgentCard(@Nullable String tenant) {
}
LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'");
}
- if (!agentCardInstance.isResolvable()) {
+ AgentCard card = CdiUtils.resolveDefault(agentCardInstance);
+ if (card == null) {
return new HTTPRestResponse(404, "text/plain", "Public agent card not configured");
}
return new HTTPRestResponse(200, APPLICATION_JSON,
- JsonUtil.toJson(AgentCardValidator.resolveAndValidateOnce(agentCardInstance, transportValidated)),
+ JsonUtil.toJson(AgentCardValidator.resolveAndValidateOnce(() -> card, transportValidated,
+ AgentCardValidator::validateTransportConfiguration)),
cacheMetadata.getHttpHeadersMap());
} catch (TenantNotFoundException e) {
return new HTTPRestResponse(404, "text/plain", e.getResponseMessage());