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
6 changes: 5 additions & 1 deletion docs/content/1_3_0_Final/extra/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion docs/content/dev/extra/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
* <h2>Features</h2>
* <ul>
* <li>Standard agent card endpoint discovery ({@code /.well-known/agent-card.json})</li>
* <li>Tenant-specific path support ({@code /tenant/.well-known/agent-card.json})</li>
* <li>Tenant-specific path support ({@code /.well-known/{tenant}/agent-card.json})</li>
* <li>Custom card path support for non-standard agent card locations</li>
* <li>Custom authentication header injection</li>
* <li>Pluggable HTTP client via {@link A2AHttpClientFactory}</li>
Expand All @@ -43,7 +43,7 @@
* .build();
* AgentCard card = resolver.getAgentCard();
*
* // With tenant path
* // With tenant — fetches from /.well-known/my-tenant/agent-card.json
* A2ACardResolver resolver = A2ACardResolver.builder()
* .baseUrl("http://localhost:9999")
* .tenant("my-tenant")
Expand Down Expand Up @@ -83,22 +83,41 @@ public class A2ACardResolver {

private final A2AHttpClient httpClient;
private final String cardUrl;
private final @Nullable String fallbackUrl;
private final @Nullable Map<String, String> authHeaders;

private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath, @Nullable Map<String, String> authHeaders) throws A2AClientError {
checkNotNullParam("httpClient", httpClient);
checkNotNullParam("baseUrl", baseUrl);
this.httpClient = httpClient;
try {
// Strip any well-known suffix from baseUrl before appending the tenant,
// so that a full card URL like https://host/.well-known/agent-card.json + tenant
// doesn't produce a malformed path.
// 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 baseUrlWithTenant = Utils.buildBaseUrl(cleanBase, tenant);
Utils.validateAbsoluteUrl(baseUrlWithTenant);
this.cardUrl = (agentCardPath == null || agentCardPath.isEmpty())
? Utils.buildCardUrl(baseUrlWithTenant, Utils.DEFAULT_AGENT_CARD_PATH)
: Utils.buildCardUrl(baseUrlWithTenant, agentCardPath);
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;
} catch (URISyntaxException e) {
throw new A2AClientError("Invalid agent URL", e);
}
Expand Down Expand Up @@ -223,8 +242,10 @@ public A2ACardResolver build() throws A2AClientError {
* Fetches the agent card for this resolver's configured agent.
*
* <p>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)
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -233,16 +233,16 @@ 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)
.baseUrl("https://example.com/.well-known/agent-card.json")
.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);
}

// -------------------------------------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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
// -------------------------------------------------------------------------
Expand All @@ -319,6 +447,7 @@ private String printAgentCard(AgentCard agentCard) throws InvalidProtocolBufferE

private static class TestHttpClient implements A2AHttpClient {
int status = 200;
List<Integer> statusSequence = new ArrayList<>();
String body;
String url;
boolean throwIOException = false;
Expand Down Expand Up @@ -352,7 +481,7 @@ public A2AHttpResponse get() throws IOException, InterruptedException {
if (throwInterruptedException) {
throw new InterruptedException("Simulated interrupt");
}
int effectiveStatus = status;
int effectiveStatus = statusSequence.isEmpty() ? status : statusSequence.remove(0);
return new A2AHttpResponse() {
@Override
public int status() {
Expand Down
Loading
Loading