Skip to content

Commit d6068f5

Browse files
authored
fix(multitenancy): support @PublicAgentCard on tenant-specific cards (#1176)
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 b72848d commit d6068f5

19 files changed

Lines changed: 363 additions & 55 deletions

File tree

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ Lets a single A2A server serve multiple tenants with different agent behavior
1212

1313
| Artifact ID | Description |
1414
|-------------|-------------|
15-
| `a2a-java-extras-multitenancy` | `@Tenant` qualifier, `CdiAgentExecutorRouter`, `CdiAgentCardRouter` |
15+
| `a2a-java-extras-multitenancy` | `CdiAgentExecutorRouter`, `CdiAgentCardRouter` |
16+
17+
> **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.
1618
1719
### Add Dependency
1820

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

107-
// Tenant-specific public card — no @PublicAgentCard qualifier (see note below)
109+
// Tenant-specific public card — @PublicAgentCard is optional but recommended
108110
@Produces
109111
@Tenant("acme")
112+
@PublicAgentCard
110113
public AgentCard acmePublicCard() {
111114
return AgentCard.builder().name("Acme Agent")...build();
112115
}
113116
}
114117
```
115118

116-
> **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.
119+
> **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`.
117120
118121
## Routing Rules
119122

@@ -132,7 +135,7 @@ public class MyAgentCards {
132135
| `getExtendedAgentCard` with no tenant | Default `@ExtendedAgentCard` |
133136
| `getExtendedAgentCard` with `tenant: "acme"` | `@Tenant("acme") @ExtendedAgentCard`, or default |
134137
| `GET /.well-known/agent-card.json` | Default `@PublicAgentCard` |
135-
| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme")` card (no `@PublicAgentCard`), or default |
138+
| `GET /.well-known/acme/agent-card.json` | `@Tenant("acme") @PublicAgentCard` card (preferred), or bare `@Tenant("acme")` card, or default |
136139

137140
## Accessing the Tenant in AgentExecutor
138141

‎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
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
44
import static org.junit.jupiter.api.Assertions.assertNull;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
56

67
import java.util.Collections;
78
import java.util.List;
@@ -12,6 +13,7 @@
1213

1314
import org.a2aproject.sdk.server.ExtendedAgentCard;
1415
import org.a2aproject.sdk.server.PublicAgentCard;
16+
import org.a2aproject.sdk.server.multitenancy.Tenant;
1517
import org.a2aproject.sdk.spec.AgentCapabilities;
1618
import org.a2aproject.sdk.spec.AgentCard;
1719
import org.a2aproject.sdk.spec.AgentInterface;
@@ -113,6 +115,58 @@ void publicCardNoDefaultReturnsNull() {
113115
assertNull(router.resolvePublicCard("unknown"));
114116
}
115117

118+
@Test
119+
void publicCardWithPublicQualifierResolvesToTenantSpecific() {
120+
startContainer(DefaultPublicAndExtendedCardProducer.class, PublicQualifiedTenantCardProducer.class);
121+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
122+
assertEquals("acme-public-qualified", router.resolvePublicCard("acme").name());
123+
}
124+
125+
@Test
126+
void publicCardWithBothPublicAndExtendedQualifierResolvesToTenantSpecific() {
127+
startContainer(DefaultPublicAndExtendedCardProducer.class, PublicAndExtendedTenantCardProducer.class);
128+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
129+
assertEquals("acme-both", router.resolvePublicCard("acme").name());
130+
}
131+
132+
@Test
133+
void explicitPublicCardTakesPrecedenceOverBareTenantCard() {
134+
startContainer(DefaultPublicAndExtendedCardProducer.class, BareAndPublicQualifiedTenantCardProducer.class);
135+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
136+
assertEquals("acme-public-qualified", router.resolvePublicCard("acme").name());
137+
}
138+
139+
@Test
140+
void duplicatePublicQualifiedCardsThrows() {
141+
startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicatePublicQualifiedTenantCardProducer.class);
142+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
143+
assertThrows(IllegalStateException.class, () -> router.resolvePublicCard("acme"));
144+
}
145+
146+
@Test
147+
void duplicateBareTenantCardsThrows() {
148+
startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicateBareTenantCardProducer.class);
149+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
150+
assertThrows(IllegalStateException.class, () -> router.resolvePublicCard("acme"));
151+
}
152+
153+
@Test
154+
void duplicateExtendedTenantCardsThrows() {
155+
startContainer(DefaultPublicAndExtendedCardProducer.class, DuplicateExtendedTenantCardProducer.class);
156+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
157+
assertThrows(IllegalStateException.class, () -> router.resolveExtendedCard("acme"));
158+
}
159+
160+
@Test
161+
void dualQualifiedCardResolvedByBothPublicAndExtended() {
162+
startContainer(DefaultPublicAndExtendedCardProducer.class, PublicAndExtendedTenantCardProducer.class);
163+
CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get();
164+
AgentCard publicCard = router.resolvePublicCard("acme");
165+
AgentCard extendedCard = router.resolveExtendedCard("acme");
166+
assertEquals("acme-both", publicCard.name());
167+
assertEquals("acme-both", extendedCard.name());
168+
}
169+
116170
private static AgentCard buildCard(String name) {
117171
return AgentCard.builder()
118172
.name(name)
@@ -179,4 +233,105 @@ AgentCard acmeExtendedCard() {
179233
return buildCard("acme-extended");
180234
}
181235
}
236+
237+
static class DefaultPublicAndExtendedCardProducer {
238+
239+
@Produces
240+
@PublicAgentCard
241+
AgentCard defaultPublicCard() {
242+
return buildCard("default-public");
243+
}
244+
245+
@Produces
246+
@ExtendedAgentCard
247+
AgentCard defaultExtendedCard() {
248+
return buildCard("default-extended");
249+
}
250+
}
251+
252+
static class PublicQualifiedTenantCardProducer {
253+
254+
@Produces
255+
@Tenant("acme")
256+
@PublicAgentCard
257+
AgentCard acmePublicCard() {
258+
return buildCard("acme-public-qualified");
259+
}
260+
}
261+
262+
static class PublicAndExtendedTenantCardProducer {
263+
264+
@Produces
265+
@Tenant("acme")
266+
@PublicAgentCard
267+
@ExtendedAgentCard
268+
AgentCard acmeBothCard() {
269+
return buildCard("acme-both");
270+
}
271+
}
272+
273+
static class BareAndPublicQualifiedTenantCardProducer {
274+
275+
@Produces
276+
@Tenant("acme")
277+
AgentCard acmeBareCard() {
278+
return buildCard("acme-bare");
279+
}
280+
281+
@Produces
282+
@Tenant("acme")
283+
@PublicAgentCard
284+
AgentCard acmePublicCard() {
285+
return buildCard("acme-public-qualified");
286+
}
287+
}
288+
289+
static class DuplicatePublicQualifiedTenantCardProducer {
290+
291+
@Produces
292+
@Tenant("acme")
293+
@PublicAgentCard
294+
AgentCard acmePublicCard1() {
295+
return buildCard("acme-public-1");
296+
}
297+
298+
@Produces
299+
@Tenant("acme")
300+
@PublicAgentCard
301+
AgentCard acmePublicCard2() {
302+
return buildCard("acme-public-2");
303+
}
304+
}
305+
306+
static class DuplicateBareTenantCardProducer {
307+
308+
@Produces
309+
@Tenant("acme")
310+
AgentCard acmeBareCard1() {
311+
return buildCard("acme-bare-1");
312+
}
313+
314+
@Produces
315+
@Tenant("acme")
316+
AgentCard acmeBareCard2() {
317+
return buildCard("acme-bare-2");
318+
}
319+
}
320+
321+
static class DuplicateExtendedTenantCardProducer {
322+
323+
@Produces
324+
@Tenant("acme")
325+
@ExtendedAgentCard
326+
AgentCard acmeExtendedCard1() {
327+
return buildCard("acme-extended-1");
328+
}
329+
330+
@Produces
331+
@Tenant("acme")
332+
@ExtendedAgentCard
333+
AgentCard acmeExtendedCard2() {
334+
return buildCard("acme-extended-2");
335+
}
336+
}
182337
}

0 commit comments

Comments
 (0)