From ca10e5a04f8886a801cf0fd6ccedb7ea527489df Mon Sep 17 00:00:00 2001 From: Emmanuel Hugonnet Date: Thu, 3 Sep 2026 12:46:55 +0200 Subject: [PATCH] feat(multitenancy): return 404 for unknown tenant on public agent card - Add TenantNotFoundException; JSONRPCHandler and RestHandler throw/return 404 when AgentCardRouter is set but tenant not found - Single-tenant servers (no router) ignore the tenant segment and serve the default card - CdiAgentCardRouter.resolvePublicCard now returns null for unknown/blank tenants instead of the default card - Add Utils.buildTenantCardPath helper; restrict fallback retry in A2ACardResolver to 404 only - Update all tests to expect 404 for unknown tenants Fixes: #1108 Signed-off-by: Emmanuel Hugonnet --- .../multitenancy/CdiAgentCardRouter.java | 7 ++- .../it/MultiTenantJSONRPCTest.java | 9 +-- .../multitenancy/it/MultiTenantRESTTest.java | 9 +-- .../sdk/client/http/A2ACardResolver.java | 63 ++++++++++--------- .../sdk/client/http/A2ACardResolverTest.java | 42 ++++++++++++- .../server/apps/quarkus/A2AServerRoutes.java | 14 +++-- .../server/multitenancy/AgentCardRouter.java | 17 ++--- .../multitenancy/TenantNotFoundException.java | 30 +++++++++ .../org/a2aproject/sdk/spec/util/Utils.java | 12 ++-- .../a2aproject/sdk/spec/util/UtilsTest.java | 14 +++++ .../jsonrpc/AgentCardProducer.java | 10 ++- .../MultiTenantJSONRPCPublicCardTest.java | 8 +-- .../multitenancy/CdiAgentCardRouterTest.java | 14 ++++- .../jsonrpc/handler/JSONRPCHandler.java | 22 +++++-- .../transport/rest/handler/RestHandler.java | 25 +++++--- .../rest/handler/RestHandlerTest.java | 59 +++++++++++++++++ 16 files changed, 269 insertions(+), 86 deletions(-) create mode 100644 server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/TenantNotFoundException.java 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 14429fef4..c7b99d764 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 @@ -25,8 +25,9 @@ * qualifier must NOT be used on tenant-specific public cards because it would cause CDI * ambiguity on injection points requesting the default public card. *

