fix(multitenancy): move @Tenant to server-common to resolve CDI ambig… · a2aproject/a2a-java@091ccb4 · GitHub
Skip to content

Commit 091ccb4

Browse files
committed
fix(multitenancy): move @tenant to server-common to resolve CDI ambiguity
Move the @tenant qualifier from extras/multitenancy to server-common so CdiUtils.resolveDefault() can filter tenant-qualified beans at all @PublicAgentCard/@ExtendedAgentCard injection points. This makes @tenant("x") @PublicAgentCard safe on tenant cards — previously it made the default card injection ambiguous, causing 404 on the default endpoint. - @PublicAgentCard tenant cards take precedence over bare @tenant cards - Reject duplicate tenant @ExtendedAgentCard registrations - Throw when no default AgentExecutor bean exists - Widen A2AServerRoutes catch to handle all server errors Fixes #1175 Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent ec5994a commit 091ccb4

19 files changed

Lines changed: 363 additions & 55 deletions

File tree

‎docs/content/dev/extra/multi-tenancy.md‎

Lines changed: 7 additions & 4 deletions

‎docs/content/dev/extras.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Replaces the default `InMemoryQueueManager` with event replication across multip
4242

4343
## [Multi-Tenancy](../extra/multi-tenancy)
4444

45-
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.
45+
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.
4646

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

‎docs/content/dev/multi-tenancy.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ layout: page
88

99
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.
1010

11-
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.
11+
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.
1212

1313
For setup instructions, configuration reference, and code examples, see the **[Multi-Tenancy extras page](../extra/multi-tenancy)**.

‎extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouter.java‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package org.a2aproject.sdk.extras.multitenancy;
22

33
import java.lang.annotation.Annotation;
4-
import java.util.Set;
4+
import java.util.ArrayList;
5+
import java.util.List;
56

