diff --git a/docs/content/1_3_0_Final/extra/multi-tenancy.md b/docs/content/1_3_0_Final/extra/multi-tenancy.md index 7c39cb91c..b7ccf2b0d 100644 --- a/docs/content/1_3_0_Final/extra/multi-tenancy.md +++ b/docs/content/1_3_0_Final/extra/multi-tenancy.md @@ -148,7 +148,11 @@ public void execute(RequestContext context, AgentEmitter emitter) throws A2AErro ## Tenant Source -The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). For the REST transport the tenant can also come from the URL path (e.g. `/\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present. +The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). + +For the **public agent card**, the tenant is encoded in the URL path — `/.well-known/\{tenant}/agent-card.json` — and is served by all transports (JSON-RPC and REST). + +For the **REST transport**, task-operation URLs also encode the tenant as a path prefix (e.g. `/\{tenant}/message:send`, `/\{tenant}/extendedAgentCard`); the payload `tenant` field takes precedence when both are present. Tenant identifiers are restricted to `a-zA-Z0-9_-.` characters — path segments containing `/` or `?` are rejected. diff --git a/docs/content/dev/extra/multi-tenancy.md b/docs/content/dev/extra/multi-tenancy.md index 7c39cb91c..b7ccf2b0d 100644 --- a/docs/content/dev/extra/multi-tenancy.md +++ b/docs/content/dev/extra/multi-tenancy.md @@ -148,7 +148,11 @@ public void execute(RequestContext context, AgentEmitter emitter) throws A2AErro ## Tenant Source -The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). For the REST transport the tenant can also come from the URL path (e.g. `/\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present. +The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). + +For the **public agent card**, the tenant is encoded in the URL path — `/.well-known/\{tenant}/agent-card.json` — and is served by all transports (JSON-RPC and REST). + +For the **REST transport**, task-operation URLs also encode the tenant as a path prefix (e.g. `/\{tenant}/message:send`, `/\{tenant}/extendedAgentCard`); the payload `tenant` field takes precedence when both are present. Tenant identifiers are restricted to `a-zA-Z0-9_-.` characters — path segments containing `/` or `?` are rejected. 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 8013de94c..188dfa908 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 @@ -29,7 +29,7 @@ *
Fetches from the custom {@code agentCardPath} when one was supplied, otherwise fetches
- * from the standard {@code /.well-known/agent-card.json} endpoint. No automatic fallback
- * is performed; errors are propagated directly to the caller.
+ * 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.
*
* @return the agent card
* @throws A2AClientHTTPError If the server returns a non-2xx response (carries status, body, and headers)
@@ -234,9 +255,30 @@ public A2ACardResolver build() throws A2AClientError {
*/
public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError {
LOGGER.debug("Fetching agent card from URL: {}", cardUrl);
+ try {
+ return fetchAgentCard(cardUrl);
+ } catch (A2AClientHTTPError e) {
+ if (fallbackUrl != null && e.getCode() == 404) {
+ LOGGER.debug("Failed to fetch agent card from {} (status {}); retrying with provided URL: {}", cardUrl, e.getCode(), fallbackUrl);
+ try {
+ return fetchAgentCard(fallbackUrl);
+ } catch (A2AClientError fallbackEx) {
+ fallbackEx.addSuppressed(e);
+ throw fallbackEx;
+ }
+ }
+ throw e;
+ }
+ }
+
+ private static boolean isSameUrl(String a, String b) {
+ String stripSlash = b.endsWith("/") ? b.substring(0, b.length() - 1) : b;
+ return a.equals(stripSlash);
+ }
+ private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSONError {
A2AHttpClient.GetBuilder builder = httpClient.createGet()
- .url(cardUrl)
+ .url(url)
.addHeader("Content-Type", "application/json");
if (authHeaders != null) {
@@ -248,11 +290,11 @@ public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError {
A2AHttpResponse response = builder.get();
if (!response.success()) {
String msg = "Failed to obtain agent card: " + response.status();
- LOGGER.debug("Failed to fetch agent card from {}, status: {}", cardUrl, response.status());
+ LOGGER.debug("Failed to fetch agent card from {}, status: {}", url, response.status());
throw new A2AClientHTTPError(response.status(), msg, response.body(), response.headers().toMap());
}
body = response.body();
- LOGGER.debug("Successfully fetched agent card from {}", cardUrl);
+ LOGGER.debug("Successfully fetched agent card from {}", url);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new A2AClientError("Failed to obtain agent card", e);
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 1e4a2e690..501e650a3 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
@@ -179,7 +179,7 @@ public void testGetAgentCard_interruptedExceptionThrows() throws Exception {
public void testGetAgentCard_withTenant() throws Exception {
TestHttpClient client = createTestClient();
A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("my-tenant").build().getAgentCard();
- assertEquals("http://example.com/my-tenant" + AGENT_CARD_PATH, client.url);
+ assertEquals("http://example.com/.well-known/my-tenant/agent-card.json", client.url);
}
@Test
@@ -233,8 +233,8 @@ public void testBuilder_malformedBaseUrl_throws() {
@Test
public void testFullWellKnownUrlWithTenant() throws Exception {
- // Full well-known URL + tenant must strip the suffix before appending tenant,
- // not produce a malformed path like ...agent-card.json/my-tenant/.well-known/agent-card.json
+ // Full well-known URL + tenant must strip the suffix before embedding tenant inside the path,
+ // not produce a malformed path like ...agent-card.json/.well-known/my-tenant/agent-card.json
TestHttpClient client = createTestClient();
A2ACardResolver resolver = A2ACardResolver.builder()
.httpClient(client)
@@ -242,7 +242,7 @@ public void testFullWellKnownUrlWithTenant() throws Exception {
.tenant("my-tenant")
.build();
resolver.getAgentCard();
- assertEquals("https://example.com/my-tenant" + AGENT_CARD_PATH, client.url);
+ assertEquals("https://example.com/.well-known/my-tenant/agent-card.json", client.url);
}
// -------------------------------------------------------------------------
@@ -260,7 +260,7 @@ public void testSpec03PathPreservation_wellKnown() throws Exception {
public void testSpec03PathPreservation_withTenant() throws Exception {
TestHttpClient client = createTestClient();
A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").tenant("my-tenant").build().getAgentCard();
- assertEquals("https://example.com/spec03/my-tenant" + AGENT_CARD_PATH, client.url);
+ assertEquals("https://example.com/spec03/.well-known/my-tenant/agent-card.json", client.url);
}
@Test
@@ -303,6 +303,134 @@ public void testFullWellKnownUrlWithSameAgentCardPath() throws Exception {
assertEquals("https://example.com/spec03/.well-known/agent-card.json", client.url);
}
+ // -------------------------------------------------------------------------
+ // Tenant URL — correct /.well-known/{tenant}/agent-card.json pattern
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void testTenantUrl_embeddedInWellKnownPath() throws Exception {
+ TestHttpClient client = createTestClient();
+ A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("acme").build().getAgentCard();
+ assertEquals("http://example.com/.well-known/acme/agent-card.json", client.url);
+ }
+
+ @Test
+ public void testTenantUrl_fullTenantCardUrlProvidedAsBase_notDuplicated() throws Exception {
+ // When the caller already supplies the tenant card URL, it must be used as-is.
+ TestHttpClient client = createTestClient();
+ String tenantCardUrl = "http://example.com/.well-known/acme/agent-card.json";
+ A2ACardResolver.builder().httpClient(client).baseUrl(tenantCardUrl).tenant("acme").build().getAgentCard();
+ assertEquals(tenantCardUrl, client.url);
+ assertEquals(1, client.urlsCalled.size());
+ }
+
+ @Test
+ public void testTenantUrl_fullTenantCardUrlProvidedAsBase_withTrailingSlash_notDuplicated() throws Exception {
+ TestHttpClient client = createTestClient();
+ A2ACardResolver.builder().httpClient(client)
+ .baseUrl("http://example.com/.well-known/acme/agent-card.json/")
+ .tenant("acme")
+ .build()
+ .getAgentCard();
+ assertEquals("http://example.com/.well-known/acme/agent-card.json", client.url);
+ assertEquals(1, client.urlsCalled.size());
+ }
+
+ @Test
+ public void testTenantUrl_fullTenantCardUrlProvidedAsBase_noFallback() throws Exception {
+ // No fallback when the provided URL already IS the computed card URL.
+ TestHttpClient client = createTestClient();
+ client.status = 404;
+ String tenantCardUrl = "http://example.com/.well-known/acme/agent-card.json";
+ A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl(tenantCardUrl).tenant("acme").build();
+ assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
+ assertEquals(1, client.urlsCalled.size());
+ }
+
+ @Test
+ public void testTenantUrl_withSubBasePath() throws Exception {
+ TestHttpClient client = createTestClient();
+ A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").tenant("acme").build().getAgentCard();
+ assertEquals("https://example.com/spec03/.well-known/acme/agent-card.json", client.url);
+ }
+
+ // -------------------------------------------------------------------------
+ // Fallback to provided URL on HTTP error
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void testGetAgentCard_httpError_fallsBackToProvidedUrl() throws Exception {
+ // Primary URL (/.well-known/agent-card.json) returns 404; fallback to original base URL succeeds.
+ TestHttpClient client = createTestClient();
+ client.statusSequence.add(404);
+ client.statusSequence.add(200);
+ A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build().getAgentCard();
+ assertEquals(2, client.urlsCalled.size());
+ assertEquals("http://example.com" + AGENT_CARD_PATH, client.urlsCalled.get(0));
+ assertEquals("http://example.com", client.urlsCalled.get(1));
+ }
+
+ @Test
+ public void testGetAgentCard_doubleSlashBaseUrl_fallbackUrlNormalized() throws Exception {
+ // A baseUrl with a double trailing slash must not produce a double-slash fallback URL.
+ TestHttpClient client = createTestClient();
+ client.statusSequence.add(404);
+ client.statusSequence.add(200);
+ A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com//").build().getAgentCard();
+ assertEquals(2, client.urlsCalled.size());
+ assertEquals("http://example.com" + AGENT_CARD_PATH, client.urlsCalled.get(0));
+ // cleanBase strips one trailing slash from "http://example.com//", yielding "http://example.com/"
+ assertEquals("http://example.com/", client.urlsCalled.get(1));
+ }
+
+ @Test
+ public void testGetAgentCard_httpError_bothFail_throwsLastError() throws Exception {
+ // Both primary (/.well-known/agent-card.json) and fallback return 404; last error is propagated
+ // and the primary error is attached as a suppressed exception.
+ TestHttpClient client = createTestClient();
+ client.status = 404;
+ A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build();
+ A2AClientHTTPError error = assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
+ assertEquals(404, error.getCode());
+ assertEquals(2, client.urlsCalled.size());
+ assertEquals(1, error.getSuppressed().length);
+ assertTrue(error.getSuppressed()[0] instanceof A2AClientHTTPError);
+ 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.
+ TestHttpClient client = createTestClient();
+ client.status = 503;
+ A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build();
+ A2AClientHTTPError error = assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
+ assertEquals(503, error.getCode());
+ assertEquals(1, client.urlsCalled.size());
+ }
+
+ @Test
+ public void testGetAgentCard_noFallback_whenUrlAlreadyIsCardUrl() throws Exception {
+ // When the provided URL already equals the computed card URL, no fallback.
+ TestHttpClient client = createTestClient();
+ client.status = 404;
+ String fullCardUrl = "http://example.com" + AGENT_CARD_PATH;
+ A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl(fullCardUrl).build();
+ assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
+ assertEquals(1, client.urlsCalled.size());
+ }
+
+ @Test
+ public void testGetAgentCard_withTenant_httpError_fallsBackToProvidedUrl() throws Exception {
+ TestHttpClient client = createTestClient();
+ client.statusSequence.add(404);
+ client.statusSequence.add(200);
+ A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("acme").build().getAgentCard();
+ assertEquals(2, client.urlsCalled.size());
+ assertEquals("http://example.com/.well-known/acme/agent-card.json", client.urlsCalled.get(0));
+ assertEquals("http://example.com", client.urlsCalled.get(1));
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -319,6 +447,7 @@ private String printAgentCard(AgentCard agentCard) throws InvalidProtocolBufferE
private static class TestHttpClient implements A2AHttpClient {
int status = 200;
+ List
- * Only {@link #DEFAULT_AGENT_CARD_PATH} is stripped; custom paths are never inferred
- * from the URL structure.
+ * Handles both the standard suffix ({@code /.well-known/agent-card.json}) and
+ * single-level tenant suffixes ({@code /.well-known/{tenant}/agent-card.json}).
*
* @param baseUrl the URL to strip
* @return the URL with any trailing slash and well-known suffix removed
*/
public static String stripWellKnownSuffix(String baseUrl) {
- String s = stripTrailingSlash(baseUrl);
- return s.endsWith(DEFAULT_AGENT_CARD_PATH)
- ? s.substring(0, s.length() - DEFAULT_AGENT_CARD_PATH.length())
- : s;
+ return WELL_KNOWN_SUFFIX.matcher(stripTrailingSlash(baseUrl)).replaceFirst("");
}
/**
@@ -290,13 +294,19 @@ public static void validateTenant(@Nullable String tenant) {
throw new IllegalArgumentException("Tenant exceeds maximum length of " + MAX_TENANT_LENGTH + " characters");
}
- if (!stripped.matches("^[a-zA-Z0-9_.\\-]+$")) {
+ if (!VALID_TENANT_CHARS.matcher(stripped).matches()) {
throw new IllegalArgumentException(
"Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed");
}
}
- private static String normalizeTenant(String tenant) {
+ /**
+ * Normalizes a tenant identifier by stripping any leading or trailing slashes.
+ *
+ * @param tenant the tenant to normalize, must not be null
+ * @return the normalized tenant identifier (e.g. {@code "acme"} for {@code "/acme/"})
+ */
+ public 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 ca6221d7d..b9aa85afc 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
@@ -373,4 +373,38 @@ void testStripWellKnownSuffix_unrelatedPath() {
assertEquals("http://example.com/custom/agent.json",
Utils.stripWellKnownSuffix("http://example.com/custom/agent.json"));
}
+
+ @Test
+ void testStripWellKnownSuffix_tenantSpecific() {
+ assertEquals("http://example.com",
+ Utils.stripWellKnownSuffix("http://example.com/.well-known/acme/agent-card.json"));
+ }
+
+ @Test
+ void testStripWellKnownSuffix_tenantSpecificWithSubPath() {
+ assertEquals("http://example.com/spec03",
+ Utils.stripWellKnownSuffix("http://example.com/spec03/.well-known/acme/agent-card.json"));
+ }
+
+ @Test
+ void testStripWellKnownSuffix_tenantSpecificWithTrailingSlash() {
+ assertEquals("http://example.com",
+ Utils.stripWellKnownSuffix("http://example.com/.well-known/acme/agent-card.json/"));
+ }
+
+ @Test
+ void testStripWellKnownSuffix_multiLevelTenantNotStripped() {
+ // Multi-level tenant paths (containing /) are not stripped
+ assertEquals("http://example.com/.well-known/org/team/agent-card.json",
+ Utils.stripWellKnownSuffix("http://example.com/.well-known/org/team/agent-card.json"));
+ }
+
+ @Test
+ void testStripWellKnownSuffix_invalidTenantCharsNotStripped() {
+ // Tenant with characters outside [a-zA-Z0-9_.-] must not be stripped
+ assertEquals("http://example.com/.well-known/invalid tenant/agent-card.json",
+ Utils.stripWellKnownSuffix("http://example.com/.well-known/invalid tenant/agent-card.json"));
+ assertEquals("http://example.com/.well-known/bad@tenant/agent-card.json",
+ Utils.stripWellKnownSuffix("http://example.com/.well-known/bad@tenant/agent-card.json"));
+ }
}