From 089764d71e64b959687b8328b89c00517cb75298 Mon Sep 17 00:00:00 2001 From: Emmanuel Hugonnet Date: Tue, 5 May 2026 11:03:07 +0200 Subject: [PATCH] feat!: support complete agent card URL in A2ACardResolver URI.resolve() silently drops intermediate path segments when the resolved path starts with '/', causing incorrect URLs for agents hosted under a path prefix (e.g. /spec03). Replace with direct concatenation and add a pass-through guard when the supplied URL already ends with the agent card path. Signed-off-by: Emmanuel Hugonnet --- .../src/main/java/org/a2aproject/sdk/A2A.java | 11 +- .../examples/helloworld/HelloWorldClient.java | 2 +- .../sdk/client/http/A2ACardResolver.java | 338 ++++++++++++------ .../sdk/client/http/package-info.java | 4 +- .../sdk/client/http/A2ACardResolverTest.java | 213 +++++++---- 5 files changed, 402 insertions(+), 166 deletions(-) diff --git a/client/base/src/main/java/org/a2aproject/sdk/A2A.java b/client/base/src/main/java/org/a2aproject/sdk/A2A.java index 48e40fb15..db05bfe6d 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/A2A.java +++ b/client/base/src/main/java/org/a2aproject/sdk/A2A.java @@ -393,7 +393,14 @@ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, M * @throws org.a2aproject.sdk.spec.A2AClientJSONError if the response body cannot be decoded as JSON or validated against the AgentCard schema */ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, String relativeCardPath, Map authHeaders) throws A2AClientError, A2AClientJSONError { - A2ACardResolver resolver = new A2ACardResolver(httpClient, agentUrl, "", relativeCardPath, authHeaders); - return resolver.getAgentCard(); + A2ACardResolver resolver = A2ACardResolver.builder() + .httpClient(httpClient) + .baseUrl(agentUrl) + .agentCardPath(relativeCardPath) + .authHeaders(authHeaders) + .build(); + return (relativeCardPath == null || relativeCardPath.isBlank()) + ? resolver.getWellKnownAgentCard() + : resolver.getConfiguredAgentCard(); } } diff --git a/examples/helloworld/client/src/main/java/org/a2aproject/sdk/examples/helloworld/HelloWorldClient.java b/examples/helloworld/client/src/main/java/org/a2aproject/sdk/examples/helloworld/HelloWorldClient.java index d948c2e7c..fc50d5673 100644 --- a/examples/helloworld/client/src/main/java/org/a2aproject/sdk/examples/helloworld/HelloWorldClient.java +++ b/examples/helloworld/client/src/main/java/org/a2aproject/sdk/examples/helloworld/HelloWorldClient.java @@ -53,7 +53,7 @@ public class HelloWorldClient { public static void main(String[] args) { OpenTelemetrySdk openTelemetrySdk = null; try { - AgentCard publicAgentCard = new A2ACardResolver(SERVER_URL).getAgentCard(); + AgentCard publicAgentCard = A2ACardResolver.builder().baseUrl(SERVER_URL).build().getWellKnownAgentCard(); System.out.println("Successfully fetched public agent card:"); System.out.println(JsonUtil.toJson(publicAgentCard)); System.out.println("Using public agent card for client initialization (default)."); 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 d1edf49b8..47267b4c3 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 @@ -1,184 +1,320 @@ package org.a2aproject.sdk.client.http; +import static org.a2aproject.sdk.util.Assert.checkNotNullParam; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.Map; - import org.a2aproject.sdk.grpc.utils.JSONRPCUtils; import org.a2aproject.sdk.grpc.utils.ProtoUtils; import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; import org.a2aproject.sdk.spec.A2AClientError; import org.a2aproject.sdk.spec.A2AClientJSONError; -import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.A2AError; -import org.jspecify.annotations.Nullable; - -import static org.a2aproject.sdk.util.Assert.checkNotNullParam; - +import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Utility for fetching agent cards from A2A agents. * - *

Retrieves agent cards from the standard {@code /.well-known/agent-card.json} endpoint + *

+ * Retrieves agent cards from the standard {@code /.well-known/agent-card.json} endpoint * with support for tenant-specific paths and authentication headers. * *

Features

*
    - *
  • Standard agent card endpoint discovery ({@code /.well-known/agent-card.json})
  • - *
  • Tenant-specific path support ({@code /tenant/.well-known/agent-card.json})
  • - *
  • Custom authentication header injection
  • - *
  • Pluggable HTTP client via {@link A2AHttpClientFactory}
  • - *
  • Support for both public and extended agent cards
  • + *
  • Standard agent card endpoint discovery ({@code /.well-known/agent-card.json})
  • + *
  • Tenant-specific path support ({@code /tenant/.well-known/agent-card.json})
  • + *
  • Custom card path support for non-standard agent card locations
  • + *
  • Custom authentication header injection
  • + *
  • Pluggable HTTP client via {@link A2AHttpClientFactory}
  • *
* *

Usage Examples

*
{@code
- * // Basic usage - fetch agent card
- * A2ACardResolver resolver = new A2ACardResolver("http://localhost:9999");
- * AgentCard card = resolver.getAgentCard();
+ * // Basic usage - fetch the well-known agent card from a base URL
+ * A2ACardResolver resolver = A2ACardResolver.builder()
+ *     .baseUrl("http://localhost:9999")
+ *     .build();
+ * AgentCard card = resolver.getWellKnownAgentCard();
  *
  * // With tenant path
- * A2ACardResolver resolver = new A2ACardResolver("http://localhost:9999", "my-tenant");
- * AgentCard card = resolver.getAgentCard();
+ * A2ACardResolver resolver = A2ACardResolver.builder()
+ *     .baseUrl("http://localhost:9999")
+ *     .tenant("my-tenant")
+ *     .build();
+ * AgentCard card = resolver.getWellKnownAgentCard();
  *
- * // With custom HTTP client
+ * // With custom HTTP client and authentication
  * A2AHttpClient httpClient = A2AHttpClientFactory.create();
- * A2ACardResolver resolver = new A2ACardResolver(httpClient, "http://localhost:9999", "my-tenant");
- * AgentCard card = resolver.getAgentCard();
+ * A2ACardResolver resolver = A2ACardResolver.builder()
+ *     .httpClient(httpClient)
+ *     .baseUrl("http://localhost:9999")
+ *     .tenant("my-tenant")
+ *     .authHeader("Authorization", "Bearer token")
+ *     .build();
+ * AgentCard card = resolver.getWellKnownAgentCard();
  *
- * // With authentication headers
- * A2AHttpClient httpClient = A2AHttpClientFactory.create();
- * Map authHeaders = Map.of("Authorization", "Bearer token");
- * A2ACardResolver resolver = new A2ACardResolver(
- *     httpClient,
- *     "http://localhost:9999",
- *     "my-tenant",
- *     null,  // use default agent card path
- *     authHeaders
- * );
- * AgentCard card = resolver.getAgentCard();
+ * // With a custom agent card path
+ * A2ACardResolver resolver = A2ACardResolver.builder()
+ *     .baseUrl("http://localhost:9999")
+ *     .agentCardPath("/custom/agent.json")
+ *     .build();
+ * AgentCard card = resolver.getConfiguredAgentCard();
  *
- * // Fetch extended agent card (if available)
- * AgentCard extendedCard = resolver.getExtendedAgentCard();
+ * // Using a complete URL (e.g., with path prefix like /spec03)
+ * A2ACardResolver resolver = A2ACardResolver.builder()
+ *     .baseUrl("https://example.com/spec03")
+ *     .build();
+ * AgentCard card = resolver.getWellKnownAgentCard();
  * }
* * @see AgentCard * @see A2AHttpClient */ public class A2ACardResolver { - private final A2AHttpClient httpClient; - private final String url; - private final @Nullable Map authHeaders; private static final String DEFAULT_AGENT_CARD_PATH = "/.well-known/agent-card.json"; + private static final Logger LOGGER = LoggerFactory.getLogger(A2ACardResolver.class); + private final A2AHttpClient httpClient; /** - * Creates an agent card resolver. An HTTP client will be auto-selected via {@link A2AHttpClientFactory}. - * - * @param baseUrl the base URL for the agent whose agent card we want to retrieve, must not be null - * @throws A2AClientError if the URL for the agent is invalid - * @throws IllegalArgumentException if baseUrl is null + * URL used by {@link #getWellKnownAgentCard()}. */ - public A2ACardResolver(String baseUrl) throws A2AClientError { - this(A2AHttpClientFactory.create(), baseUrl, null, null); - } - + private final String wellKnownUrl; /** - * Get the agent card for an A2A agent. An HTTP client will be auto-selected via {@link A2AHttpClientFactory}. - * - * @param baseUrl the base URL for the agent whose agent card we want to retrieve, must not be null - * @param tenant the tenant path to use when fetching the agent card, may be null for no tenant - * @throws A2AClientError if the URL for the agent is invalid - * @throws IllegalArgumentException if baseUrl is null + * URL used by {@link #getConfiguredAgentCard()}. */ - public A2ACardResolver(String baseUrl, @Nullable String tenant) throws A2AClientError { - this(A2AHttpClientFactory.create(), baseUrl, tenant, null); + private final String configuredAgentCardUrl; + private final @Nullable Map authHeaders; + + private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath, @Nullable Map authHeaders) throws A2AClientError { + checkNotNullParam("httpClient", httpClient); + checkNotNullParam("baseUrl", baseUrl); + this.httpClient = httpClient; + try { + URI builtBaseUri = new URI(org.a2aproject.sdk.util.Utils.buildBaseUrl(new AgentInterface("JSONRPC", baseUrl, ""), tenant)); + this.wellKnownUrl = resolveAgentCardUrl(builtBaseUri, DEFAULT_AGENT_CARD_PATH).toString(); + this.configuredAgentCardUrl = (agentCardPath == null || agentCardPath.isBlank()) + ? builtBaseUri.toString() + : resolveAgentCardUrl(builtBaseUri, agentCardPath).toString(); + + LOGGER.debug("Initialized A2ACardResolver with wellKnownUrl={}, configuredAgentCardUrl={}", wellKnownUrl, configuredAgentCardUrl); + } catch (URISyntaxException e) { + throw new A2AClientError("Invalid agent URL", e); + } + this.authHeaders = authHeaders; } /** - * Constructs an A2ACardResolver with a specific HTTP client and base URL. + * Creates a new builder for constructing an A2ACardResolver. * - * @param httpClient the HTTP client to use for fetching the agent card, must not be null - * @param baseUrl the base URL for the agent whose agent card we want to retrieve, must not be null - * @param tenant the tenant path to use when fetching the agent card, may be null for no tenant - * @throws A2AClientError if the URL for the agent is invalid - * @throws IllegalArgumentException if httpClient or baseUrl is null + * @return a new builder instance */ - public A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant) throws A2AClientError { - this(httpClient, baseUrl, tenant, null); + public static Builder builder() { + return new Builder(); } /** - * Constructs an A2ACardResolver with a specific HTTP client, base URL, and custom agent card path. - * - * @param httpClient the HTTP client to use for fetching the agent card, must not be null - * @param baseUrl the base URL for the agent whose agent card we want to retrieve, must not be null - * @param tenant the tenant path to use when fetching the agent card, may be null for no tenant - * @param agentCardPath optional path to the agent card endpoint relative to the base - * agent URL, defaults to "/.well-known/agent-card.json" if null or empty - * @throws A2AClientError if the URL for the agent is invalid - * @throws IllegalArgumentException if httpClient or baseUrl is null + * Builder for creating A2ACardResolver instances with a fluent API. */ - public A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath) throws A2AClientError { - this(httpClient, baseUrl, tenant, agentCardPath, null); + public static class Builder { + + private @Nullable + A2AHttpClient httpClient; + private @Nullable + String baseUrl; + private @Nullable + String tenant; + private @Nullable + String agentCardPath; + private @Nullable + Map authHeaders; + + private Builder() { + } + + /** + * Sets the HTTP client to use for fetching agent cards. + * + * @param httpClient the HTTP client, if null a default client will be created + * @return this builder + */ + public Builder httpClient(@Nullable A2AHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + /** + * Sets the base URL for the agent. + * + * @param baseUrl the base URL, must not be null + * @return this builder + */ + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + /** + * Sets the tenant path to use when fetching the agent card. + * + * @param tenant the tenant path, may be null for no tenant + * @return this builder + */ + public Builder tenant(@Nullable String tenant) { + this.tenant = tenant; + return this; + } + + /** + * Sets a custom agent card path relative to the base URL. + * + * @param agentCardPath the custom agent card path, may be null to use base URL as-is + * @return this builder + */ + public Builder agentCardPath(@Nullable String agentCardPath) { + this.agentCardPath = agentCardPath; + return this; + } + + /** + * Sets the authentication headers to use when fetching the agent card. + * + * @param authHeaders the authentication headers, may be null + * @return this builder + */ + public Builder authHeaders(@Nullable Map authHeaders) { + this.authHeaders = authHeaders; + return this; + } + + /** + * Adds a single authentication header. + * + * @param name the header name + * @param value the header value + * @return this builder + */ + public Builder authHeader(String name, String value) { + if (this.authHeaders == null) { + this.authHeaders = new java.util.HashMap<>(); + } + this.authHeaders.put(name, value); + return this; + } + + /** + * Builds the A2ACardResolver instance. + * + * @return a new A2ACardResolver + * @throws A2AClientError if the configuration is invalid + * @throws IllegalArgumentException if baseUrl is null + */ + public A2ACardResolver build() throws A2AClientError { + A2AHttpClient client = httpClient != null ? httpClient : A2AHttpClientFactory.create(); + if (baseUrl == null) { + throw new IllegalArgumentException("baseUrl must not be null"); + } + return new A2ACardResolver(client, baseUrl, tenant, agentCardPath, authHeaders); + } } /** - * Constructs an A2ACardResolver with full configuration including authentication headers. + * Fetches the agent card from the standard {@code /.well-known/agent-card.json} endpoint. * - * @param httpClient the HTTP client to use for fetching the agent card, must not be null - * @param baseUrl the base URL for the agent whose agent card we want to retrieve, must not be null - * @param tenant the tenant path to use when fetching the agent card, may be null for no tenant - * @param agentCardPath optional path to the agent card endpoint relative to the base - * agent URL, defaults to "/.well-known/agent-card.json" if null or empty - * @param authHeaders the HTTP authentication headers to use, may be null - * @throws A2AClientError if the URL for the agent is invalid - * @throws IllegalArgumentException if httpClient or baseUrl is null + * @return the agent card + * @throws A2AClientError If an HTTP error occurs fetching the card + * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard + * schema */ - public A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath, - @Nullable Map authHeaders) throws A2AClientError { - checkNotNullParam("httpClient", httpClient); - checkNotNullParam("baseUrl", baseUrl); - - this.httpClient = httpClient; - String effectiveAgentCardPath = (agentCardPath == null || agentCardPath.isEmpty()) ? DEFAULT_AGENT_CARD_PATH : agentCardPath; - try { - this.url = new URI(org.a2aproject.sdk.util.Utils.buildBaseUrl(new AgentInterface("JSONRPC", baseUrl, ""), tenant)).resolve(effectiveAgentCardPath).toString(); - } catch (URISyntaxException e) { - throw new A2AClientError("Invalid agent URL", e); - } - this.authHeaders = authHeaders; + public AgentCard getWellKnownAgentCard() throws A2AClientError, A2AClientJSONError { + return fetchAgentCard(this.wellKnownUrl); } /** - * Get the agent card for the configured A2A agent. + * Fetches the agent card from the configured card URL. + * + *

+ * Uses the configured agent card URL, which is either: + *

    + *
  • the configured {@code baseUrl} as-is when no {@code agentCardPath} was supplied, or
  • + *
  • the {@code agentCardPath} resolved relative to the configured {@code baseUrl}.
  • + *
* * @return the agent card * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard schema + * @throws A2AClientJSONError If the response body cannot be decoded as JSON or validated against the AgentCard + * schema + */ + public AgentCard getConfiguredAgentCard() throws A2AClientError, A2AClientJSONError { + return fetchAgentCard(this.configuredAgentCardUrl); + } + + /** + * @deprecated Use {@link #getConfiguredAgentCard()} for fetching the configured card URL, or + * {@link #getWellKnownAgentCard()} for the standard well-known endpoint. */ + @Deprecated public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError { + return getConfiguredAgentCard(); + } + + private static URI resolveAgentCardUrl(URI baseUri, String agentCardPath) throws URISyntaxException { + if (baseUri.getPath() != null && baseUri.getPath().endsWith(DEFAULT_AGENT_CARD_PATH)) { + LOGGER.debug("Base URI already ends with agent card path, returning as-is: {}", baseUri); + return baseUri; + } + + String normalizedBasePath = normalizeBasePath(baseUri.getPath()); + String normalizedAgentCardPath = agentCardPath.startsWith("/") ? agentCardPath : "/" + agentCardPath; + URI resolvedUri = new URI( + baseUri.getScheme(), + baseUri.getAuthority(), + normalizedBasePath + normalizedAgentCardPath, + baseUri.getQuery(), + baseUri.getFragment()); + + LOGGER.debug("Resolved agent card URL: baseUri={}, agentCardPath={}, result={}", baseUri, agentCardPath, resolvedUri); + + return resolvedUri; + } + + private static String normalizeBasePath(@Nullable String path) { + if (path == null || path.isEmpty() || "/".equals(path)) { + return ""; + } + return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + + private AgentCard fetchAgentCard(String agentCardUrl) throws A2AClientError, A2AClientJSONError { + LOGGER.debug("Fetching agent card from URL: {}", agentCardUrl); + A2AHttpClient.GetBuilder builder = httpClient.createGet() - .url(url) + .url(agentCardUrl) .addHeader("Content-Type", "application/json"); if (authHeaders != null) { - for (Map.Entry entry : authHeaders.entrySet()) { - builder.addHeader(entry.getKey(), entry.getValue()); - } + builder.addHeaders(authHeaders); } String body; try { A2AHttpResponse response = builder.get(); if (!response.success()) { + LOGGER.error("Failed to fetch agent card, status: {}", response.status()); throw new A2AClientError("Failed to obtain agent card: " + response.status()); } body = response.body(); - } catch (IOException | InterruptedException e) { + LOGGER.debug("Successfully fetched agent card from {}", agentCardUrl); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new A2AClientError("Failed to obtain agent card", e); + } catch (IOException e) { throw new A2AClientError("Failed to obtain agent card", e); } diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/package-info.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/package-info.java index b4b33edc0..fa28dd163 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/package-info.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/package-info.java @@ -24,8 +24,8 @@ *

Usage Example

*
{@code
  * // Fetch an agent card
- * A2ACardResolver resolver = new A2ACardResolver("http://localhost:9999");
- * AgentCard card = resolver.getAgentCard();
+ * A2ACardResolver resolver = A2ACardResolver.builder().baseUrl("http://localhost:9999").build();
+ * AgentCard card = resolver.getWellKnownAgentCard();
  *
  * // Make HTTP requests
  * A2AHttpClient client = A2AHttpClientFactory.create();
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 19c91ded8..64b0caf46 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
@@ -1,83 +1,79 @@
 package org.a2aproject.sdk.client.http;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.util.JsonFormat;
 import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Consumer;
-
-import com.google.protobuf.InvalidProtocolBufferException;
-import com.google.protobuf.util.JsonFormat;
 import org.a2aproject.sdk.grpc.utils.JSONRPCUtils;
 import org.a2aproject.sdk.grpc.utils.ProtoUtils;
 import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException;
 import org.a2aproject.sdk.spec.A2AClientError;
 import org.a2aproject.sdk.spec.A2AClientJSONError;
 import org.a2aproject.sdk.spec.AgentCard;
-import java.util.Map;
 import org.junit.jupiter.api.Test;
 
 public class A2ACardResolverTest {
 
     private static final String AGENT_CARD_PATH = "/.well-known/agent-card.json";
 
-    @Test
-    public void testConstructorStripsSlashes() throws Exception {
+    private TestHttpClient createTestClient() {
         TestHttpClient client = new TestHttpClient();
         client.body = JsonMessages.AGENT_CARD;
+        return client;
+    }
 
-        A2ACardResolver resolver = new A2ACardResolver(client, "http://example.com/", "");
-        AgentCard card = resolver.getAgentCard();
+    @Test
+    public void testWellKnownUrlStripsSlashes() throws Exception {
+        TestHttpClient client = createTestClient();
 
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").build().getWellKnownAgentCard();
         assertEquals("http://example.com" + AGENT_CARD_PATH, client.url);
 
-
-        resolver = new A2ACardResolver(client, "http://example.com", "");
-        card = resolver.getAgentCard();
-
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build().getWellKnownAgentCard();
         assertEquals("http://example.com" + AGENT_CARD_PATH, client.url);
+    }
 
-        // baseUrl with trailing slash, agentCardParth with leading slash
-        resolver = new A2ACardResolver(client, "http://example.com/", AGENT_CARD_PATH);
-        card = resolver.getAgentCard();
+    @Test
+    public void testConfiguredAgentCardUrlNormalization() throws Exception {
+        TestHttpClient client = createTestClient();
 
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").agentCardPath(AGENT_CARD_PATH).build().getConfiguredAgentCard();
         assertEquals("http://example.com" + AGENT_CARD_PATH, client.url);
 
-        // baseUrl without trailing slash, agentCardPath with leading slash
-        resolver = new A2ACardResolver(client, "http://example.com", AGENT_CARD_PATH);
-        card = resolver.getAgentCard();
-
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").agentCardPath(AGENT_CARD_PATH).build().getConfiguredAgentCard();
         assertEquals("http://example.com" + AGENT_CARD_PATH, client.url);
 
-        // baseUrl with trailing slash, agentCardPath without leading slash
-        resolver = new A2ACardResolver(client, "http://example.com/", AGENT_CARD_PATH.substring(1));
-        card = resolver.getAgentCard();
-
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").agentCardPath(AGENT_CARD_PATH.substring(1)).build().getConfiguredAgentCard();
         assertEquals("http://example.com" + AGENT_CARD_PATH, client.url);
 
-        // baseUrl without trailing slash, agentCardPath without leading slash
-        resolver = new A2ACardResolver(client, "http://example.com", AGENT_CARD_PATH.substring(1));
-        card = resolver.getAgentCard();
-
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").agentCardPath(AGENT_CARD_PATH.substring(1)).build().getConfiguredAgentCard();
         assertEquals("http://example.com" + AGENT_CARD_PATH, client.url);
     }
 
+    @Test
+    public void testConfiguredAgentCardUrlDoesNotIntroduceDoubleSlash() throws Exception {
+        TestHttpClient client = createTestClient();
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").agentCardPath("/custom/agent.json").build().getConfiguredAgentCard();
+
+        assertEquals("http://example.com/custom/agent.json", client.url);
+    }
 
     @Test
-    public void testGetAgentCardSuccess() throws Exception {
-        TestHttpClient client = new TestHttpClient();
-        client.body = JsonMessages.AGENT_CARD;
+    public void testGetWellKnownAgentCardSuccess() throws Exception {
+        TestHttpClient client = createTestClient();
 
-        A2ACardResolver resolver = new A2ACardResolver(client, "http://example.com/", "");
-        AgentCard card = resolver.getAgentCard();
+        AgentCard card = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").build().getWellKnownAgentCard();
 
         AgentCard expectedCard = unmarshalFrom(JsonMessages.AGENT_CARD);
-        String expected = printAgentCard(expectedCard);
-
-        String requestCardString = printAgentCard(card);
-        assertEquals(expected, requestCardString);
+        assertEquals(printAgentCard(expectedCard), printAgentCard(card));
     }
 
     private AgentCard unmarshalFrom(String body) throws JsonProcessingException {
@@ -91,42 +87,129 @@ private String printAgentCard(AgentCard agentCard) throws InvalidProtocolBufferE
     }
 
     @Test
-    public void testGetAgentCardJsonDecodeError() throws Exception {
-        TestHttpClient client = new TestHttpClient();
+    public void testGetWellKnownAgentCardJsonDecodeError() throws Exception {
+        TestHttpClient client = createTestClient();
         client.body = "X" + JsonMessages.AGENT_CARD;
 
-        A2ACardResolver resolver = new A2ACardResolver(client, "http://example.com/", "");
+        A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").build();
 
-        boolean success = false;
-        try {
-            AgentCard card = resolver.getAgentCard();
-            success = true;
-        } catch (A2AClientJSONError expected) {
-        }
-        assertFalse(success);
+        assertThrows(A2AClientJSONError.class, resolver::getWellKnownAgentCard);
     }
 
-
     @Test
-    public void testGetAgentCardRequestError() throws Exception {
-        TestHttpClient client = new TestHttpClient();
+    public void testGetWellKnownAgentCardRequestError() throws Exception {
+        TestHttpClient client = createTestClient();
         client.status = 503;
 
-        A2ACardResolver resolver = new A2ACardResolver(client, "http://example.com/", "");
+        A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com/").build();
 
-        String msg = null;
-        try {
-            AgentCard card = resolver.getAgentCard();
-        } catch (A2AClientError expected) {
-            msg = expected.getMessage();
-        }
-        assertTrue(msg.contains("503"));
+        A2AClientError error = assertThrows(A2AClientError.class, resolver::getWellKnownAgentCard);
+        assertTrue(error.getMessage().contains("503"));
+    }
+
+    @Test
+    public void testGetConfiguredAgentCard_fullUrlAlreadyContainingAgentCardPath() throws Exception {
+        String fullUrl = "https://agentbin.greensmoke-1163cb63.eastus.azurecontainerapps.io/spec03/.well-known/agent-card.json";
+        TestHttpClient client = createTestClient();
+
+        A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl(fullUrl).build();
+        resolver.getConfiguredAgentCard();
+        assertEquals(fullUrl, client.url);
+
+        resolver.getWellKnownAgentCard();
+        assertEquals(fullUrl, client.url);
+    }
+
+    @Test
+    public void testGetWellKnownAgentCard_withTenant() throws Exception {
+        TestHttpClient client = createTestClient();
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("my-tenant").build().getWellKnownAgentCard();
+
+        assertEquals("http://example.com/my-tenant" + AGENT_CARD_PATH, client.url);
+    }
+
+    @Test
+    public void testGetConfiguredAgentCard_withCustomAgentCardPath() throws Exception {
+        TestHttpClient client = createTestClient();
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").agentCardPath("/custom/agent.json").build().getConfiguredAgentCard();
+        assertEquals("http://example.com/custom/agent.json", client.url);
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").agentCardPath("custom/agent.json").build().getConfiguredAgentCard();
+        assertEquals("http://example.com/custom/agent.json", client.url);
+    }
+
+    @Test
+    public void testGetWellKnownAgentCard_withAuthHeaders() throws Exception {
+        TestHttpClient client = createTestClient();
+        Map authHeaders = Map.of("Authorization", "Bearer token123");
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").authHeaders(authHeaders).build().getWellKnownAgentCard();
+
+        assertEquals("Bearer token123", client.capturedHeaders.get("Authorization"));
+    }
+
+    @Test
+    public void testGetWellKnownAgentCard_ioExceptionThrowsA2AClientError() throws Exception {
+        TestHttpClient client = createTestClient();
+        client.throwIOException = true;
+
+        A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build();
+
+        assertThrows(A2AClientError.class, resolver::getWellKnownAgentCard);
+    }
+
+    @Test
+    public void testGetWellKnownAgentCard_interruptedExceptionThrowsA2AClientError() throws Exception {
+        TestHttpClient client = createTestClient();
+        client.throwInterruptedException = true;
+
+        A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build();
+
+        assertThrows(A2AClientError.class, resolver::getWellKnownAgentCard);
+    }
+
+    @Test
+    public void testBuilder_nullBaseUrl_throws() {
+        assertThrows(IllegalArgumentException.class, () -> A2ACardResolver.builder().build());
+    }
+
+    @Test
+    public void testSpec03PathPreservation() throws Exception {
+        TestHttpClient client = createTestClient();
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").build().getWellKnownAgentCard();
+        assertEquals("https://example.com/spec03" + AGENT_CARD_PATH, client.url);
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").tenant("my-tenant").build().getWellKnownAgentCard();
+        assertEquals("https://example.com/spec03/my-tenant" + AGENT_CARD_PATH, client.url);
+
+        A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").agentCardPath("/custom/card.json").build().getConfiguredAgentCard();
+        assertEquals("https://example.com/spec03/custom/card.json", client.url);
+    }
+
+    @Test
+    public void testBuilder_withAuthHeader() throws Exception {
+        TestHttpClient client = createTestClient();
+
+        A2ACardResolver.builder()
+                .httpClient(client)
+                .baseUrl("http://example.com")
+                .authHeader("Authorization", "Bearer token123")
+                .build()
+                .getWellKnownAgentCard();
+
+        assertEquals("Bearer token123", client.capturedHeaders.get("Authorization"));
     }
 
     private static class TestHttpClient implements A2AHttpClient {
         int status = 200;
         String body;
         String url;
+        boolean throwIOException = false;
+        boolean throwInterruptedException = false;
+        Map capturedHeaders = new HashMap<>();
 
         @Override
         public GetBuilder createGet() {
@@ -147,6 +230,12 @@ class TestGetBuilder implements A2AHttpClient.GetBuilder {
 
             @Override
             public A2AHttpResponse get() throws IOException, InterruptedException {
+                if (throwIOException) {
+                    throw new IOException("Simulated IO error");
+                }
+                if (throwInterruptedException) {
+                    throw new InterruptedException("Simulated interrupt");
+                }
                 return new A2AHttpResponse() {
                     @Override
                     public int status() {
@@ -166,7 +255,10 @@ public String body() {
             }
 
             @Override
-            public CompletableFuture getAsyncSSE(Consumer messageConsumer, Consumer errorConsumer, Runnable completeRunnable) throws IOException, InterruptedException {
+            public CompletableFuture getAsyncSSE(
+                    Consumer messageConsumer,
+                    Consumer errorConsumer,
+                    Runnable completeRunnable) throws IOException, InterruptedException {
                 return null;
             }
 
@@ -178,14 +270,15 @@ public GetBuilder url(String s) {
 
             @Override
             public GetBuilder addHeader(String name, String value) {
+                capturedHeaders.put(name, value);
                 return this;
             }
 
             @Override
             public GetBuilder addHeaders(Map headers) {
+                capturedHeaders.putAll(headers);
                 return this;
             }
         }
     }
-
 }