- * Falls back to the default (non-{@code @Tenant}) card when the tenant is {@code null}, - * blank, or does not match any registered tenant. + * 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 + * any registered tenant — the caller treats that as a 404. */ @ApplicationScoped public class CdiAgentCardRouter implements AgentCardRouter { @@ -74,6 +75,6 @@ void init() { return handle.get(); } } - return defaultPublicCard; + return null; } } diff --git a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java index 54977067a..884ccad5c 100644 --- a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java +++ b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantJSONRPCTest.java @@ -122,13 +122,10 @@ public void getPublicAgentCardWithBetaTenant() { } @Test - public void getPublicAgentCardWithUnknownTenantFallsBackToDefault() { - String response = RestAssured.given() + public void unknownTenantReturns404() { + RestAssured.given() .when().get("/.well-known/unknown/agent-card.json") - .then().statusCode(200) - .extract().asString(); - JsonPath json = JsonPath.from(response); - assertEquals("Multi-Tenant Test Agent", json.getString("name")); + .then().statusCode(404); } @Test diff --git a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java index bc0612028..191667484 100644 --- a/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java +++ b/extras/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/it/MultiTenantRESTTest.java @@ -137,13 +137,10 @@ public void getPublicAgentCardWithBetaTenant() { } @Test - public void getPublicAgentCardWithUnknownTenantFallsBackToDefault() { - String response = RestAssured.given() + public void unknownTenantReturns404() { + RestAssured.given() .when().get("/.well-known/unknown/agent-card.json") - .then().statusCode(200) - .extract().asString(); - JsonPath json = JsonPath.from(response); - assertEquals("Multi-Tenant Test Agent", json.getString("name")); + .then().statusCode(404); } @Test diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java index 188dfa908..2c47f7cdb 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java @@ -94,30 +94,9 @@ private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable Stri // Strip any well-known suffix from baseUrl so that a full card URL like // https://host/.well-known/agent-card.json doesn't produce a malformed path. String cleanBase = Utils.stripWellKnownSuffix(baseUrl); - String resolvedCardUrl; - @Nullable String resolvedFallbackUrl; - if (agentCardPath != null && !agentCardPath.isEmpty()) { - // Custom path: tenant goes as path prefix (explicit override); no fallback. - String baseUrlWithTenant = Utils.buildBaseUrl(cleanBase, tenant); - Utils.validateAbsoluteUrl(baseUrlWithTenant); - resolvedCardUrl = Utils.buildCardUrl(baseUrlWithTenant, agentCardPath); - resolvedFallbackUrl = null; - } else { - // Standard well-known path: optionally embed tenant inside the path. - if (tenant != null && !tenant.isBlank()) { - // {base}/.well-known/{tenant}/agent-card.json - Utils.validateTenant(tenant); - Utils.validateAbsoluteUrl(cleanBase); - resolvedCardUrl = Utils.buildCardUrl(cleanBase, "/.well-known/" + Utils.normalizeTenant(tenant) + "/agent-card.json"); - } else { - // {base}/.well-known/agent-card.json - Utils.validateAbsoluteUrl(cleanBase); - resolvedCardUrl = Utils.buildCardUrl(cleanBase, Utils.DEFAULT_AGENT_CARD_PATH); - } - resolvedFallbackUrl = isSameUrl(resolvedCardUrl, baseUrl) ? null : cleanBase; - } - this.cardUrl = resolvedCardUrl; - this.fallbackUrl = resolvedFallbackUrl; + ResolvedUrls resolved = resolveUrls(cleanBase, baseUrl, tenant, agentCardPath); + this.cardUrl = resolved.cardUrl(); + this.fallbackUrl = resolved.fallbackUrl(); } catch (URISyntaxException e) { throw new A2AClientError("Invalid agent URL", e); } @@ -244,8 +223,8 @@ public A2ACardResolver build() throws A2AClientError { *

Fetches from the custom {@code agentCardPath} when one was supplied, otherwise fetches * from the standard {@code /.well-known/agent-card.json} (or tenant-specific variant) endpoint. * When no custom path was provided and the computed card URL differs from the originally - * supplied base URL, a 404 on the primary URL triggers a single retry against the - * original base URL before propagating the error. + * supplied base URL, a 404 response from the primary URL triggers a single retry against + * the original base URL before propagating the error. * * @return the agent card * @throws A2AClientHTTPError If the server returns a non-2xx response (carries status, body, and headers) @@ -271,9 +250,37 @@ public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError { } } + private static ResolvedUrls resolveUrls(String cleanBase, String originalBase, @Nullable String tenant, @Nullable String agentCardPath) throws URISyntaxException { + if (agentCardPath != null && !agentCardPath.isBlank()) { + // Custom path: tenant goes as path prefix (explicit override); no fallback. + String baseUrlWithTenant = Utils.buildBaseUrl(cleanBase, tenant); + Utils.validateAbsoluteUrl(baseUrlWithTenant); + return new ResolvedUrls(Utils.buildCardUrl(baseUrlWithTenant, agentCardPath), null); + } + // Standard well-known path: optionally embed tenant inside the path. + String cardUrl; + if (tenant != null && !tenant.isBlank()) { + // {base}/.well-known/{tenant}/agent-card.json + Utils.validateTenant(tenant); + Utils.validateAbsoluteUrl(cleanBase); + cardUrl = Utils.buildCardUrl(cleanBase, Utils.buildTenantCardPath(tenant)); + } else { + // {base}/.well-known/agent-card.json + Utils.validateAbsoluteUrl(cleanBase); + cardUrl = Utils.buildCardUrl(cleanBase, Utils.DEFAULT_AGENT_CARD_PATH); + } + String fallbackUrl = isSameUrl(cardUrl, originalBase) ? null : cleanBase; + return new ResolvedUrls(cardUrl, fallbackUrl); + } + + private record ResolvedUrls(String cardUrl, @Nullable String fallbackUrl) {} + + // Intentionally limited: only strips trailing slashes. Both arguments are always + // programmatically constructed URLs so case, port, and percent-encoding are consistent. private static boolean isSameUrl(String a, String b) { - String stripSlash = b.endsWith("/") ? b.substring(0, b.length() - 1) : b; - return a.equals(stripSlash); + String normA = a.endsWith("/") ? a.substring(0, a.length() - 1) : a; + String normB = b.endsWith("/") ? b.substring(0, b.length() - 1) : b; + return normA.equals(normB); } private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSONError { diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java index 501e650a3..344648e60 100644 --- a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java @@ -383,6 +383,43 @@ public void testGetAgentCard_doubleSlashBaseUrl_fallbackUrlNormalized() throws E assertEquals("http://example.com/", client.urlsCalled.get(1)); } + @Test + public void testGetAgentCard_doubleSlashWellKnownUrl_normalizedCardUrl() throws Exception { + // A baseUrl that is already the well-known URL but with a double slash before /.well-known + // must strip the well-known suffix and rebuild cleanly. + TestHttpClient client = createTestClient(); + A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com//.well-known/agent-card.json").build().getAgentCard(); + assertEquals(1, client.urlsCalled.size()); + assertEquals("http://example.com" + AGENT_CARD_PATH, client.urlsCalled.get(0)); + } + + @Test + public void testGetAgentCard_doubleSlashTenantCardUrl_sameTenant_normalizedCardUrl() throws Exception { + // baseUrl is the tenant card URL with a double slash; tenant matches — must normalize cleanly. + TestHttpClient client = createTestClient(); + A2ACardResolver.builder().httpClient(client) + .baseUrl("http://example.com//.well-known/acme/agent-card.json") + .tenant("acme") + .build() + .getAgentCard(); + assertEquals(1, client.urlsCalled.size()); + assertEquals("http://example.com/.well-known/acme/agent-card.json", client.urlsCalled.get(0)); + } + + @Test + public void testGetAgentCard_doubleSlashTenantCardUrl_differentTenant_normalizedCardUrl() throws Exception { + // baseUrl is a tenant card URL with a double slash; a different tenant is requested — the + // suffix must be stripped before the new tenant path is embedded. + TestHttpClient client = createTestClient(); + A2ACardResolver.builder().httpClient(client) + .baseUrl("http://example.com//.well-known/acme/agent-card.json") + .tenant("foo") + .build() + .getAgentCard(); + assertEquals(1, client.urlsCalled.size()); + assertEquals("http://example.com/.well-known/foo/agent-card.json", client.urlsCalled.get(0)); + } + @Test public void testGetAgentCard_httpError_bothFail_throwsLastError() throws Exception { // Both primary (/.well-known/agent-card.json) and fallback return 404; last error is propagated @@ -398,9 +435,10 @@ public void testGetAgentCard_httpError_bothFail_throwsLastError() throws Excepti assertEquals(404, ((A2AClientHTTPError) error.getSuppressed()[0]).getCode()); } + @Test - public void testGetAgentCard_nonNotFound_httpError_noFallback() throws Exception { - // Non-404 errors (e.g. 503) must not trigger the fallback — only 1 request made. + public void testGetAgentCard_nonNotFound_noFallback() throws Exception { + // A 5xx from the primary URL is not a URL-format issue, so no fallback is attempted. TestHttpClient client = createTestClient(); client.status = 503; A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build(); 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 35c3de68d..7817224de 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 @@ -72,11 +72,13 @@ import org.a2aproject.sdk.server.extensions.A2AExtensions; import org.a2aproject.sdk.server.util.async.Internal; import org.a2aproject.sdk.server.util.sse.SseFormatter; +import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.InternalError; import org.a2aproject.sdk.spec.JSONParseError; import org.a2aproject.sdk.spec.TransportProtocol; import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.a2aproject.sdk.spec.util.Utils; import org.a2aproject.sdk.transport.jsonrpc.handler.JSONRPCHandler; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -234,7 +236,7 @@ void setupRoutes(@Observes Router router) { .putHeader(CONTENT_TYPE, APPLICATION_JSON) .end(agentCard); } catch (JsonProcessingException e) { - ctx.response().setStatusCode(500).end("Internal Server Error"); + ctx.response().setStatusCode(500).putHeader(CONTENT_TYPE, "text/plain").end("Internal Server Error"); } }); @@ -248,10 +250,12 @@ void setupRoutes(@Observes Router router) { .setStatusCode(200) .putHeader(CONTENT_TYPE, APPLICATION_JSON) .end(agentCard); + } catch (TenantNotFoundException e) { + ctx.response().setStatusCode(404).putHeader(CONTENT_TYPE, "text/plain").end(e.getResponseMessage()); } catch (IllegalArgumentException e) { - ctx.response().setStatusCode(400).end(e.getMessage()); + ctx.response().setStatusCode(400).putHeader(CONTENT_TYPE, "text/plain").end(e.getMessage()); } catch (JsonProcessingException e) { - ctx.response().setStatusCode(500).end("Internal Server Error"); + ctx.response().setStatusCode(500).putHeader(CONTENT_TYPE, "text/plain").end("Internal Server Error"); } }); } @@ -439,15 +443,15 @@ public String getAgentCard(RoutingContext rc) throws JsonProcessingException { * * @param rc the Vert.x routing context (must contain a {@code tenant} path parameter) * @return the tenant-specific agent card as a JSON string - * @throws IllegalArgumentException if the {@code tenant} path parameter is absent + * @throws IllegalArgumentException if the tenant contains invalid characters * @throws JsonProcessingException if serialization fails */ public String getTenantAgentCard(RoutingContext rc) throws JsonProcessingException { - // Route is /.well-known/{tenant}/agent-card.json — the named capture group must be present. String tenant = rc.pathParam("tenant"); if (tenant == null) { throw new IllegalArgumentException("Missing tenant path parameter"); } + Utils.validateTenant(tenant); cacheMetadata.getHttpHeadersMap().forEach((k, v) -> rc.response().putHeader(k, v)); return JsonUtil.toJson(jsonRpcHandler.getAgentCard(tenant)); } diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java index 8f7b0d5fd..c7928e003 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/AgentCardRouter.java @@ -6,8 +6,9 @@ /** * Resolves tenant-specific {@link AgentCard} instances. *

- * Implementations should return the default (unqualified) card when the tenant - * is {@code null}, blank, or does not match any registered tenant. + * Implementations should return the default public card when the tenant is {@code null} + * or blank. When a non-blank tenant is provided and no matching card exists, + * implementations should return {@code null}; the caller will treat that as a 404. */ public interface AgentCardRouter { @@ -22,13 +23,15 @@ public interface AgentCardRouter { /** * Resolves the public {@link AgentCard} for the given tenant. *

- * Returns {@code null} by default, signaling the handler to fall back to the - * default (non-tenant-specific) public agent card injected via {@code @PublicAgentCard}. - * Implementations that manage tenant-specific public cards should return - * a non-{@code null} card for known tenants. + * Implementations should return the default public card when {@code tenant} is + * {@code null} or blank, the tenant-specific card when a match is found, and + * {@code null} when a non-blank tenant has no matching card — the caller treats + * that as HTTP 404. + *

+ * The default implementation returns {@code null} (no public-card routing configured). * * @param tenant the tenant identifier, may be {@code null} - * @return the tenant-specific public agent card, or {@code null} to fall back to the default public card + * @return the public agent card, or {@code null} if a non-blank tenant is unknown */ default @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { return null; diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/TenantNotFoundException.java b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/TenantNotFoundException.java new file mode 100644 index 000000000..3aec2a05b --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/multitenancy/TenantNotFoundException.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.server.multitenancy; + +/** + * Thrown when a tenant-specific public agent card is requested but no card is registered + * for that tenant. Callers should map this to an HTTP 404 response. + */ +public class TenantNotFoundException extends RuntimeException { + + private static final String MESSAGE_PREFIX = "No public agent card registered for tenant: "; + + private final String tenant; + + public TenantNotFoundException(String tenant) { + super(MESSAGE_PREFIX + tenant); + this.tenant = tenant; + } + + public String getTenant() { + return tenant; + } + + /** + * Returns the response body text for this exception (guaranteed non-null). + * + * @return the response message + */ + public String getResponseMessage() { + return MESSAGE_PREFIX + tenant; + } +} diff --git a/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java b/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java index b2ba17305..651c20305 100644 --- a/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java +++ b/spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java @@ -301,12 +301,16 @@ public static void validateTenant(@Nullable String tenant) { } /** - * Normalizes a tenant identifier by stripping any leading or trailing slashes. + * Builds the standard well-known path for a tenant-specific agent card. * - * @param tenant the tenant to normalize, must not be null - * @return the normalized tenant identifier (e.g. {@code "acme"} for {@code "/acme/"}) + * @param tenant the tenant identifier, must not be null or blank + * @return the card path (e.g. {@code "/.well-known/acme/agent-card.json"}) */ - public static String normalizeTenant(String tenant) { + public static String buildTenantCardPath(String tenant) { + return "/.well-known/" + normalizeTenant(tenant) + "/agent-card.json"; + } + + static String normalizeTenant(String tenant) { String stripped = tenant; if (stripped.startsWith("/")) { stripped = stripped.substring(1); diff --git a/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java b/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java index b9aa85afc..2ae04ba14 100644 --- a/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java +++ b/spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java @@ -407,4 +407,18 @@ void testStripWellKnownSuffix_invalidTenantCharsNotStripped() { assertEquals("http://example.com/.well-known/bad@tenant/agent-card.json", Utils.stripWellKnownSuffix("http://example.com/.well-known/bad@tenant/agent-card.json")); } + + // ------------------------------------------------------------------------- + // buildTenantCardPath + // ------------------------------------------------------------------------- + + @Test + void testBuildTenantCardPath_simple() { + assertEquals("/.well-known/acme/agent-card.json", Utils.buildTenantCardPath("acme")); + } + + @Test + void testBuildTenantCardPath_stripsLeadingAndTrailingSlashes() { + assertEquals("/.well-known/acme/agent-card.json", Utils.buildTenantCardPath("/acme/")); + } } diff --git a/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/AgentCardProducer.java b/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/AgentCardProducer.java index 172458dcd..d76f13ead 100644 --- a/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/AgentCardProducer.java +++ b/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/AgentCardProducer.java @@ -6,6 +6,7 @@ import jakarta.inject.Singleton; import org.a2aproject.sdk.extras.multitenancy.Tenant; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.spec.AgentCapabilities; @@ -16,15 +17,18 @@ @Singleton public class AgentCardProducer { - private static final String BASE_URL = "http://localhost:8081"; + @ConfigProperty(name = "quarkus.http.port", defaultValue = "8081") + int serverPort; @Produces + @Singleton @PublicAgentCard public AgentCard publicCard() { return card("Default Agent"); } @Produces + @Singleton @ExtendedAgentCard public AgentCard defaultExtendedCard() { return card("Default Agent (extended)"); @@ -60,7 +64,7 @@ public AgentCard betaExtendedCard() { return card("Beta Agent (extended)"); } - private static AgentCard card(String name) { + private AgentCard card(String name) { return AgentCard.builder() .name(name) .description(name) @@ -69,7 +73,7 @@ private static AgentCard card(String name) { .defaultOutputModes(List.of("text")) .capabilities(AgentCapabilities.builder().streaming(true).extendedAgentCard(true).build()) .skills(List.of()) - .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.JSONRPC.asString(), BASE_URL))) + .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:" + serverPort))) .build(); } } diff --git a/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/MultiTenantJSONRPCPublicCardTest.java b/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/MultiTenantJSONRPCPublicCardTest.java index c61009b70..e795d4cf9 100644 --- a/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/MultiTenantJSONRPCPublicCardTest.java +++ b/tests/multitenancy/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multitenancy/jsonrpc/MultiTenantJSONRPCPublicCardTest.java @@ -43,11 +43,9 @@ public void betaTenantPublicCardIsServed() { } @Test - public void unknownTenantPublicCardFallsBackToDefault() { - String response = RestAssured.given() + public void unknownTenantReturns404() { + RestAssured.given() .when().get("/.well-known/unknown-corp/agent-card.json") - .then().statusCode(200) - .extract().asString(); - assertEquals("Default Agent", JsonPath.from(response).getString("name")); + .then().statusCode(404); } } diff --git a/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java b/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java index f683d1f8a..0d8b770e2 100644 --- a/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java +++ b/tests/multitenancy/src/test/java/org/a2aproject/sdk/extras/multitenancy/CdiAgentCardRouterTest.java @@ -84,19 +84,27 @@ void publicCardKnownTenantResolvesToTenantSpecific() { } @Test - void publicCardUnknownTenantFallsBackToDefault() { + void publicCardUnknownTenantReturnsNull() { startContainer(FullCardProducer.class); CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); - assertEquals("default-public", router.resolvePublicCard("unknown").name()); + assertNull(router.resolvePublicCard("unknown")); } @Test - void publicCardNullTenantReturnsDefault() { + void publicCardNullTenantReturnsDefaultCard() { startContainer(FullCardProducer.class); CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); assertEquals("default-public", router.resolvePublicCard(null).name()); } + @Test + void publicCardBlankTenantReturnsDefaultCard() { + startContainer(FullCardProducer.class); + CdiAgentCardRouter router = container.select(CdiAgentCardRouter.class).get(); + assertEquals("default-public", router.resolvePublicCard("").name()); + assertEquals("default-public", router.resolvePublicCard(" ").name()); + } + @Test void publicCardNoDefaultReturnsNull() { startContainer(TenantOnlyCardProducer.class); 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 77d8d7686..6421f78eb 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 @@ -45,6 +45,7 @@ import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.extensions.A2AExtensions; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.util.async.Internal; @@ -747,19 +748,28 @@ public AgentCard getAgentCard() { * Returns the public agent card, optionally for a specific tenant. *

* When a tenant is specified and an {@link AgentCardRouter} is available, the router - * resolves a tenant-specific public card. Falls back to the default public card - * if no tenant-specific card is configured. + * resolves the card. If the router returns {@code null} the tenant is unknown and a + * {@link TenantNotFoundException} is thrown (callers should map this to HTTP 404). + *

+ * When no router is configured, any non-blank tenant is logged and the default card + * is returned (single-tenant server, tenant segment is ignored). * * @param tenant the tenant identifier, may be {@code null} * @return the public agent card + * @throws TenantNotFoundException if a router is configured but the tenant is not registered */ public AgentCard getAgentCard(@Nullable String tenant) { Utils.validateTenant(tenant); - if (agentCardRouter != null && tenant != null && !tenant.isBlank()) { - AgentCard card = agentCardRouter.resolvePublicCard(tenant); - if (card != null) { - return card; + if (tenant != null && !tenant.isBlank()) { + if (agentCardRouter != null) { + AgentCard card = agentCardRouter.resolvePublicCard(tenant); + if (card != null) { + return card; + } + LOGGER.fine(() -> "Tenant '" + tenant + "' not found in AgentCardRouter — returning 404"); + throw new TenantNotFoundException(tenant); } + LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'"); } return 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 6ef653bcf..4425561d4 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 @@ -41,6 +41,7 @@ import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.extensions.A2AExtensions; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.util.async.Internal; @@ -917,24 +918,32 @@ public HTTPRestResponse getAgentCard() { * Retrieves the public agent card, optionally for a specific tenant. *

* When a tenant is specified and an {@link AgentCardRouter} is available, the router - * resolves a tenant-specific public card. Falls back to the default public card - * if no tenant-specific card is configured. + * resolves the card. A {@code null} result for a non-blank tenant means the tenant is + * unknown and a 404 is returned. When no router is configured the default card is returned + * regardless of the tenant value (single-tenant server). * * @param tenant the tenant identifier, may be {@code null} - * @return the HTTP response containing the agent card + * @return the HTTP response containing the agent card, or a 404 if the tenant is unknown */ public HTTPRestResponse getAgentCard(@Nullable String tenant) { try { Utils.validateTenant(tenant); - if (agentCardRouter != null && tenant != null && !tenant.isBlank()) { - AgentCard card = agentCardRouter.resolvePublicCard(tenant); - if (card != null) { - return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card), - cacheMetadata.getHttpHeadersMap()); + if (tenant != null && !tenant.isBlank()) { + if (agentCardRouter != null) { + AgentCard card = agentCardRouter.resolvePublicCard(tenant); + if (card != null) { + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card), + cacheMetadata.getHttpHeadersMap()); + } + log.fine(() -> "Tenant '" + tenant + "' not found in AgentCardRouter — returning 404"); + throw new TenantNotFoundException(tenant); } + log.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'"); } return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(resolveAgentCard()), cacheMetadata.getHttpHeadersMap()); + } catch (TenantNotFoundException e) { + return new HTTPRestResponse(404, "text/plain", e.getResponseMessage()); } catch (Throwable t) { return createErrorResponse(500, internalError(t)); } diff --git a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java index 50fa9fb3a..b3c2dcdf0 100644 --- a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java @@ -1300,6 +1300,65 @@ public void testExtendedAgentCardWithoutRouter() { Assertions.assertTrue(response.getBody().contains("extended")); } + // ------------------------------------------------------------------------- + // Public agent card — tenant routing + // ------------------------------------------------------------------------- + + @Test + public void testGetPublicAgentCard_withRouterKnownTenant_returns200() { + AgentCard tenantCard = AgentCard.builder(CARD).name("acme-public").build(); + AgentCardRouter router = new AgentCardRouter() { + @Override public AgentCard resolveExtendedCard(String t) { return null; } + @Override public AgentCard resolvePublicCard(String t) { return "acme".equals(t) ? tenantCard : null; } + }; + + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, + createCacheMetadata(CARD), requestHandler, internalExecutor, new FixedInstance<>(router)); + + RestHandler.HTTPRestResponse response = handler.getAgentCard("acme"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains("acme-public")); + } + + @Test + public void testGetPublicAgentCard_withRouterUnknownTenant_returns404() { + AgentCardRouter router = new AgentCardRouter() { + @Override public AgentCard resolveExtendedCard(String t) { return null; } + @Override public AgentCard resolvePublicCard(String t) { return null; } + }; + + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, + createCacheMetadata(CARD), requestHandler, internalExecutor, new FixedInstance<>(router)); + + RestHandler.HTTPRestResponse response = handler.getAgentCard("unknown"); + + Assertions.assertEquals(404, response.getStatusCode()); + } + + @Test + public void testGetPublicAgentCard_noRouterWithTenant_returnsDefaultCard() { + // Single-tenant server: no AgentCardRouter configured; tenant segment is ignored. + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, + createCacheMetadata(CARD), requestHandler, internalExecutor, null); + + RestHandler.HTTPRestResponse response = handler.getAgentCard("any-tenant"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains(CARD.name())); + } + + @Test + public void testGetPublicAgentCard_noTenant_returnsDefaultCard() { + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, + createCacheMetadata(CARD), requestHandler, internalExecutor, null); + + RestHandler.HTTPRestResponse response = handler.getAgentCard((String) null); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains(CARD.name())); + } + @Test public void testVersionNotSupportedErrorOnSubscribeToTask() throws Exception { RestHandler handler = versionTestHandler();