67
import jakarta.annotation.PostConstruct;
78
import jakarta.enterprise.context.ApplicationScoped;
@@ -12,6 +13,7 @@
1213
import org.a2aproject.sdk.server.ExtendedAgentCard;
1314
import org.a2aproject.sdk.server.PublicAgentCard;
1415
import org.a2aproject.sdk.server.multitenancy.AgentCardRouter;
16+
import org.a2aproject.sdk.server.multitenancy.Tenant;
1517
import org.a2aproject.sdk.server.util.CdiUtils;
1618
import org.a2aproject.sdk.spec.AgentCard;
1719
import org.jspecify.annotations.Nullable;
@@ -20,10 +22,18 @@
2022
* CDI-based {@link AgentCardRouter} that resolves tenant-specific {@link AgentCard} beans.
2123
* <p>
2224
* Extended cards are resolved via {@code @Tenant("x") @ExtendedAgentCard}-qualified beans.
23-
* Tenant-specific public cards are resolved via {@code @Tenant("x")}-qualified beans that
24-
* carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard}. The {@code @PublicAgentCard}
25-
* qualifier must NOT be used on tenant-specific public cards because it would cause CDI
26-
* ambiguity on injection points requesting the default public card.
25+
* Tenant-specific public cards are resolved by matching beans that carry the
26+
* {@code @Tenant("x")} qualifier and either:
27+
* <ul>
28+
* <li>carry {@code @PublicAgentCard} (preferred — these cards are resolved before bare
29+
* tenant cards, even if they also carry {@code @ExtendedAgentCard}), or</li>
30+
* <li>carry neither {@code @ExtendedAgentCard} nor {@code @PublicAgentCard} (bare
31+
* tenant card).</li>
32+
* </ul>
33+
* Using {@code @Tenant("x") @PublicAgentCard} on a tenant-specific bean is safe because
34+
* both the router's default-card lookup and the transport handler injection points use
35+
* {@link org.a2aproject.sdk.server.util.CdiUtils#resolveDefault} to exclude
36+
* {@code @Tenant}-qualified beans when resolving the default card.
2737
* <p>
2838
* Returns the default public card from {@link #resolvePublicCard} when the tenant is
2939
* {@code null} or blank. Returns {@code null} when a non-blank tenant does not match
@@ -57,6 +67,9 @@ void init() {
5767
if (selected.isResolvable()) {
5868
return selected.get();
5969
}
70+
if (selected.isAmbiguous()) {
71+
throw duplicateTenantBean("@ExtendedAgentCard", tenant);
72+
}
6073
return defaultExtendedCard;
6174
}
6275

@@ -65,16 +78,53 @@ void init() {
6578
if (tenant == null || tenant.isBlank()) {
6679
return defaultPublicCard;
6780
}
68-
for (Instance.Handle<AgentCard> handle : allCards.handles()) {
69-
Set<Annotation> qualifiers = handle.getBean().getQualifiers();
70-
boolean matchesTenant = qualifiers.stream()
71-
.anyMatch(a -> a instanceof Tenant t && tenant.equals(t.value()));
72-
if (matchesTenant
73-
&& qualifiers.stream().noneMatch(ExtendedAgentCard.class::isInstance)
74-
&& qualifiers.stream().noneMatch(PublicAgentCard.class::isInstance)) {
75-
return handle.get();
81+
Instance.Handle<AgentCard> publicHandle = null;
82+
Instance.Handle<AgentCard> bareHandle = null;
83+
List<Instance.Handle<AgentCard>> allHandles = new ArrayList<>();
84+
try {
85+
for (Instance.Handle<AgentCard> handle : allCards.handles()) {
86+
allHandles.add(handle);
87+
boolean matchesTenant = false;
88+
boolean hasPublic = false;
89+
boolean hasExtended = false;
90+
for (Annotation q : handle.getBean().getQualifiers()) {
91+
if (q instanceof Tenant t && tenant.equals(t.value())) {
92+
matchesTenant = true;
93+
} else if (q instanceof PublicAgentCard) {
94+
hasPublic = true;
95+
} else if (q instanceof ExtendedAgentCard) {
96+
hasExtended = true;
97+
}
98+
}
99+
if (!matchesTenant) {
100+
continue;
101+
}
102+
if (hasPublic) {
103+
if (publicHandle != null) {
104+
throw duplicateTenantBean("@PublicAgentCard", tenant);
105+
}
106+
publicHandle = handle;
107+
} else if (!hasExtended) {
108+
if (bareHandle != null) {
109+
throw duplicateTenantBean("bare public", tenant);
110+
}
111+
bareHandle = handle;
112+
}
113+
}
114+
Instance.Handle<AgentCard> selected = publicHandle != null ? publicHandle : bareHandle;
115+
return selected != null ? selected.get() : null;
116+
} finally {
117+
Instance.Handle<AgentCard> kept = publicHandle != null ? publicHandle : bareHandle;
118+
for (Instance.Handle<AgentCard> h : allHandles) {
119+
if (h != kept) {
120+
h.close();
121+
}
76122
}
77123
}
78-
return null;
124+
}
125+
126+
private static IllegalStateException duplicateTenantBean(String cardType, String tenant) {
127+
return new IllegalStateException(
128+
String.format("Multiple tenant %s cards detected for tenant '%s'", cardType, tenant));
79129
}
80130
}

‎extras/multitenancy/src/main/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentExecutorRouter.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import org.a2aproject.sdk.server.agentexecution.AgentExecutor;
1010
import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter;
11+
import org.a2aproject.sdk.server.multitenancy.Tenant;
1112
import org.a2aproject.sdk.server.util.CdiUtils;
1213
import org.jspecify.annotations.Nullable;
1314

@@ -36,12 +37,26 @@ void init() {
3637
@Override
3738
public AgentExecutor resolve(@Nullable String tenant) {
3839
if (tenant == null || tenant.isBlank()) {
39-
return defaultExecutor;
40+
return requireDefaultExecutor();
4041
}
4142
Instance<AgentExecutor> selected = allExecutors.select(new Tenant.Literal(tenant));
4243
if (selected.isResolvable()) {
4344
return selected.get();
4445
}
46+
return requireDefaultExecutor();
47+
}
48+
49+
/**
50+
* Returns the default executor, throwing if none was discovered at startup.
51+
*
52+
* @return the default executor, never {@code null}
53+
* @throws IllegalStateException if no default {@link AgentExecutor} bean exists
54+
*/
55+
private AgentExecutor requireDefaultExecutor() {
56+
if (defaultExecutor == null) {
57+
throw new IllegalStateException(
58+
"No default AgentExecutor bean found — provide an AgentExecutor bean without @Tenant qualifier");
59+
}
4560
return defaultExecutor;
4661
}
4762
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@NullMarked
2+
package org.a2aproject.sdk.extras.multitenancy;
3+
4+
import org.jspecify.annotations.NullMarked;
5+

‎extras/multitenancy/tests/router-unit/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java‎

Lines changed: 155 additions & 0 deletions

0 commit comments

Comments
 (0)