Skip to content

Commit 100bf86

Browse files
kabirclaude
andauthored
feat: Add HTTP caching headers to Agent Card endpoint (#761)
Implements A2A specification section 8.6 caching requirements: - Cache-Control header with configurable max-age - ETag header derived from Agent Card content hash - Last-Modified header with initialization timestamp **Implementation:** - Created AgentCardCacheMetadata bean in server-common to compute and cache HTTP headers at initialization - Enhanced HTTPRestResponse to support additional headers via Map - Updated RestHandler and JSON-RPC A2AServerRoutes to include caching headers - Caching headers applied to both REST and JSON-RPC transports (gRPC out of scope per spec) **Configuration:** - Max-age configurable via `a2a.agent-card.cache.max-age` (default: 3600 seconds) - ETag calculated as MD5 hash of serialized Agent Card JSON - Last-Modified set to bean initialization time in RFC 1123 format **Testing:** - Added testAgentCardHeaders() to AbstractA2AServerTest - Validates all three caching headers are present and correctly formatted - gRPC test overrides to skip (HTTP-only requirement) Fixes #749 🦕 Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d1578d3 commit 100bf86

7 files changed

Lines changed: 343 additions & 44 deletions

File tree

reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,10 @@ public static void closeChannel() {
5050
Thread.currentThread().interrupt();
5151
}
5252
}
53+
54+
@Override
55+
public void testAgentCardHeaders() {
56+
// Skip - gRPC doesn't use HTTP caching headers for Agent Card
57+
// The A2A spec section 8.6 caching requirements apply only to HTTP endpoints
58+
}
5359
}

reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ public class A2AServerRoutes {
173173
@Inject
174174
JSONRPCHandler jsonRpcHandler;
175175

176+
@Inject
177+
io.a2a.server.AgentCardCacheMetadata cacheMetadata;
178+
176179
// Hook so testing can wait until the MultiSseSupport is subscribed.
177180
// Without this we get intermittent failures
178181
private static volatile Runnable streamingMultiSseSupportSubscribedRunnable;
@@ -322,6 +325,13 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
322325
* <p>Returns the agent's capabilities and metadata in JSON format according to the
323326
* A2A protocol specification. This endpoint is publicly accessible (no authentication).
324327
*
328+
* <p>Includes HTTP caching headers per A2A specification section 8.6:
329+
* <ul>
330+
* <li>{@code Cache-Control} - with max-age directive</li>
331+
* <li>{@code ETag} - content hash for validation</li>
332+
* <li>{@code Last-Modified} - timestamp when agent card was initialized</li>
333+
* </ul>
334+
*
325335
* <p><b>Request:</b>
326336
* <pre>{@code
327337
* GET /.well-known/agent-card.json
@@ -331,6 +341,9 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
331341
* <pre>{@code
332342
* HTTP/1.1 200 OK
333343
* Content-Type: application/json
344+
* Cache-Control: public, max-age=3600
345+
* ETag: "a1b2c3d4..."
346+
* Last-Modified: Mon, 17 Mar 2025 10:00:00 GMT
334347
*
335348
* {
336349
* "name": "My Agent",
@@ -343,12 +356,15 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
343356
* }
344357
* }</pre>
345358
*
359+
* @param rc the Vert.x routing context
346360
* @return the agent card as a JSON string
347361
* @throws JsonProcessingException if serialization fails
348362
* @see JSONRPCHandler#getAgentCard()
349363
*/
350364
@Route(path = "/.well-known/agent-card.json", methods = Route.HttpMethod.GET, produces = APPLICATION_JSON)
351-
public String getAgentCard() throws JsonProcessingException {
365+
public String getAgentCard(RoutingContext rc) throws JsonProcessingException {
366+
// Add caching headers per A2A specification section 8.6
367+
cacheMetadata.getHttpHeadersMap().forEach((k, v) -> rc.response().putHeader(k, v));
352368
return JsonUtil.toJson(jsonRpcHandler.getAgentCard());
353369
}
354370

reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,10 +380,14 @@ public void cancelTask(@Body String body, RoutingContext rc) {
380380
*/
381381
private void sendResponse(RoutingContext rc, @Nullable HTTPRestResponse response) {
382382
if (response != null) {
383-
rc.response()
383+
var httpResponse = rc.response()
384384
.setStatusCode(response.getStatusCode())
385-
.putHeader(CONTENT_TYPE, response.getContentType())
386-
.end(response.getBody());
385+
.putHeader(CONTENT_TYPE, response.getContentType());
386+
387+
// Add any additional headers from the response
388+
response.getHeaders().forEach(httpResponse::putHeader);
389+
390+
httpResponse.end(response.getBody());
387391
} else {
388392
rc.response().end();
389393
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package io.a2a.server;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.security.MessageDigest;
5+
import java.security.NoSuchAlgorithmException;
6+
import java.time.Instant;
7+
import java.time.ZoneOffset;
8+
import java.time.format.DateTimeFormatter;
9+
import java.util.HashMap;
10+
import java.util.HexFormat;
11+
import java.util.Map;
12+
import java.util.function.Consumer;
13+
14+
import jakarta.annotation.PostConstruct;
15+
import jakarta.enterprise.context.ApplicationScoped;
16+
import jakarta.enterprise.inject.Instance;
17+
import jakarta.inject.Inject;
18+
19+
import org.jspecify.annotations.Nullable;
20+
21+
import io.a2a.jsonrpc.common.json.JsonProcessingException;
22+
import io.a2a.jsonrpc.common.json.JsonUtil;
23+
import io.a2a.server.config.A2AConfigProvider;
24+
import io.a2a.spec.AgentCard;
25+
26+
/**
27+
* Provides HTTP caching metadata for Agent Card responses.
28+
*
29+
* <p>This bean computes and caches HTTP caching headers (Cache-Control, ETag, Last-Modified)
30+
* for the Agent Card endpoint as specified in the A2A protocol specification section 8.6.
31+
*
32+
* <p>The metadata is computed once at initialization:
33+
* <ul>
34+
* <li><b>Cache-Control:</b> Configured via {@code a2a.agent-card.cache.max-age} (default: 3600 seconds)</li>
35+
* <li><b>ETag:</b> MD5 hash of the serialized Agent Card JSON</li>
36+
* <li><b>Last-Modified:</b> Timestamp when the bean was initialized (RFC 1123 format)</li>
37+
* </ul>
38+
*
39+
* <p>Since the Agent Card is {@code @ApplicationScoped}, these values remain stable
40+
* throughout the application lifecycle unless the application is restarted.
41+
*
42+
* @see <a href="https://github.com/a2aproject/A2A/blob/main/docs/specification.md#86-caching">A2A Specification - Agent Card Caching</a>
43+
*/
44+
@ApplicationScoped
45+
public class AgentCardCacheMetadata {
46+
47+
private static final String CONFIG_KEY_MAX_AGE = "a2a.agent-card.cache.max-age";
48+
private static final String DEFAULT_MAX_AGE = "3600"; // 1 hour
49+
private static final DateTimeFormatter RFC_1123_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME;
50+
51+
@Inject
52+
@PublicAgentCard
53+
Instance<AgentCard> agentCardInstance;
54+
55+
@Inject
56+
Instance<A2AConfigProvider> configInstance;
57+
58+
private @Nullable AgentCard agentCard;
59+
private @Nullable A2AConfigProvider config;
60+
61+
@SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available
62+
private String etag;
63+
@SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available
64+
private String lastModified;
65+
@SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available
66+
private String cacheControl;
67+
68+
/**
69+
* Package-private no-arg constructor for CDI.
70+
*/
71+
AgentCardCacheMetadata() {
72+
// For CDI
73+
}
74+
75+
/**
76+
* Public constructor for testing purposes.
77+
*
78+
* @param agentCard the agent card
79+
* @param config the configuration provider
80+
*/
81+
public AgentCardCacheMetadata(AgentCard agentCard, A2AConfigProvider config) {
82+
this.agentCard = agentCard;
83+
this.config = config;
84+
init();
85+
}
86+
87+
@PostConstruct
88+
@SuppressWarnings("NullAway") // agentCard and config are guaranteed non-null in both paths
89+
void init() {
90+
// Handle two initialization paths:
91+
// 1. CDI injection: get beans from Instance if available
92+
// 2. Direct constructor: agentCard and config already set
93+
94+
if (agentCard == null && agentCardInstance != null) {
95+
// CDI path - only initialize if AgentCard bean is available
96+
if (agentCardInstance.isUnsatisfied() || configInstance.isUnsatisfied()) {
97+
return;
98+
}
99+
this.agentCard = agentCardInstance.get();
100+
this.config = configInstance.get();
101+
}
102+
103+
// At this point, agentCard and config should be set (either via CDI or constructor)
104+
if (agentCard == null || config == null) {
105+
return;
106+
}
107+
108+
// Calculate ETag from the serialized JSON representation
109+
this.etag = calculateETag(agentCard);
110+
111+
// Set Last-Modified to the initialization time
112+
this.lastModified = RFC_1123_FORMATTER.format(Instant.now().atZone(ZoneOffset.UTC));
113+
114+
// Configure Cache-Control with max-age directive
115+
String maxAge = config.getOptionalValue(CONFIG_KEY_MAX_AGE).orElse(DEFAULT_MAX_AGE);
116+
this.cacheControl = "public, max-age=" + maxAge;
117+
}
118+
119+
/**
120+
* Returns the ETag header value for the Agent Card.
121+
*
122+
* <p>The ETag is an MD5 hash of the serialized Agent Card JSON, quoted per HTTP specification.
123+
*
124+
* @return the ETag header value (e.g., {@code "a1b2c3d4..."})
125+
*/
126+
public String getETag() {
127+
return etag;
128+
}
129+
130+
/**
131+
* Returns the Last-Modified header value for the Agent Card.
132+
*
133+
* <p>The timestamp represents when the bean was initialized, in RFC 1123 format.
134+
*
135+
* @return the Last-Modified header value (e.g., {@code "Mon, 17 Mar 2025 10:00:00 GMT"})
136+
*/
137+
public String getLastModified() {
138+
return lastModified;
139+
}
140+
141+
/**
142+
* Returns the Cache-Control header value for the Agent Card.
143+
*
144+
* <p>The value includes {@code public} and a {@code max-age} directive configured
145+
* via {@code a2a.agent-card.cache.max-age} (default: 3600 seconds).
146+
*
147+
* @return the Cache-Control header value (e.g., {@code "public, max-age=3600"})
148+
*/
149+
public String getCacheControl() {
150+
return cacheControl;
151+
}
152+
153+
/**
154+
* Calculates an MD5 hash of the Agent Card JSON for use as an ETag.
155+
*
156+
* @param card the agent card to hash
157+
* @return the hex-encoded MD5 hash, quoted per HTTP specification
158+
*/
159+
private String calculateETag(AgentCard card) {
160+
try {
161+
String json = JsonUtil.toJson(card);
162+
MessageDigest md = MessageDigest.getInstance("MD5");
163+
byte[] hash = md.digest(json.getBytes(StandardCharsets.UTF_8));
164+
return "\"" + HexFormat.of().formatHex(hash) + "\"";
165+
} catch (NoSuchAlgorithmException e) {
166+
throw new IllegalStateException("MD5 algorithm not available", e);
167+
} catch (JsonProcessingException e) {
168+
throw new IllegalStateException("Failed to serialize Agent Card for ETag calculation", e);
169+
}
170+
}
171+
172+
/**
173+
* Populates a map with header names and header values stored in this instance.
174+
*
175+
* @return a map of the headers
176+
*/
177+
public Map<String, String> getHttpHeadersMap() {
178+
Map<String, String> headers = new HashMap<>();
179+
if (cacheControl != null) {
180+
headers.put("Cache-Control", cacheControl);
181+
}
182+
if (lastModified != null) {
183+
headers.put("Last-Modified", lastModified);
184+
}
185+
if (etag != null) {
186+
headers.put("ETag", etag);
187+
}
188+
return headers;
189+
}
190+
}

tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,48 @@ public void testGetExtendedAgentCard() throws A2AClientException {
636636
assertTrue(agentCard.skills().isEmpty());
637637
}
638638

639+
/**
640+
* Tests that the Agent Card endpoint returns HTTP caching headers.
641+
*
642+
* <p>Per A2A specification section 8.6, Agent Card HTTP endpoints SHOULD include:
643+
* <ul>
644+
* <li>Cache-Control header with max-age directive (CARD-CACHE-001)</li>
645+
* <li>ETag header for conditional request support (CARD-CACHE-002)</li>
646+
* <li>Last-Modified header (CARD-CACHE-003, MAY requirement)</li>
647+
* </ul>
648+
*
649+
* @throws Exception if HTTP request fails
650+
*/
651+
@Test
652+
public void testAgentCardHeaders() throws Exception {
653+
HttpClient client = HttpClient.newHttpClient();
654+
HttpRequest request = HttpRequest.newBuilder()
655+
.uri(URI.create("http://localhost:" + serverPort + "/.well-known/agent-card.json"))
656+
.GET()
657+
.build();
658+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
659+
660+
assertEquals(200, response.statusCode());
661+
662+
// Verify Cache-Control header with max-age directive (CARD-CACHE-001)
663+
Optional<String> cacheControl = response.headers().firstValue("Cache-Control");
664+
assertTrue(cacheControl.isPresent(), "Cache-Control header should be present");
665+
assertTrue(cacheControl.get().contains("max-age"),
666+
"Cache-Control should contain max-age directive, got: " + cacheControl.get());
667+
668+
// Verify ETag header (CARD-CACHE-002)
669+
Optional<String> etag = response.headers().firstValue("ETag");
670+
assertTrue(etag.isPresent(), "ETag header should be present");
671+
assertTrue(etag.get().startsWith("\"") && etag.get().endsWith("\""),
672+
"ETag should be quoted per HTTP specification, got: " + etag.get());
673+
674+
// Verify Last-Modified header in RFC 1123 format (CARD-CACHE-003)
675+
Optional<String> lastModified = response.headers().firstValue("Last-Modified");
676+
assertTrue(lastModified.isPresent(), "Last-Modified header should be present");
677+
assertTrue(lastModified.get().contains("GMT"),
678+
"Last-Modified should be in RFC 1123 format (containing GMT), got: " + lastModified.get());
679+
}
680+
639681
@Test
640682
public void testSendMessageStreamNewMessageSuccess() throws Exception {
641683
testSendStreamingMessage(false);

0 commit comments

Comments
 (0)