Skip to content

Commit 773c8ed

Browse files
kabirclaude
andcommitted
feat: Add HTTP caching headers to Agent Card endpoint
Implements A2A specification section 8.6 caching requirements: - Cache-Control header with configurable max-age (CARD-CACHE-001) - ETag header derived from Agent Card content hash (CARD-CACHE-002) - Last-Modified header with initialization timestamp (CARD-CACHE-003) **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 03e4458 commit 773c8ed

7 files changed

Lines changed: 319 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: 21 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,19 @@ 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+
rc.response()
368+
.putHeader("Cache-Control", cacheMetadata.getCacheControl())
369+
.putHeader("ETag", cacheMetadata.getETag())
370+
.putHeader("Last-Modified", cacheMetadata.getLastModified());
371+
352372
return JsonUtil.toJson(jsonRpcHandler.getAgentCard());
353373
}
354374

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: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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.HexFormat;
10+
11+
import jakarta.annotation.PostConstruct;
12+
import jakarta.enterprise.context.ApplicationScoped;
13+
import jakarta.enterprise.inject.Instance;
14+
import jakarta.inject.Inject;
15+
16+
import org.jspecify.annotations.Nullable;
17+
18+
import io.a2a.jsonrpc.common.json.JsonProcessingException;
19+
import io.a2a.jsonrpc.common.json.JsonUtil;
20+
import io.a2a.server.config.A2AConfigProvider;
21+
import io.a2a.spec.AgentCard;
22+
23+
/**
24+
* Provides HTTP caching metadata for Agent Card responses.
25+
*
26+
* <p>This bean computes and caches HTTP caching headers (Cache-Control, ETag, Last-Modified)
27+
* for the Agent Card endpoint as specified in the A2A protocol specification section 8.6.
28+
*
29+
* <p>The metadata is computed once at initialization:
30+
* <ul>
31+
* <li><b>Cache-Control:</b> Configured via {@code a2a.agent-card.cache.max-age} (default: 3600 seconds)</li>
32+
* <li><b>ETag:</b> MD5 hash of the serialized Agent Card JSON</li>
33+
* <li><b>Last-Modified:</b> Timestamp when the bean was initialized (RFC 1123 format)</li>
34+
* </ul>
35+
*
36+
* <p>Since the Agent Card is {@code @ApplicationScoped}, these values remain stable
37+
* throughout the application lifecycle unless the application is restarted.
38+
*
39+
* @see <a href="https://github.com/a2aproject/A2A/blob/main/docs/specification.md#86-caching">A2A Specification - Agent Card Caching</a>
40+
*/
41+
@ApplicationScoped
42+
public class AgentCardCacheMetadata {
43+
44+
private static final String CONFIG_KEY_MAX_AGE = "a2a.agent-card.cache.max-age";
45+
private static final String DEFAULT_MAX_AGE = "3600"; // 1 hour
46+
private static final DateTimeFormatter RFC_1123_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME;
47+
48+
@Inject
49+
@PublicAgentCard
50+
Instance<AgentCard> agentCardInstance;
51+
52+
@Inject
53+
Instance<A2AConfigProvider> configInstance;
54+
55+
private @Nullable AgentCard agentCard;
56+
private @Nullable A2AConfigProvider config;
57+
58+
@SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available
59+
private String etag;
60+
@SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available
61+
private String lastModified;
62+
@SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available
63+
private String cacheControl;
64+
65+
/**
66+
* Package-private no-arg constructor for CDI.
67+
*/
68+
AgentCardCacheMetadata() {
69+
// For CDI
70+
}
71+
72+
/**
73+
* Public constructor for testing purposes.
74+
*
75+
* @param agentCard the agent card
76+
* @param config the configuration provider
77+
*/
78+
public AgentCardCacheMetadata(AgentCard agentCard, A2AConfigProvider config) {
79+
this.agentCard = agentCard;
80+
this.config = config;
81+
init();
82+
}
83+
84+
@PostConstruct
85+
@SuppressWarnings("NullAway") // agentCard and config are guaranteed non-null after isUnsatisfied check
86+
void init() {
87+
// Only initialize if AgentCard bean is available
88+
if (agentCardInstance.isUnsatisfied() || configInstance.isUnsatisfied()) {
89+
return;
90+
}
91+
92+
this.agentCard = agentCardInstance.get();
93+
this.config = configInstance.get();
94+
95+
// Calculate ETag from the serialized JSON representation
96+
this.etag = calculateETag(agentCard);
97+
98+
// Set Last-Modified to the initialization time
99+
this.lastModified = RFC_1123_FORMATTER.format(Instant.now().atZone(ZoneOffset.UTC));
100+
101+
// Configure Cache-Control with max-age directive
102+
String maxAge = config.getOptionalValue(CONFIG_KEY_MAX_AGE).orElse(DEFAULT_MAX_AGE);
103+
this.cacheControl = "public, max-age=" + maxAge;
104+
}
105+
106+
/**
107+
* Returns the ETag header value for the Agent Card.
108+
*
109+
* <p>The ETag is an MD5 hash of the serialized Agent Card JSON, quoted per HTTP specification.
110+
*
111+
* @return the ETag header value (e.g., {@code "a1b2c3d4..."})
112+
*/
113+
public String getETag() {
114+
return etag;
115+
}
116+
117+
/**
118+
* Returns the Last-Modified header value for the Agent Card.
119+
*
120+
* <p>The timestamp represents when the bean was initialized, in RFC 1123 format.
121+
*
122+
* @return the Last-Modified header value (e.g., {@code "Mon, 17 Mar 2025 10:00:00 GMT"})
123+
*/
124+
public String getLastModified() {
125+
return lastModified;
126+
}
127+
128+
/**
129+
* Returns the Cache-Control header value for the Agent Card.
130+
*
131+
* <p>The value includes {@code public} and a {@code max-age} directive configured
132+
* via {@code a2a.agent-card.cache.max-age} (default: 3600 seconds).
133+
*
134+
* @return the Cache-Control header value (e.g., {@code "public, max-age=3600"})
135+
*/
136+
public String getCacheControl() {
137+
return cacheControl;
138+
}
139+
140+
/**
141+
* Calculates an MD5 hash of the Agent Card JSON for use as an ETag.
142+
*
143+
* @param card the agent card to hash
144+
* @return the hex-encoded MD5 hash, quoted per HTTP specification
145+
*/
146+
private String calculateETag(AgentCard card) {
147+
try {
148+
String json = JsonUtil.toJson(card);
149+
MessageDigest md = MessageDigest.getInstance("MD5");
150+
byte[] hash = md.digest(json.getBytes(StandardCharsets.UTF_8));
151+
return "\"" + HexFormat.of().formatHex(hash) + "\"";
152+
} catch (NoSuchAlgorithmException e) {
153+
throw new IllegalStateException("MD5 algorithm not available", e);
154+
} catch (JsonProcessingException e) {
155+
throw new IllegalStateException("Failed to serialize Agent Card for ETag calculation", e);
156+
}
157+
}
158+
}

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)