Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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 {
Expand Down Expand Up @@ -74,6 +75,6 @@ void init() {
return handle.get();
}
}
return defaultPublicCard;
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -244,8 +223,8 @@ public A2ACardResolver build() throws A2AClientError {
* <p>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)
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
});

Expand All @@ -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");
}
});
}
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
/**
* Resolves tenant-specific {@link AgentCard} instances.
* <p>
* 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 {

Expand All @@ -22,13 +23,15 @@ public interface AgentCardRouter {
/**
* Resolves the public {@link AgentCard} for the given tenant.
* <p>
* 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.
* <p>
* 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 8 additions & 4 deletions spec/src/main/java/org/a2aproject/sdk/spec/util/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions spec/src/test/java/org/a2aproject/sdk/spec/util/UtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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/"));
}
}
Loading
Loading