Skip to content

Commit 35dedf7

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 35dedf7

7 files changed

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

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);

transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import io.a2a.jsonrpc.common.json.JsonProcessingException;
3030
import io.a2a.jsonrpc.common.json.JsonUtil;
3131
import io.a2a.jsonrpc.common.wrappers.ListTasksResult;
32+
import io.a2a.server.AgentCardCacheMetadata;
3233
import io.a2a.server.AgentCardValidator;
3334
import io.a2a.server.ExtendedAgentCard;
3435
import io.a2a.server.PublicAgentCard;
@@ -127,6 +128,7 @@ public class RestHandler {
127128
// final, is not proxyable in all runtimes
128129
private AgentCard agentCard;
129130
private @Nullable Instance<AgentCard> extendedAgentCard;
131+
private AgentCardCacheMetadata cacheMetadata;
130132
private RequestHandler requestHandler;
131133
private Executor executor;
132134

@@ -146,14 +148,16 @@ protected RestHandler() {
146148
*
147149
* @param agentCard the public agent card containing agent capabilities
148150
* @param extendedAgentCard optional extended agent card instance
151+
* @param cacheMetadata the agent card caching metadata
149152
* @param requestHandler the handler for processing A2A requests
150153
* @param executor the executor for asynchronous operations
151154
*/
152155
@Inject
153156
public RestHandler(@PublicAgentCard AgentCard agentCard, @ExtendedAgentCard Instance<AgentCard> extendedAgentCard,
154-
RequestHandler requestHandler, @Internal Executor executor) {
157+
AgentCardCacheMetadata cacheMetadata, RequestHandler requestHandler, @Internal Executor executor) {
155158
this.agentCard = agentCard;
156159
this.extendedAgentCard = extendedAgentCard;
160+
this.cacheMetadata = cacheMetadata;
157161
this.requestHandler = requestHandler;
158162
this.executor = executor;
159163

@@ -165,11 +169,14 @@ public RestHandler(@PublicAgentCard AgentCard agentCard, @ExtendedAgentCard Inst
165169
* Creates a REST handler with basic dependencies.
166170
*
167171
* @param agentCard the agent card containing agent capabilities
172+
* @param cacheMetadata the agent card caching metadata
168173
* @param requestHandler the handler for processing A2A requests
169174
* @param executor the executor for asynchronous operations
170175
*/
171-
public RestHandler(AgentCard agentCard, RequestHandler requestHandler, Executor executor) {
176+
public RestHandler(AgentCard agentCard, AgentCardCacheMetadata cacheMetadata,
177+
RequestHandler requestHandler, Executor executor) {
172178
this.agentCard = agentCard;
179+
this.cacheMetadata = cacheMetadata;
173180
this.requestHandler = requestHandler;
174181
this.executor = executor;
175182
}
@@ -929,7 +936,12 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String t
929936
*/
930937
public HTTPRestResponse getAgentCard() {
931938
try {
932-
return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(agentCard));
939+
Map<String, String> headers = Map.of(
940+
"Cache-Control", cacheMetadata.getCacheControl(),
941+
"ETag", cacheMetadata.getETag(),
942+
"Last-Modified", cacheMetadata.getLastModified()
943+
);
944+
return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(agentCard), headers);
933945
} catch (Throwable t) {
934946
return createErrorResponse(500, new InternalError(t.getMessage()));
935947
}
@@ -943,6 +955,7 @@ public static class HTTPRestResponse {
943955
private final int statusCode;
944956
private final String contentType;
945957
private final String body;
958+
private final Map<String, String> headers;
946959

947960
/**
948961
* Creates an HTTP REST response.
@@ -952,9 +965,22 @@ public static class HTTPRestResponse {
952965
* @param body the response body
953966
*/
954967
public HTTPRestResponse(int statusCode, String contentType, String body) {
968+
this(statusCode, contentType, body, Map.of());
969+
}
970+
971+
/**
972+
* Creates an HTTP REST response with custom headers.
973+
*
974+
* @param statusCode the HTTP status code
975+
* @param contentType the content type of the response
976+
* @param body the response body
977+
* @param headers additional HTTP headers
978+
*/
979+
public HTTPRestResponse(int statusCode, String contentType, String body, Map<String, String> headers) {
955980
this.statusCode = statusCode;
956981
this.contentType = contentType;
957982
this.body = body;
983+
this.headers = Map.copyOf(headers);
958984
}
959985

960986
/**
@@ -984,9 +1010,18 @@ public String getBody() {
9841010
return body;
9851011
}
9861012

1013+
/**
1014+
* Returns additional HTTP headers.
1015+
*
1016+
* @return the headers map
1017+
*/
1018+
public Map<String, String> getHeaders() {
1019+
return headers;
1020+
}
1021+
9871022
@Override
9881023
public String toString() {
989-
return "HTTPRestResponse{" + "statusCode=" + statusCode + ", contentType=" + contentType + ", body=" + body + '}';
1024+
return "HTTPRestResponse{" + "statusCode=" + statusCode + ", contentType=" + contentType + ", body=" + body + ", headers=" + headers + '}';
9901025
}
9911026
}
9921027

0 commit comments

Comments
 (0)