feat(a2a): implement A2A transport and server bridge (Track 3)#22
feat(a2a): implement A2A transport and server bridge (Track 3)#22MichielDean wants to merge 16 commits into
Conversation
|
I know it said to wait for a full 1.0.0 release, but I think this CR1 release is likely close enough for now and we likely won't have any major breaking changes and we can punt the full 1.0.0 upgrade to later. This will allow us to close track 3 for now. |
There was a problem hiding this comment.
Request changes — narrow block. The only thing keeping this from approval is a missing .changeset/*.md. Expert verdicts came back clean: security-reviewer no HIGH/MEDIUM with all five hardening claims verified line-by-line; ad-tech-protocol-expert sound-with-caveats; code-reviewer no Blockers. The implementation is well-built — 5 audit cycles show in the diff.
Why this blocks
.changeset/ has only README.md and config.json. This PR adds a new public transport surface on two published artifacts — adcp (org.adcontextprotocol.adcp.transport.a2a.A2aCaller, A2aConnectionManager) and adcp-server (A2aServlet, A2aServerBuilder, A2aAgentExecutor, A2aAuthProvider). Per .changeset/README.md and D18, that is the textbook adopter-visible change. changeset-check is green because npx changeset status succeeds on zero entries — the workflow is informational, the policy is not. Run npx changeset, pick adcp + adcp-server at minor, and ship the entry alongside.
Things I checked
- D10 alignment.
a2a-java-sdk:1.0.0.CR1ingradle/libs.versions.tomlis the explicit pinned version per D10 — not drift. Upgrade to GA is a straight version bump per the decision row. - D9 alignment. No drift off
mcp-core:1.1.2+mcp-json-jackson2:1.1.2. Nomcpbundle artifact slipped in. - D2 / D7 / D3. JDK 21 features used (no
*Asyncmirror),jakarta.servletthroughout,org.adcontextprotocol.adcp.{transport.a2a,server.a2a}sub-packages match the surface convention. *Request/*Responseinvariant.ad-tech-protocol-expertconfirmed no new public records violate the naming guard.- No
Optional<T>on the public surface. Confirmed by both reviewers. - SSRF posture (
A2aCaller,A2aConnectionManager).security-reviewerwalked the path:AdcpHttpClient.send→DnsPinResolver.resolveAndPin→Redirect.NEVER→ body cap;StrictSsrfPolicydenies loopback / link-local / site-local / multicast / IPv4-mapped-v6 / CGN / ULA / 6to4-private / Teredo-private / NAT64-private. AgentCard URL pinning atA2aConnectionManager.java:359-366overridesurl+supportedInterfacesfrom the validatedbaseUri— hostile card cannot redirect subsequent JSON-RPC POSTs. - Cache-key isolation (
A2aConnectionManager.java:77). HMAC-SHA256 with a per-process random key viaProtocolClient.java:252; raw secrets never enter the key. Protected headers (Authorization,Cookie,Proxy-Authorization) stripped from cache keys but forwarded to agent-card discovery.A2aConnectionManagerTest.java:110-116assertsAuthorizationabsent from the key — the right assertion shape. - SSE concurrency (
A2aServlet.java).writerLockguards every writer touch;completed.get()checked inside the lock at:362before any write;completeAsyncCAS-flipscompletedinside the same lock at:379. Write-after-complete is closed. - Input caps. Request body 1 MB at
A2aServlet.java:188enforced before parse; method/message-id/tool-name length caps + control-character strip throughout;adcp_versioncapped at 20; parts-/history-scan capped at 20. - Build.
./gradlew buildgreen; lockfile regen across all eight modules looks like an honest sweep, not drift. - Tests.
A2aServletTest.java(556 LoC, largest-file rule) covers body-cap, malformed JSON, missing method, unknown method — solid edge-case shape.@SuppressWarnings("deprecation")at:54is the symptom of the unauth-provider footgun called out below.
Follow-ups (non-blocking — file as issues)
-
A2aServerBuilder.ensureStarted()lifecycle (adcp-server/.../A2aServerBuilder.java:75).mainEventBusProcessoris started but never closed. Adopters who hot-reload or callbuild()more than once leak the event bus + queue manager + task store per call. Either return aCloseableserver wrapper, or move the processor's lifecycle to a parent component. -
extractCallContextHeadersprojects state into headers (adcp-server/.../A2aAgentExecutor.java:179).ServerCallContext.getState()carries non-HTTP-header data; flattening it intoAdcpContext.headers()(primitive-only filter or not) means tool handlers see internal context entries as if they came from the wire. Allowlist the keys, or namespace them, or surface them onAdcpContextas a distinct field. -
AgentCard well-known path (
adcp/.../A2aConnectionManager.java:356). Hand-rolled/.well-known/agent.json; current A2A spec is/.well-known/agent-card.json. The pinned SDK likely already has a discovery helper — delegate to it rather than hand-rolling the URL, and you stop paying this tax on every spec revision. -
Idempotency-Keynot first-class.A2aCaller.callToolforwards a genericheadersmap (A2aCaller.java:111);AdcpContext(AdcpContext.java:18-22) has noidempotencyKeyfield. PR description claims plumbing, but the canonical header name isn't enforced at either end. Either honor the IETF draft header name inA2aCaller+ surface it onAdcpContext, or drop the claim from the description. -
adcp_versionextracted from args, not metadata (A2aAgentExecutor.java:138-171). Tool-name discriminator lives inmetadata.adcp_tool_nameon the caller side; version lives in args on the server side. Cross-transport invariant check against the MCP path would be worth filing. Also themajor >= 3gate at:156-158is a magic number without a citation. -
Transport-error retry walk doesn't check
eitself (adcp/.../ProtocolClient.java:169).isTransportErrorwalksgetCause()but skipse.A2aCaller.java:122throws timeoutProtocolErrorwithcause == null, so the timeout path never retries — likely the opposite of intent. -
Hard-coded 30s
RESPONSE_TIMEOUT_SECONDS(A2aCaller.java:122). IgnoresCallToolOptions.timeout. MCP path honors it; A2A doesn't. v0.1 limitation but file it. -
Deprecated single-arg
A2aServletconstructor (A2aServlet.java:91-97). Wires an unauthenticated provider.@Deprecatedis the right marker, but it's also the copy-paste path fromA2aServerBuilderTest. Drop it before GA, or require an explicitA2aAuthProvider.unauthenticated()sentinel so the call-site reads as a deliberate opt-out. -
Unbounded
objectMapper.valueToTree(first)fallback (A2aCaller.java:235-238). Last-ditch path whenDataPartandTextPartscans both miss. Bounded upstream by the SSE chunk cap, so impact is small, but gate byMAX_CONTENT_LENGTHor throwProtocolErrorand stop encoding "unknown" into a tree.
Minor nits (non-blocking)
- JSONRPC literal string repeated in
A2aServerBuilder.java:96-102— pull the constant fromA2aConnectionManager.JSONRPC_TRANSPORTor sharedA2aConstants. A2aServlet.writeError(response, SC_OK, …)(A2aServlet.java:171) returns HTTP 200 with a JSON-RPC error body — correct per JSON-RPC 2.0, inconsistent with the 4xx paths above. Pick a convention or document the split.HttpAgentCardLoader.loadlog level (A2aConnectionManager.java:330). Non-2xx responses currentlydebug;warnwould give adopters a signal when an agent URL is wrong without spamming on transient network failures.
Add the changeset and the verdict flips to approve. Sensible v0.1 baseline; the audit-cycle claims hold up under scrutiny.
There was a problem hiding this comment.
Solid implementation of Track 3. SSRF closure via HttpAgentCardLoader.normalize pinning url + supportedInterfaces to the validated baseUri (A2aConnectionManager.java:359-392) is the right shape, and the SSE writerLock + AtomicBoolean completed CAS inside the lock (A2aServlet.java:377-383) is the correct exactly-once machinery. code-reviewer, security-reviewer, and ad-tech-protocol-expert all came back with no Critical/Blocker — Mediums and Minors only.
Things I checked
- D-decision audit. D2 (no
*Async, JDK 21) — clean. D7 (jakarta only) —A2aServletis onjakarta.servlet.*. D9 (mcp-core+mcp-json-jackson2 1.1.2) — unchanged, no jackson3 leak via the A2A bundle. D10 — modified in-PR (see Follow-up 1). D18 — Conventional Commits clean. - Lockfile diff. Seven
gradle.lockfiles touched. No Bouncy Castle (D2 honored), no jackson3 pull from the A2A transitive tree. - Largest-file rule. Read
A2aConnectionManager.java(414),A2aServlet.java(390),A2aCaller.java(313),A2aAgentExecutor.java(199 — borderline),A2aServletTest.java(556). Findings cited inline below. - Per-process random HMAC for cache hashing (
ProtocolClient.java:241-245) — preventsghp_*-style token reversal from heap dumps. Right call. - Storyboard CI still in progress at the time of review.
Follow-ups (non-blocking — file as issues)
-
D10 governance.
ROADMAP.md:22flips D10 from "keep types in-tree until ≥1.0.0" to "depend on 1.0.0.CR1 directly" in-PR. PerCONTRIBUTING.md, changing a confirmed decision starts with an issue. Technical rationale (Beta1→CR1 is bug-fix only, package layout stable) holds, but this should have an explicit governance trail rather than landing bundled with the implementation. Flagging for maintainer sign-off — not blocking, since the change is documented openly in the PR body and the row itself. -
specs/a2a-binding.mdis missing. AdCP-over-A2A wire shape is invented in code:adcp_tool_namemetadata key,TextPart(toolName)duplicated as first part, version envelope merged intoDataPartargs (not metadata),extractResponseprecedence DataPart→TextPart→tree-of-first-part, history-scan of up to 20 messages on terminal tasks. None of this is inspecs/. Without a binding doc, the TS / Python SDKs diverge silently.mcp-prototype-findings.mdset the precedent — A2A should match. -
Cross-tenant cache collision on shared OAuth
clientId.ProtocolClient.computeTokenHash()at L283-L286 keysoauthClientCredentialson"cc:" + clientIdonly; two tenants sharing the sameclientIdbut differentclientSecretcollide.basicAuth(username + ":" + password) collides at("a:b", "c") ≡ ("a", "b:c"). Include the secret in the HMAC input (delimiter-separated or HMAC-per-field). Edge-case for single-tenant adopters, real gap for shared-IdP multi-tenant. -
A2aAgentExecutor.extractCallContextHeadersmissingProtectedHeadersfilter (A2aAgentExecutor.java:173-188). Client side filters extraHeaders throughProtectedHeaders.isProtected()before sending; server side copiesServerCallContext.stateintoAdcpContext.headers()with no symmetric filter. If an adopter'sA2aAuthProviderstashesAuthorization/Cookiein state, those leak into tool implementations. -
A2aServletJSON-RPC body is parsed by Gson with no depth bound. 1 MB byte cap is enforced (readRequestBody, L183-L197) but Gson defaults to depth 1000 and is not configured byJsonUtil. A 1 MB crafted-nested payload OOM-multiplies into ~16 MB of wrapper allocations. Not code-exec — DoS multiplier. Either depth-limitJsonReaderor transcode throughAdcpObjectMapperFactory(which hasStreamReadConstraints) before re-handing to Gson. -
A2aConnectionManager.evictOldestholdscacheLockduringclient.close()(A2aConnectionManager.java:120-121, 200-209). A blocking socket teardown pins the global lock; concurrentgetOrConnect()across all 32 stripes queues behind it. Collect evictees locally, release the lock, then close. -
Test coverage gap on the SSE concurrency machinery.
A2aServletTest.AsyncStreamingRequestHandler(L529-L555) submits two events sequentially from one thread and closes. ThewriterLock+completedCAS guard exists to serializeonError/onTimeoutagainst in-flightonNext— none of which is exercised. The PR claims this race is closed; tests don't validate the claim. Worth at least one race-test before this code goes load-bearing. -
A2aCallersynchronous-delivery guard is dead (A2aCaller.java:128-131). If callbacks fired synchronously, the latch is already at 0, so thegetCount() > 0 && (latestMessage|task|failure != null)predicate is unreachable in the synchronous-delivery case. In the only scenario it could fire (async, callback path broken), force-counting-down masks the bug. Remove or convert to an assertion. -
extractResponsehistory-scan is too forgiving (A2aCaller.java:182-189). A task inTASK_STATE_COMPLETEDwith nostatus.messageis server-misbehavior; walking up to 20 history messages risks returning a stale prior-turn payload. Scope the scan to non-terminal states. -
AgentCard
skills=List.of()may fail A2A schema validation.A2aServerBuilder.buildAgentCard(L102) andHttpAgentCardLoader.fallbackCard(L410) both emit emptyskills. A2A 0.3+ requires at least one. Stubadcp.invokeuntil codegen surfaces skill metadata. -
A2aServerBuilder.build()wiresRunnable::runfor both executors (L84-85). Tool execution runs on the servlet container's request thread — fine if the adopter pairs this with a virtual-thread container per D2, worth a Javadoc note pointing atThread.ofVirtual().factory()for adopters who aren't. -
No changeset. New public packages (
transport.a2a,server.a2a) plus a newa2a-java-sdkcoordinate.changeset-checkCI passed because the workflow runschangeset statusinformationally — but per.changeset/README.md("when in doubt, add one"), a new transport surface isn't in doubt. Add aminorforadcp+adcp-server.
Minor nits (non-blocking)
-
Stripe selection uses signed mod.
A2aConnectionManager.java:90—(hashCode & 0x7FFFFFFF) % STRIPE_COUNTdistributes weakly across near-identical keys. PreferMath.floorMod(cacheKey.hashCode(), STRIPE_COUNT). -
A2aServletsets status / content-type afterstartAsync(A2aServlet.java:247-253). Move the header block abovestartAsyncso subscribers invoked synchronously insidepublisher.subscribe(...)always observe the right headers. -
Single-arg
A2aServlet(RequestHandler)is correctly deprecated and Javadoc-warned as test-only, but production safety rides entirely on adopters reading the doc. Worth an explicit opt-in (-Dadcp.a2a.allow-unauthenticated=true) so a misconfigured prod deploy fails fast instead of accepting everyone.
Interesting that a PR advertising "5 audit cycles + 3 code review cycles" lands with zero concurrent-write tests on the writerLock machinery it's claiming to harden — worth closing that loop before this gets load-bearing.
LGTM. Ship it once storyboard CI goes green. D10 governance and specs/a2a-binding.md are the two follow-ups worth catching the maintainer on; the rest can roll into Track-3 follow-up PRs.
There was a problem hiding this comment.
Approving. The Track 3 wire-up is the right shape: caller-side A2aConnectionManager with HMAC-keyed cache, server-side A2aServlet with writerLock-guarded SSE writes, and ProtocolClient evict-and-retry — and the D10 pin at 1.0.0.CR1 matches the amended ROADMAP row authorizing CR1.
Things I checked
- D10-aligned.
gradle/libs.versions.toml:24setsa2a-sdk = "1.0.0.CR1"; ROADMAP D10 (line 22) explicitly authorizes CR1 with 1.0.0 final as a straight version bump. No drift. - D9-aligned. Still on
mcp-core:1.1.2+mcp-json-jackson2:1.1.2. No jackson3 leak through the A2A artifacts. - D7-aligned.
jakarta.servlet.*everywhere; nojavaxsurface. - D2-aligned. No
*Asyncmirror methods, no Bouncy Castle. - SSE concurrency invariant.
A2aServlet#writerLockcovers bothresponse.getWriter().write(...)and thecompleted.compareAndSetincompleteAsync(A2aServlet.java:361-367,377-383). The write-after-complete race the description claims to close is in fact closed. - Cache-key isolation.
ProtocolClient#computeCacheHashHMACs token + sortedextraHeaderswith\0/=/\ndelimiters keyed by a per-processSecureRandomkey. Raw secrets never appear in the cache-key string. CR/LF inextraHeadersis rejected upstream atAgentConfig.validateExtraHeaders, so the\ndelimiter is unambiguous. - SSRF posture.
HttpAgentCardLoader#normalizeunconditionally overridescard.url()andsupportedInterfaceswith the validatedbaseUri(A2aConnectionManager.java:365-366), and the underlyingHttpClientusesfollowRedirects(NEVER). JSON-RPC traffic cannot be redirected to internal addresses via a hostile agent card. - Body cap.
A2aServlet#readRequestBodyenforces the 1 MB cap beforeout.write(A2aServlet.java:189-193); max overshoot bounded bybuffer.length. - Auth opt-in.
A2aServerBuilder.build()returns aRequestHandler, not a servlet — adopters must instantiateA2aServletthemselves, so the@Deprecatedno-arg unauthenticated constructor is opt-in.
Follow-ups (non-blocking — file as issues)
- AgentCard URL pinning over-pins.
A2aConnectionManager.java:365-366replacescard.url()andsupportedInterfaceswith the barebaseUri. That forces all JSON-RPC traffic to baseUri root and breaks any agent that legitimately advertises a path-suffixed endpoint (e.g.https://host/agents/seller/jsonrpc). The SSRF concern is real but the fix is broader than it needs to be — pin the origin to baseUri, allow the path the card declares. (ad-tech-protocol-expertflagged this as the highest-priority finding.) - Caller/server wire-shape asymmetry. Caller sends
metadata["adcp_tool_name"]+TextPart(toolName)+DataPart(args)(A2aCaller.java:152-162); server readsmetadata["adcp_args"]first (which the caller never writes), then falls back toTextPart+DataPart(A2aAgentExecutor.java:108-118). Pick one canonical shape and landspecs/a2a-binding.mdbefore GA — right now the Java SDK is unilaterally defining the wire convention. subRefrace vs. asynconSubscribe.A2aServlet.java:294-296assumesFlow.Publisher.subscribeinvokesonSubscribesynchronously. If a publisher dispatchesonSubscribeon its own executor,onTimeout/onErrorcan run first,cancelSubscription(subRef)returns null, and the later-arriving subscription leaks. Cancel insideonSubscribeifcompleted.get()is already true.Runnable::runinline executor.A2aServerBuilder.java:84-85wires both executors toRunnable::run, so any blocking work inAdcpPlatform.handleToolruns on the servlet request thread and stalls the SSE writer. At minimum add a javadoc warning; ideally accept anExecutorparameter.- No changeset. ~700 LoC of new public API (
A2aCaller,A2aConnectionManager,A2aServlet,A2aServerBuilder,A2aAuthProvider,A2aAgentExecutor) ships with no.changeset/*.md. CI doesn't fail (changeset-check.ymllacks--exit-code) and no artifact is published yet, but the v1.0 release notes will be the poorer for it. - Test coverage gap. 6 happy-path tests for the 390-LOC servlet (
A2aServletTest.java). No coverage for: writerLock contention,onTimeoutmid-stream, body atMAX_REQUEST_BYTES + 1, asynconSubscribe. Add at least one publisher-firing-onComplete-before-onSubscribe-returns case to lock in the race fix when Follow-up 3 lands. - Dual JSON-mapper exposure.
A2aServlet:104usescom.google.gson.JsonParserfor envelope parsing while the rest of the SDK is on Jackson.implementation(libs.gson)keeps it off the public surface, but introducing a second parser for one envelope is a notable choice. Replace with JacksonreadTreeor document why.
Minor nits (non-blocking)
- Dead synchronous-delivery guard.
A2aCaller.java:124-131— every state-setting branch (MessageEvent, terminal task,failure.compareAndSet) already callscountDown(). The extracountDown()is unreachable as a recovery path; the comment misrepresents what protection it provides. Drop or rewrite. - Stack-trace leak in error log.
A2aAgentExecutor.java:70logs the full exception chain; if Jackson surfaces a fragment of the attacker-controlled request, that lands in logs verbatim. Prefere.getClass().getName()+sanitizeErrorMessage(e.getMessage()). - SSE timeout not configurable.
A2aServlet.java:66SSE_STREAM_TIMEOUT_SECONDS = 300isprivate static final. Expose via constructor orinit-paramso adopters with long-running tools can tune. evict(URI)deadequals(prefix)branch.A2aConnectionManager.java:131-135—buildCacheKeyalways appends#cacheHash, so no key equalsagentUri.toString()alone. Harmless; trim or comment.- Discovery
Authorizationsilently dropped.AdcpHttpClientstrips protected headers from outbound discovery; adopters whose/.well-known/agent.jsonrequires auth will silently fall back to the synthetic card. Document the behavior.
Approving on the strength of the SSE concurrency invariant plus the unconditional AgentCard URL override closing the C-2 SSRF surface. The "5 audit cycles + 3 code review cycles" framing in the description sets a notable bar for what counts as a security-hardened PR around here.
There was a problem hiding this comment.
Request changes on the missing changeset. The code is sound; the block is a release-notes gap, not a defect.
Things I checked
- D10 update (
ROADMAP.md:22) is intentional governance — pin moves from "wait for 1.0 GA" to1.0.0.CR1with rationale (Beta1→CR1 delta is bug fixes only, in-tree fallback would have been throwaway). Aligned, not drift. - D9 still on
mcp-core:1.1.2+mcp-json-jackson2:1.1.2. No swap to themcpbundle artifact. - D7 jakarta-only:
A2aServletimportsjakarta.servlet.*,compileOnly(libs.jakarta.servlet.api). Confirmed. - D2 invariants: no
*Asyncmirrors, no Bouncy Castle, JDK 21 features in use. *Request/*Responsenaming invariant: holds across the new surface.@NullableoverOptional<T>: confirmed acrossA2aAgentExecutor,ProtocolClient.- SSRF posture (caller):
JdkA2AHttpClientis built on theAdcpHttpClientbuilder which setsfollowRedirects(NEVER).HttpAgentCardLoader.normalize(A2aConnectionManager.java:359-392) unconditionally pinsurlandsupportedInterfacestobaseUriregardless of what the remote card declares — closes the obvious bypass. - Cache-key cross-tenant isolation: HMAC-keyed via per-process random in
ProtocolClient.computeCacheHash(ProtocolClient.java:241-269).filterProtectedstrips Authorization/Cookie beforebuildCacheKey(A2aConnectionManager.java:77-78). No raw secret in the key. - SSE write-after-complete:
completeAsyncCAS is underwriterLock(A2aServlet.java:377-383);writeStreamingResponsere-checkscompleted.get()inside the same lock (line 361-364). Race is closed. - Caller-side version envelope is merged at
ProtocolClient.callTool:111viaVersionEnvelope.mergeInto(args, version)before dispatch —A2aCallerreceives merged args. Looked like a gap on first read; it isn't. code-reviewer: SOUND-WITH-CAVEATS, no Blockers.security-reviewer: SOUND-WITH-CAVEATS, no High findings.ad-tech-protocol-expert: SOUND-WITH-CAVEATS.
MUST FIX
- No changeset for an adopter-visible PR. Adds two new public packages (
org.adcontextprotocol.adcp.transport.a2a.*andorg.adcontextprotocol.adcp.server.a2a.*) across two of the eight published artifacts plus pulls in a new top-level dep (a2a-java-sdk:1.0.0.CR1). Per.changeset/README.mdandCONTRIBUTING.md, this is the canonical "adopter-visible behavior" case. Thechangeset-checkjob passes becausenpx changeset statusdoesn't exit non-zero on empty — that's a CI gap, not permission. Add aminorchangeset foradcp+adcp-serverdescribing the A2A transport addition, and call out the D10 pin so the v0.3 release notes carry it.
Follow-ups (non-blocking — file as issues)
- Caller-side
tasks/cancelfire-and-forget on buyer abort. ROADMAP §7.x explicitly requires it (ROADMAP.md:93).A2aCallercurrently only times out the latch (A2aCaller.java:133); no POST on interrupt/timeout. Track for v0.4. - DNS TOCTOU between
ProtocolClient.validateUrlprobe and JDKHttpClientre-resolve. Acknowledged in the comment atProtocolClient.java:217-221.DnsPinResolveralready exists; the A2A path doesn't yet flow through it. Decide before v0.4 whether to thread the pinned resolver throughJdkA2AHttpClientor accept the JDK's re-resolve. - Streaming SSE has no event-count or byte-budget cap.
A2aServlet.streamhonors the 300s timeout but does not bound total bytes written. A misbehavingAgentEmittercan stream unbounded data. A2aCaller.extractFromPartsfallback bypassesMAX_CONTENT_LENGTH.A2aCaller.java:246-256: when neitherDataPartnorTextPartmatches in the scan window, thevalueToTree/treeToValuepath runs without the size guard. Bounded by Jackson stream constraints but inconsistent with the rest of the file.A2aServlet.doPostcatch-all logs nothing. Line 172. The 500 envelope reaches the client; the operator gets no stack. Addlog.error("A2A request handling failed", e)on this branch and on the IO branch at 163-169.A2aConnectionManager.close()holdscacheLockduringcloseQuietlyfor every cached client. A slowClient.close()blocks every concurrentgetOrConnect. Snapshot values, clear+flag closed under the lock, close outside.A2aServerBuilderuses caller-thread executors and unbounded in-memory stores by default (Runnable::run, in-memory task/queue/notification stores). Javadoc warns about the stores but not the executors. Strengthen before v0.3.- CR1 → 1.0 final transition. D10 calls this "a straight version bump." Surface area is small but isolated to
A2aServlet.java:302/347/354; be ready for wrapper-class rename at GA.
Minor nits (non-blocking)
A2aConnectionManager.java:90—cacheKey.hashCode() & 0x7FFFFFFF) % STRIPE_COUNTis correct;Math.floorMod(cacheKey.hashCode(), STRIPE_COUNT)reads cleaner.A2aCaller.java:128-131— the synchronous-delivery latch guard is a no-op when the latch is already at 0; the comment admits it. Delete the dead branch.A2aConnectionManager.java:213-216—MAX_HEADERS=50silently truncates rather than rejects. A caller passing 60 routing headers gets the last 10 dropped and the connection still proceeds with potentially-wrong routing. Reject is the safer default.A2aServlet.java:90-97— deprecated single-arg constructor allocates a newUnauthenticatedUserServerCallContextlambda per request. Hoist or static-field it. The WARNING Javadoc reads stronger than the@Deprecatedannotation —since="0.1"would help.A2aCaller.java:220-221, 240-241— Jacksone.getMessage()is logged unsanitized at debug level. Source-input excerpts from a malicious agent can include CRLF; inconsistent with the rest of the file's sanitize-then-log discipline.
Five audit cycles plus three code-review cycles show in the diff — concurrency model is the right shape and the SSRF defenses are real. Add the changeset and this lands.
There was a problem hiding this comment.
Approving. D10 pin (a2a-sdk = "1.0.0.CR1" at gradle/libs.versions.toml:24) is exactly what the ROADMAP authorized, the SSRF posture is the right shape (redirect=NEVER + AgentCard URL/interface pinning to validated baseUri), and the public surface stays D2-compliant — no *Async mirrors, no Optional returns.
Things I checked
- D10 lineage —
libs.versions.toml:24and everya2a-java-sdk-*line inadcp/gradle.lockfile+adcp-server/gradle.lockfileresolve to1.0.0.CR1. Matches ROADMAP §Confirmed decisions D10 and §7.x line 231. - D2 / surface —
A2aCaller.callToolis synchronous-blocking;Flow.Publisheruse is internal to the SSE bridge inA2aServlet.stream. No async mirror methods leaked to the public API. *Request/*Responsenaming — locally defined types comply; theSend*Request/Send*Responseenvelopes come from upstreama2a-javaand are not ours to rename.- SSRF (caller) —
A2aConnectionManager.DefaultClientFactory:303-305wiresJdkA2AHttpClientover theAdcpHttpClient-builtHttpClient(redirect=NEVER);HttpAgentCardLoader.normalize:357-364pinsAgentCard.urlandsupportedInterfacesto the validatedbaseUriregardless of what the remote card declares. SSRF bypass through card-embedded URLs is closed. - HMAC cache isolation — per-process random key in
ProtocolClientHMACs the credential intocacheHash; raw secrets never enter the cache key string;filterProtectedkeepsAuthorization/Cookieout of the cache-key portion that includes discovery headers. - SSE write-after-complete —
writerLock+AtomicBoolean completedform a correct atomic gate inA2aServlet.writeStreamingResponse:363-369andcompleteAsync:379-385. No write can land on a completedAsyncContext. - Input bounds — 1 MB request body cap (
A2aServlet:188-194), tool-name/method/message-ID/version caps with control-char stripping applied before any log site inA2aCaller:80-91,A2aServlet:217-223,A2aConnectionManager.sanitizeForLog:265-269,A2aAgentExecutor.sanitizeErrorMessage. code-reviewer: sound-with-caveats — flagged the timeout leak below.security-reviewer: sound-with-caveats — no Critical/High; two Mediums on agent-card discovery posture (below).ad-tech-protocol-expert: sound-with-caveats — D10 aligned, A2A wire shape conformant, MCP↔A2A parity gaps to track (below).
Follow-ups (non-blocking — file as issues)
A2aServlet.onTimeoutdoes not explicitly complete theAsyncContext.A2aServlet.java:336-346— when timeout fires before the response is committed,writeTimeoutResponsesetscompleted=trueinsidewriterLock, thenonTimeoutcallscompleteAsync(line 273), whose CAS now fails andasyncContext.complete()is never invoked. Container teardown still tears it down on its own timeout, but explicit completion is the contract. Fix: have the not-committed-yet branch inwriteTimeoutResponsecallasyncContext.complete()itself, or split the gate socompleteAsynccan complete even aftercompletedwas set by the committed-error path.A2aServletTestdoes not exercise the load-bearing concurrency claims. The 556-line file covers happy-path streaming. There is no timeout test, noonErrortest, no concurrent-write test, nosubscription.canceltest — thewriterLock/ CAS / write-after-complete invariants the PR description leans on are untested. Adding even a basic timeout test would have caught (1).- Agent-card discovery is unauthenticated despite the PR description. PR body claims protected headers are "forwarded to agent-card discovery." They are not —
AdcpHttpClient.sendunconditionally stripsProtectedHeaders.NAMES, so any/.well-known/agent.jsonbehind auth always 401s and the loader silently returnsfallbackCard. URL pinning still keeps the JSON-RPC origin safe, but skill enumeration is degraded against auth-gated agents. Either update the PR description or add an SDK-managed-auth path for.well-knownGETs. - AgentCard body cap drops real cards.
A2aConnectionManager:336short-circuits onresponse.truncated(). The defaultAdcpHttpClientbody cap is small enough that production AgentCards with skills + capabilities + OAuth metadata trip it and fall back to synthetic — and the synthetic card always declaresJSONRPC+streaming(true). Add a per-callmaxResponseBytesfor.well-knownGETs (e.g., 256 KiB). - MCP↔A2A parity gap on
AdcpContext.requestId.AdcpServerBuilder.java:148passesrequestId=nullto MCP handlers;A2aAgentExecutor.execute:62forwards A2AmessageId. SamehandleToolwill see different context shapes across transports. Track 3 marked complete shouldn't ship without MCP being brought to parity here. - Error-envelope divergence MCP vs A2A. MCP returns
{error, message}inCallToolResult.isError=true; A2A path emits the same JSON inside aTextPartviaemitter.fail().A2aCaller.extractResponse:165-194only treatsTaskState.TASK_STATE_FAILED/CANCELEDas failure — structured{error, message}bodies get parsed back as the success type, losing thecodediscriminator on A2A. - Idempotency-key not wired on A2A.
A2aCaller.callToolaccepts aheadersmap but there is no first-class handling ofidempotency_key;A2aAgentExecutordoes not read it frommetadataorcallContext.state. ROADMAP §Tracks puts idempotency cache inadcp-serverscope — covering A2A here closes the L0 parity gap. - Missing
.changeset/*.md.feat(a2a)adds adopter-visible surface inadcpandadcp-server.changeset-checkis green (the workflow doesn't fail on absence), but.changeset/README.mdsays "Add a changeset whenever your PR changes adopter-visible behavior" — fix before v0.3 publish cuts.
Minor nits (non-blocking)
A2aServlet.java:172-175swallows non-A2AErrorexceptions with no log. Operators get a 500 with no root cause. Addlog.warn("A2A request failed: {}", e.getClass().getSimpleName(), e)in the catch.DefaultClientFactory.safeHttpClientis never closed.A2aConnectionManager.close()closes per-agentClientinstances but not the underlyingHttpClient. On JDK 21HttpClientisAutoCloseable. Close it inclose()or document external ownership.- OAuth client-credentials
cacheHashonly usesclientIdinProtocolClient.java:284-285. Not exploitable today (CC throwsFeatureUnsupportedError), but a foot-gun the moment CC is implemented. IncludeclientSecretin the HMAC input now. A2aAgentExecutor.extractCallContextHeaders:184logs the rawkeywithout the control-char stripping used everywhere else. Route throughsanitizeForLog.- Tool name carried in both
metadata.adcp_tool_nameANDparts[0]asTextPart. Robust but breaks A2A's convention thatTextPartis human/agent natural-language content. Canonical channel ismetadata; drop theTextPartor move intoDataPart.data. A2aCaller.java:128-132synchronous-client guard is dead code. The condition can't fire — every consumer path that sets a ref alsocountDowns the latch. Either delete or add a test that justifies it.- JSON-RPC
idNumber magnitude unbounded atA2aServlet:208-209. Gson returns aLazilyParsedNumberbacked by the raw string; a multi-megabyte1e1000000id literal would echo back through the response serializer. Clamp viaLong.parseLongwith fallback to null.
5 audit cycles + 3 code review cycles, and the load-bearing SSE concurrency claim still has no test for the path that breaks it. Worth a follow-up.
Approved on the strength of D10 alignment, the SSRF posture, and a clean public surface. Ship it once (1) and (2) land — those are the only items that touch correctness on a path the PR actually changed.
There was a problem hiding this comment.
Approving. The implementation is sound and the security posture holds; the gaps are scope-and-paperwork, not code.
Five audit cycles plus three review rounds show. SSE concurrency (A2aServlet.stream writerLock + AtomicBoolean CAS, adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aServlet.java:247-396) is exactly-once; normalize() pinning url + supportedInterfaces to the validated baseUri (adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java:357-390) closes the AgentCard SSRF bypass that JdkA2AHttpClient's default Redirect.NORMAL would otherwise leave open; HMAC'd cache key with per-process random key (ProtocolClient.java:241-269) keeps raw tokens out of cache strings; jakarta-only imports under adcp-server (D7-aligned).
Things I checked
- D-decisions: D2 (no
*Asyncmirrors), D7 (jakarta.servlet.*only,compileOnly(libs.jakarta.servlet.api)inadcp-server/build.gradle.kts:28), D9 (mcp-core/mcp-json-jackson2 still 1.1.2), D10 (a2a-sdk = \"1.0.0.CR1\"ingradle/libs.versions.toml:24). *Request/*Responsenaming invariant: A2A SDK request wrappers (SendMessageRequest,GetTaskRequest, etc.) come from upstream; nothing new on this PR's side breaks the rule. No.builder()added on a*Responserecord.- SSE write-after-complete race: every writer path gates on
completedinsidewriterLock;completeAsyncflipscompletedbeforeasyncContext.complete()(A2aServlet.java:390-396). Lock-freecompletedread at L310 is harmless — subsequentonNextre-enters the lock and short-circuits. - Auth isolation:
cacheHashis HMAC-SHA256 hex ([0-9a-f]) — no#/?collision risk inevict(URI, String)prefix matching (A2aConnectionManager.java:138-146).filterProtected()stripsAuthorization/Cookiefrom cache-key construction (L77, L229-238). - Body cap:
MAX_REQUEST_BYTES = 1 MBenforced as a streaming check inreadRequestBody()(A2aServlet.java:188-202) beforeJsonParser.parseStringruns. Gson 2.14.0 carries the default 255-deep nesting limit. - DNS TOCTOU on JSON-RPC POST: the
JdkA2AHttpClientbuilt fromnewHttpClientBuilder()(A2aConnectionManager.java:300-305) inherits MCP's accepted TOCTOU window — same posture, not a new vector.AdcpHttpClient.java:33-37already documents the limitation; the A2A path does not.
Follow-ups (non-blocking — file as issues)
- AdCP-over-A2A wire shape isn't pinned cross-SDK.
A2aCaller.buildRequestemits bothmetadata.adcp_tool_nameand a leadingTextPart(toolName)(A2aCaller.java:153-163); server reads metadata first, falls back to TextPart (A2aAgentExecutor.java:80-101). Defensible as "robust receivers," but I can't confirm from in-repo material that this matches what@adcp/sdk(TS) actually puts on the wire. Pin the shape inspecs/a2a-wire.mdper D16, with a citation to the TS source. Until that's done this is implementation-led, not spec-led. tasks/cancelon buyer abort is not wired. ROADMAP.md:93 lists this as a v0.1 transport-track requirement (fire-and-forget, 5s timeout, swallow rejection, threaded through outbound signing). Notasks/cancelPOST exists inA2aCallerorProtocolClienttoday; only the server-side inboundCancelTaskRequesthandler is here (A2aServlet.java:150-155). The PR description and the ROADMAP edit both mark Track 3 "complete" — that's a notable claim given a v0.1 gate item is open. Either land the caller-side cancel POST or update Track 3's completion claim and file a follow-up issue.- A2A SDK surface scope vs. "straight version bump" promise. This PR pulls in 60 references across
org.a2aproject.sdk.spec,jsonrpc.common.wrappers,server.requesthandlers,server.agentexecution,server.events,server.tasks,client.transport.jsonrpc,client.http,server.auth,server.util.sse. D10's CR1→1.0 "straight version bump" rests on all of those package paths holding stable through GA. The server-side event-bus surface is the riskiest area for late renames. Name an owner to re-validate againsta2aproject/a2a-javaCHANGELOG before v0.4 cut. - Missing changeset. The CI check passed (the README treats it as "informational for borderline cases"), but
.changeset/README.mdsays "adopter-visible behavior on any of the eight published artifacts" — this PR adds eight new public classes toadcpandadcp-server. When in doubt, add one. A2aServerBuilder.build()is not idempotent. CallsmainEventBusProcessor.ensureStarted()with no shutdown surface (A2aServerBuilder.java:64-86). Repeated builds leak processors. Document the single-shot contract or guard against re-build.A2aAgentExecutor.extractArgsswallows shape errors.objectMapper.convertValue(..., LinkedHashMap.class)throwsIllegalArgumentExceptionon a non-mapDataPartpayload (A2aAgentExecutor.java:109,116), surfaces as genericinternal_error. Catch specifically and emitInvalidRequestError-shaped failure so callers know it's a request-shape problem.
Minor nits (non-blocking)
- TOCTOU comment parity.
ProtocolClient.java:217-235documents the JDKHttpClientDNS-re-resolve limitation for MCP. The same limitation applies to the A2AJdkA2AHttpClientpath and isn't called out at the A2A site. Add a one-line comment nearDefaultClientFactory(A2aConnectionManager.java:289-319). - Dead branch in
evict(URI).key.equals(prefix) || key.startsWith(prefix + \"#\")(A2aConnectionManager.java:131-136) — every cached key includes#<hash>perbuildCacheKey, so theequalsbranch can never fire. Drop it or document why it's defensive. - Empty
skills[]on the agent card.A2aServerBuilder.buildAgentCard():102andnormalize()/fallbackCard()(A2aConnectionManager.java:386-388, 408) emit.skills(List.of()). Defensible default for a generic platform but worth a tracked follow-up — TS 7.0.0's "don't advertise missing tools" invariant maps onto A2A skills, and an empty list is not the same thing as "the wired set."
Safe to merge.
There was a problem hiding this comment.
Hold. The AgentCard.normalize() SSRF guard (C-2) is incomplete — it pins url + supportedInterfaces but leaves at least one other URL-bearing field on the rebuilt card to whatever the remote agent ships, which re-opens the exact SSRF surface the C-2 comment claims to close. Block is on the v0.1 SSRF baseline (DNS pin + address-guards + redirect: manual + AgentCard URL pinning); the rest of the PR is strong.
Must fix (blocking)
-
AgentCard
normalize()is not comprehensive — SSRF re-open via untrusted card fields (adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java:373-406).Client.builder(agentCard).clientConfig(setUseClientPreference(true))(L325-333) lets the A2A client consult fields on the card for transport selection.normalize()only overwritesurlandsupportedInterfaces.security-reviewerH1: confirm whetherAgentCardina2a-java 1.0.0.CR1carries anadditionalInterfaces(or equivalent) array that the JSON-RPC transport will consult withuseClientPreference(true)— if it does, a malicious card settingadditionalInterfaces: [{transport:"JSONRPC", url:"http://169.254.169.254/..."}]reaches the metadata endpoint past the DNS pin. Also explicitly null/emptyiconUrl,documentationUrl,provider,securitySchemeson the rebuilt card since the same untrusted body controls them. -
normalize()transport-downgrade inconsistency (same file, L390-391).supportedInterfacesis force-pinned to[JSONRPC]for SSRF, butpreferredTransportis only set when blank. A remote card advertisingpreferredTransport=GRPCplus a GRPC interface will keeppreferredTransport=GRPCwhilesupportedInterfacesis silently rewritten to[JSONRPC]—getInterface(preferredTransport)then mismatches. ForcepreferredTransport=JSONRPC(and apply the same tofallbackCardfor symmetry), or log a warning when stripping a non-JSONRPC preference.
Things I checked
- D10 update is consistent — ROADMAP row at
ROADMAP.md:22flips from in-tree fallback toa2a-java 1.0.0.CR1, matching the code atgradle/libs.versions.toml:24. The CR1→GA bump path is reasonable for JBoss-cadence releases. - D2/D7/D9/D16 unchanged. No
*Asyncmirrors, jakarta-only servlet imports (compileOnlyperadcp-server/build.gradle.kts:28), MCP still onmcp-core:1.1.2, no new ADRs. *Request/*Responseinvariant intact —A2aServerBuilderis a builder for a model class, not a response record.@Nullableused throughoutA2aServerBuilder,A2aAgentExecutor. NoOptional<T>returns on the public surface.- A2aServlet exactly-once protocol (
A2aServlet.java:393-399, 362-372, 374-384, 338-360): lock-on-writerLock+ CAS oncompletedis sound.onNextreadscompletedunder the same lock; no write-after-complete race. - A2aConnectionManager double-checked locking around
cacheLock+connectStripesis correct; no double-create for the same key.evict(URI)'sstartsWith(agentUri + "#")cannot match unrelated URIs since#is the unique terminator. extractVersion(args)is called atA2aAgentExecutor.java:53before theargs.remove(...)at L54-55 — ordering correct.- Cache-key collision surface (
ProtocolClient.java:267-272,A2aConnectionManager.java:178-206): HMAC-SHA256 with per-process key, header keys lowercased + URL-encoded + TreeMap-sorted. Sound for realistic header inputs. - Path-traversal guards in
SchemaBundle.javaandAdcpSchemaValidator.javacatch the relevant vectors (.., leading/). AdcpObjectMapperFactory.deactivateDefaultTyping()is the canonical Jackson gadget defense; belt-and-suspenders since the Jackson default is already off.- Largest-file rule: read
A2aServlet.java(406),A2aConnectionManager.java(428),A2aCaller.java(316),A2aAgentExecutor.java(205),A2aServletTest.java(sampled).
Follow-ups (non-blocking — file as issues)
adcp_tool_nameover A2A is not yet a documented AdCP cross-language convention.ad-tech-protocol-expertflagged that the constant lives only atA2aCaller.java:43, with nospecs/ordocs/doc defining the wire shape. Cross-check against@adcp/sdk(TS) A2A caller before v1.0 GA; if TS encodes differently this PR breaks interop. Move the convention intospecs/a2a-wire.mdper D16.A2aServerBuilder.buildAgentCard()declaresskills: [](A2aServerBuilder.java:102). Fine for v0.4 beta but populate fromAdcpPlatform.registeredTools()(specialism declaration) before v1.0 GA — that is exactly what A2AAgentSkillexists for.- Adopter-visible PR, no
.changeset/*.md.changeset-checkCI passes only because no Java module is registered as a changesets package (package.jsonlists onlyadcp-sdk-java-tools, which is on the ignore list). Per.changeset/README.mdthis PR is exactly the case that needs a minor bump onadcp+adcp-server. Wire the Java modules into changesets and add the entry — until that happens, thechangeset-checkworkflow has no teeth on the SDK itself. computeCacheHashseparator ambiguity (ProtocolClient.java:267-272). Header-name grammar in HTTP forbids=, butAgentConfig.extraHeaders()is an unvalidatedMap. Length-prefix or validate against thetokengrammar.extractCallContextHeadersnamespace (A2aAgentExecutor.java:179-194). CoercesServerCallContext.statetoStringand hands toAdcpContext.headersas if they were caller-controlled. Namespace-prefix (e.g.a2a_state:) or document the contract.A2aServletdeprecated single-arg constructor lacksforRemoval=true(A2aServlet.java:91). AddforRemoval=trueand consider fail-fast ininit()when no auth provider is wired.A2aServletTesthas no concurrent-write race test. Author can land that as a follow-up issue.
Minor nits (non-blocking)
- Sync-delivery guard in
A2aCaller.callTool(A2aCaller.java:130-133) is effectively a defensive no-op — every callback path already callscompletion.countDown()itself. The comment overstates its role; either drop it or recomment as "defensive no-op." - Retry-success swallows the original transport error silently (
ProtocolClient.java:147-165, 187-203). Addlog.debug("... recovered after retry, original: {}", original.getMessage())so operators can see flapping. A2aServletcatch-allException(A2aServlet.java:178-181) returns 500 with no log. Addlog.error("Unhandled A2A servlet error", e)so production bugs aren't invisible.
Once normalize() pins all untrusted URL-bearing fields and the preferredTransport inconsistency is closed, this is ready to ship.
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
There was a problem hiding this comment.
LGTM. Follow-ups noted below. The SSRF posture (agent-card URL pinning in A2aConnectionManager.normalize at adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java:373) and the SSE concurrency discipline in A2aServlet (writerLock + AtomicBoolean CAS across all four write paths) are the right shapes for this surface.
Things I checked
- D10-aligned.
gradle/libs.versions.toml:24pinsa2a-sdk = "1.0.0.CR1"exactly per the ROADMAP row; the CR1 → 1.0.0 final bump is on-roadmap, not a blocker here. - D2-aligned. No
*Asyncmirror methods on the public surface. No Bouncy Castle. JDK 21 baseline preserved. - D7-aligned.
adcp-serverusesjakarta.servlet.*at compile time,compileOnlyper the existing convention. *Request/*Responseinvariant — confirmed sound byad-tech-protocol-expert; no new*Responserecord has a.builder().@NullablevsOptional<T>— clean across the new surface (A2aServerBuilder,A2aAgentExecutor,A2aConnectionManager).- Credential cache isolation.
ProtocolClient.computeCacheHash(adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java:253) HMAC-SHA256s the token with a per-process random key;"anonymous"collides only with other token-less agents;filterProtectedcorrectly excludesAuthorizationfrom the cache key while leaving it in the per-call header map. - A2A SSE write-after-complete race —
A2aServlet.completeAsync(line 393) andwriteFinalStreamingResponse(line 362) both holdwriterLockduring the CAS-and-complete; no path can write to the response afterasyncContext.complete()returns. - Outbound auth precedence.
ProtocolClient.callToolline 100-108 filters callerextraHeadersthroughProtectedHeadersbefore adding SDK-resolved auth — callers cannot overrideAuthorizationon the wire. - Changeset. None in the diff. Pre-v0.3 per D6 and the
.changeset/README.mdexemptions;Check for changesetCI is green. - CI.
./gradlew build (JDK 21),Check for changeset,commitlint,check / check, GitGuardian all green at review time.
Follow-ups (non-blocking — file as issues)
-
A2aServerBuilderleaks theMainEventBusProcessoron rebuild. adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aServerBuilder.java:76 callsmainEventBusProcessor.ensureStarted(), but neither the builder nor the returnedDefaultRequestHandlerisAutoCloseable. Hot-reload, test cycles, and multi-tenant boot will accumulate background work with no documented shutdown path. Either return a holder that implementsclose()or document the adopter-side teardown sequence in the class Javadoc. -
Version-extraction divergence between A2A and MCP server paths.
A2aAgentExecutor.extractVersion(adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aAgentExecutor.java:144) returnsnullwhenadcp_major_versionis absent; the MCP equivalentAdcpServerBuilder.extractVersion(line 198 of that file) falls back to the server's configured default. Same protocol, two semantics — identical clients will see differentAdcpContext.version()depending on transport. The A2A executor isn't even constructor-injected with the server's default. Mirror the MCP fallback. -
A2aServerBuilder.buildAgentCardhardcodes.skills(List.of()). adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aServerBuilder.java:102. Empty skills means discovery returns no tools — theCreativeBuilderPlatform doesn't advertise missing toolsrule (ROADMAP.md §7.x, 7.0.0 line) is satisfied by accident. Plumb theAdcpPlatform's registered tool list intoAgentSkillentries so discovery actually works. -
A2aAgentExecutor.extractArgsreturns 500 on a non-mapadcp_args. Line 115 doesobjectMapper.convertValue(..., LinkedHashMap.class); if a peer sendsadcp_argsas an array/string the resultingIllegalArgumentExceptionfalls into the genericExceptioncatch and emitsinternal_error. Adopters see a 500 for what is really a 400. Add aninstanceof Mapprecheck and throwInvalidRequestError. -
A2aServletbody-capIOExceptionis silently mapped to 400 with no log breadcrumb. Line 169-175. Operators investigating rejected uploads get no SLF4J signal. Add a singlelog.debugbeforewriteErrorso the cap is observable. -
Unauthenticated single-arg
A2aServletconstructor is fenced only by Javadoc. Line 91. Once Spring Boot autoconfig lands and starts auto-wiring, a careless adopter could ship the deprecated convenience constructor to prod. Consider either (a) an explicitA2aAuthProvider.allowAnonymous()opt-in sentinel, or (b) a WARN at servlet init whenUnauthenticatedUseris wired against a non-loopback bind. -
A2aConnectionManager.normalizeoverwritessupportedInterfacesblanket. Line 373-406. SSRF reason for URL pinning is sound, but if an agent legitimately advertises GRPC alongside JSONRPC on the same origin, the SDK forces JSONRPC. Acceptable for v0.1 (JSONRPC-only); add a TODO so this gets revisited when GRPC lands. -
extractCallContextHeadersdoesn't validate header values. adcp-server/src/main/java/org/adcontextprotocol/adcp/server/a2a/A2aAgentExecutor.java:184-193. Adopters whoseA2aAuthProviderstuffs raw HTTP headers intoServerCallContext.statewill pass attacker-controlledX-Forwarded-*through to tool handlers without the CRLF / control-char filter thatA2aConnectionManager.sanitizeHeadersapplies. Reuse that filter on the way in.
Minor nits (non-blocking)
-
A2aCaller.java:127-129comment. The comment says "this countDown() is a no-op" but in thefailure.get() != nullwindow from a truly async error path the countDown is real (just harmless). Tighten the wording. -
A2aConnectionManager.java:192header-case dedup. The comment is correct ("alphabetically-last original key wins among case-insensitive duplicates") but counterintuitive — worth a unit test asserting theX-Tenantvsx-tenantcollision shape so the invariant doesn't silently regress. -
ProtocolClient.java:242-246per-process HMAC key. Worth a one-line Javadoc noting connection caches do not persist across JVM restarts so adopters don't expect warm-cache behavior on rolling restarts.
Five audit cycles plus three review rounds is visible — the credential cache hash, header normalization, and SSE concurrency are all sitting in places that take a while to reach without that scrutiny.
Approving.
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
There was a problem hiding this comment.
Track 3 lands. Clean shape: caller in adcp/transport/a2a, server bridge in adcp-server/a2a, D10 row updated in-PR to authorize the 1.0.0.CR1 pin.
Things I checked
- D-decisions hold. D2 (no
*Async, no Bouncy Castle), D3 (org.adcontextprotocol.adcp.transport.a2a/...server.a2a), D7 (jakarta.servlet.http.HttpServletRequest, nojavax), D9 (mcp-core:1.1.2still pinned; jackson2.18.2 → 2.20.1stays in the jackson2 line). D10 itself is updated in this PR —ROADMAP.mdprecedesCLAUDE.mdper CLAUDE.md's own precedence rule, and the new D10 row authorizes CR1 with the path to1.0.0final documented as a straight version bump. - SSE write-after-complete race. Every write site in
A2aServlet.java:338-399(writeStreamingResponse,writeFinalStreamingResponse,writeTimeoutResponse,completeAsync) routes throughwriterLockplus thecompletedAtomicBooleangate.security-reviewer: "No bypass exists." - SSRF posture.
AdcpHttpClient.newHttpClientBuilder()pinsfollowRedirects(NEVER)atAdcpHttpClient.java:200-204;DefaultClientFactorywraps that intoJdkA2AHttpClientatA2aConnectionManager.java:319-321;HttpAgentCardLoader.normalizerewritesurlandsupportedInterfacesto the validated baseUri regardless of what the remote card declares (A2aConnectionManager.java:373-380). - Cache isolation. HMAC-SHA256 with per-process random key in
ProtocolClient.computeTokenHash(ProtocolClient.java:242-298); protected headers stripped from the cache key viafilterProtectedbefore key construction. Raw tokens never enter cache-key strings. - Wire shape.
ad-tech-protocol-expert: AdCP-over-A2A conventions match@adcp/sdk(TS) andadcp(Python) —metadata.adcp_tool_name+DataPartpayload, version envelope merged into args fields,adcp_major_version/adcp_versionstripped before dispatch. *Request/*Responseinvariant. Only*Requestrecords carry.builder(). NoOptional<T>returns. SLF4J throughout. Jackson default typing deactivated in four mappers (AdcpObjectMapperFactory,SchemaBundle.MAPPER,A2aCallercopy,HttpAgentCardLoadercopy).- Largest-file reads.
A2aServlet.java(406),A2aConnectionManager.java(428),A2aCaller.java(316),A2aAgentExecutor.java(205),ProtocolClient.java. Read end-to-end. - CI green.
./gradlew build (JDK 21),Check for changeset,commitlint,storyboard,check / check,GitGuardianallSUCCESS.
Follow-ups (non-blocking — file as issues)
- Auth-side header forwarding to
/.well-known/agent.jsondoesn't actually happen.AdcpHttpClient.sendstrips all protected headers from outbound requests (AdcpHttpClient.java:128-134), so theAuthorizationminted byAuthTokenResolvernever reaches the agent-card discovery probe. The PR description claims protected headers "stripped from cache key but still forwarded to agent-card discovery"; the second half is not true on the wire. Either document the discovery probe as anonymous (so private agents whitelist/.well-known/agent.json) or add asendAuthorizedpath for SDK-minted tokens. (security-reviewerMedium 1.) - Deprecated single-arg
A2aServlet(handler)is too easy to ship to prod (A2aServlet.java:90-97).@Deprecatedon the Javadoc is invisible at deploy time. Either remove pre-GA and require an explicitA2aAuthProvider.alwaysUnauthenticated()factory (so the "no auth" choice is grep-able at the call site) or gate it on a-Dadcp.allow-unauthenticated=truesystem property. (security-reviewerMedium 2.) A2aAgentExecutor.extractArgsemitsinternal_errorfor non-objectadcp_args(A2aAgentExecutor.java:115,122).objectMapper.convertValue(42, LinkedHashMap.class)throwsIllegalArgumentExceptionand falls through to the genericExceptionhandler at line 69. Should beInvalidRequestError. (code-reviewerfinding 3.)AgentCard.normalize()silently rewrites multi-interface cards (A2aConnectionManager.java:373-405). Acceptable for v0.4 since AdCP-over-A2A is JSONRPC-only today, but log aWARNwhen the remote card declares interfaces beyond the pinned one — surfaces the divergence loudly when a multi-interface agent shows up. (ad-tech-protocol-expertcaveat 3.)- Server-side AgentCard ships empty
skills = [](A2aServerBuilder.java:103). A2A discovery clients (a2a-inspector etc.) treat skills as the primary capability advertisement; the idiomatic move is oneAgentSkillper AdCP tool, populated from the platform's tool registry. (ad-tech-protocol-expertcaveat 4.) tasks/resubscribenot surfaced (A2aServlet.java:128-165). Long-running AdCP tasks (HITLcreate_media_buy) need reconnect-after-disconnect; the five methods wired today cover the dispatch happy path but not the resume path. (ad-tech-protocol-expertcaveat 5.)- No
.changeset/*.mdfor an adopter-visible Track 3 milestone.changeset-checkpassed (informational per.changeset/README.md), and first publish lands at v0.3 alpha per D6, but the README asks for a changeset whenever a PR changes adopter-visible behavior on any of the eight artifacts.feat(a2a):is aminorbump onadcpandadcp-server. - Auth runs after body + JSON parse (
A2aServlet.java:103-126). MoveauthProvider.authenticate(request)beforereadRequestBody— cheap reorder, removes 1 MB of unauthenticated parsing work per request. (security-reviewerLow 1.) - Auth failures return HTTP 200 with JSON-RPC error. JSON-RPC convention, but breaks WAFs and rate-limiters that scope by HTTP status. Consider 401 for explicit
UnauthorizedfromA2aAuthProvider. (security-reviewerLow 3.)
Minor nits (non-blocking)
- Retry path discards the original transport exception on success.
ProtocolClient.java:152-165and:191-204.originalis captured but only attached viaaddSuppressedon retry-failure. On retry-success the first failure is silent — log it atdebugfor flaky-agent forensics. (code-reviewerfinding 1.) safeToolName.replaceAll(\"[\\\\p{Cc}]\", \"\")is dead defense atA2aCaller.java:91—toolNamewas already rejected for control characters at:87-89. Drop thereplaceAll, or comment it as belt-and-braces. (code-reviewerfinding 4.)
Three independent expert reads landed at sound-with-caveats. The PR-description-versus-code mismatch on agent-card auth forwarding is the one notable miss — the rest of the hardening prose holds up.
Approving on the strength of the D-coherent shape, the verified SSE concurrency gate, and the three expert verdicts.
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
bokelley
left a comment
There was a problem hiding this comment.
Thanks for the Track 3 A2A implementation. I did a full pass over the caller/server bridge, tests, and the upstream A2A Java SDK CR1 behavior.
I think this needs another revision before merge.
Findings:
-
High: Agent Card discovery uses the wrong well-known path.
A2aConnectionManager.javabuilds/.well-known/agent.json, but the A2A Java SDK resolver and the spec default to/.well-known/agent-card.json. A conforming A2A server that only publishes the standard path will be missed and the caller will fall back to the synthetic card. That bypasses the server-declared RPC URL, skills, security schemes, and any tenant-specific interfaces. Please switch discovery to the standard Agent Card path, ideally by using the SDK resolver/default constant instead of hard-coding a local path. -
High: authenticated Agent Card discovery drops auth headers.
A2aConnectionManager.javapasses headers intoadcpHttpClient.get(...), butAdcpHttpClientstrips protected headers, includingAuthorization, on GET. That means private Agent Card endpoints using Basic/OAuth/Bearer will 401 during discovery and the caller will again fall back to a synthetic card. The card fetch path needs a way to send the same caller auth material used for the eventual A2A RPC request, while preserving the protected-header safeguards for redirects/cross-origin cases. -
Medium: invalid A2A request shape is reported as
internal_error.A2aAgentExecutor.javathrowsInvalidRequestErrorfor client request problems such as missing tool names, control characters, or non-objectadcp_args, but the broadcatch (Exception)catches that A2A SDK error and converts it tointernal_error. Please letA2AErrorpropagate or catch it before the generic exception handler so malformed client input is reported as an invalid request instead of a server fault. -
Medium: the server Agent Card always advertises no skills.
A2aServerBuilder.javasetsskills(List.of())even when the platform exposes supported tools. This makes the generated card much less useful for discovery and conformance. Please populateAgentSkillentries fromplatform.supportedTools()with stable ids/names and descriptions where available. -
Medium: server execution is collapsed into the servlet caller thread.
A2aServerBuilder.javapassesRunnable::runintoDefaultRequestHandler.create(...). The upstream handler uses those executors for agent execution and event consumption, so this makes nominal async work run inline. For the streaming path, that can delay or block the response before the SSE stream is established. Please use injectable real executors, with a sensible default such as virtual threads for agent work.
Lower priority:
- The deprecated unauthenticated
A2aServletconstructor is still easy to call accidentally. Consider removing it before release, or replacing it with an explicitallowAnonymousbuilder/API. A2aServletreads and parses up to 1 MB before auth. If the auth model allows it, authenticate before body parsing to reduce unauthenticated request cost.- Please add targeted tests for the standard Agent Card path, authenticated card discovery, invalid
adcp_argserror mapping, skill population in generated cards, and non-inline server execution.
I could not run local Gradle tests on my review machine because no Java runtime is installed. GitHub checks are green, but the discovery/auth issues above are enough that I would not merge this as-is.
|
Follow-up on local verification: the machine did have Homebrew JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :adcp:test --tests '*A2a*' :adcp-server:test --tests '*A2a*'\n```\n\nResult: `BUILD SUCCESSFUL in 40s`. The request-changes findings still stand; the local-test caveat in my review was just an environment setup issue. |
Caller side (adcp module): - A2aCaller: JSON-RPC tool dispatch over A2A protocol with SSE streaming, SSRF-safe redirect policy, response size limits, and idempotency key - A2aConnectionManager: credential-isolated client cache using HMAC cache hash; protected headers (Authorization, Cookie) are stripped from cache keys but forwarded to agent-card discovery; exact-key eviction on retry - ProtocolClient: A2A dispatch path with computeCacheHash, evict-and-retry Server side (adcp-server module): - A2aServlet: Jakarta servlet bridge for A2A JSON-RPC; AsyncContext-based SSE streaming with writerLock guarding concurrent writes and exactly-once asyncContext.complete() via AtomicBoolean CAS - A2aAgentExecutor: adapts A2A message requests to AdcpPlatform.handleTool; plumbs ServerCallContext state as request headers (primitives only) - A2aAuthProvider: pluggable authentication SPI - A2aServerBuilder: fluent builder wiring AgentExecutor + RequestHandler Security hardening (5 audit cycles + 3 code review cycles): - SSRF: redirect-never HttpClient, DNS pre-validation, AgentCard URL pinning - Input bounds: request body cap (1 MB), method length, message ID length, tool name length, args scan limit, adcp_version field length - Auth isolation: per-credential cache hash prevents cross-tenant sharing; raw secrets never appear in cache key strings - SSE concurrency: completeAsync holds writerLock to eliminate write-after- complete race; subscription backpressure via request(SSE_PREFETCH)/request(1) - Log safety: control-character stripping in all user-controlled log fields Dependencies: a2a-java-sdk 1.0.0.CR1 (upgrade to GA deferred) ROADMAP.md: mark Track 3 complete, document a2a-java-sdk version decision jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lockfiles regenerated to match CI resolution. The a2a-java-sdk 1.0.0.CR1 transitive dependency tree resolved differently on Linux (CI) vs macOS (local), causing the lock-drift check to fail. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Move extractVersion() inside try block in A2aAgentExecutor so VersionUnsupportedError is caught and reported via emitter.fail() - Validate toolName in A2aCaller upfront (non-null, non-blank, length, control chars); use original value for outbound request, safeToolName only in log/error strings - Add AdcpHttpClient.newHttpClientBuilder() transport-agnostic alias; update A2aConnectionManager to use it instead of newMcpClientBuilder() - Fix Javadoc link in A2aServerBuilder: DefaultRequestHandler -> RequestHandler jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add com.google.code.gson:gson 2.14.0 as an explicit implementation dependency in adcp-server so A2aServlet's Gson usage is not reliant on transitive resolution through the A2A SDK. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Normalize Accept header to lowercase (Locale.ROOT) in wantsStreaming() so Accept: Text/Event-Stream (or any other casing) correctly enables SSE streaming per RFC 7231 case-insensitivity rules - Add explicit implementation deps for a2a-java-sdk-client-transport-jsonrpc (JSONRPCTransport, JSONRPCTransportConfigBuilder) and a2a-java-sdk-http-client (JdkA2AHttpClient) to adcp/build.gradle.kts; add catalog aliases for both jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Bump slf4j to 2.0.17 in version catalog to match lockfile resolution; update adcp-cli/gradle.lockfile accordingly - Fix buildAgentCardUri() to use origin (scheme+authority) rather than appending to the full agent URI path — A2A Agent Card is always at /.well-known/agent.json on the origin root - Enforce MAX_CONTENT_LENGTH in bytes (UTF-8) not chars in A2aCaller: DataPart uses writeValueAsBytes; TextPart uses getBytes(UTF_8).length - Set completed=true inside writeTimeoutResponse() writerLock block (non-committed path) to close the race where an in-flight onNext() could write SSE data after the timeout error was sent jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Make streaming error/timeout write+complete atomic in A2aServlet: add writeFinalStreamingResponse() that sets completed=true, writes the SSE event, and calls asyncContext.complete() in one synchronized block; update onTimeout, async onError, and subscriber onError to use this pattern; writeTimeoutResponse() now takes asyncContext and handles both paths atomically - Enforce MAX_METHOD_LENGTH: reject JSON-RPC requests with method names longer than 128 chars with a 400 InvalidRequestError - Add SchemaBundle tests: null, path-traversal, and leading-slash inputs rejected by load() and silently rejected by exists() - Add AdcpSchemaValidator test: path-traversal URI throws IllegalArgumentException jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Args into try writeTimeoutResponse() was releasing writerLock before calling writeFinalStreamingResponse(), creating a race window where onNext() could write another SSE event between the timeout decision and the final error event. Inline the committed-case logic under the same synchronized block so both paths (committed and uncommitted) complete atomically. extractArgs() calls objectMapper.convertValue() which can throw IllegalArgumentException. Moving it inside the try block ensures that exception is caught by the existing catch(Exception) path and converted to emitter.fail() rather than escaping uncaught. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ring JSON-RPC method extractToolName() checked non-blank before sanitization but the replaceAll control-char strip could reduce a non-blank input to blank (e.g., a name consisting entirely of control characters). Now validates the sanitized result is non-blank before returning in both the metadata and TextPart paths; falls through to InvalidRequestError if all candidates sanitize to blank. A2aServlet method parsing used isJsonPrimitive() which coerces numbers and booleans to strings, violating JSON-RPC 2.0 which requires method to be a string. Now additionally checks isString() on the primitive. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sults The post-sendMessage guard that protects against synchronous clients firing callbacks inline was checking latestTask.get() != null, which also triggers for non-terminal TaskEvents/TaskUpdateEvents. This caused the latch to count down prematurely when an in-progress task was delivered inline, leading extractResponse() to run on a non-terminal task and throw 'Empty response' or return incorrect results. Changed the guard to use isTerminal(latestTask.get()), consistent with the terminal-only countDown() logic in the event handlers above. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…add @nullable to builder fields A2aCaller: the toolName control-char check excluded tab (c != '\t') but tabs are ISO control characters and the error message already promised no control characters. Removed the exception to match the documented invariant and the sanitization regex which already strips all \p{Cc}. A2aConnectionManager: buildCacheKey() sorted headers with case-sensitive TreeMap keys, so X-Tenant and x-tenant would produce different cache entries for semantically identical credentials. Header names are now lowercased (Locale.ROOT) before insertion into the sorted map, and the test updated to reflect the normalized lowercase key. A2aServerBuilder: agentName/agentUrl/agentVersion fields are null until configured but were declared non-null in a @NullMarked package. Annotated them @nullable and updated require() to accept @nullable String. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ly stripping them Stripping control characters from tool names before dispatch was a security concern: a caller could send 'foo\x00bar' and end up executing 'foobar', silently changing which tool runs. Both the metadata and TextPart extraction paths now throw InvalidRequestError if the capped name contains any ISO control character, matching the same policy as A2aCaller. The validated name is safe to use directly in logs. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… sanitize debug logs Bump catalog jackson version from 2.18.2 to 2.20.1 to match what the A2A SDK dependency resolves in lockfiles; regenerate all lockfiles. buildCacheKey() used putIfAbsent when normalizing header names to lowercase, making value selection non-deterministic for case-insensitive duplicate keys from non-ordered input maps. Now pre-sorts entries by original key via TreeMap before lowercasing, so the alphabetically-last original key always wins — deterministic regardless of input map type. Two debug log statements were logging raw exception messages from remote A2A responses, which can contain untrusted payload content or control characters. Both now pass through sanitizeErrorText/sanitizeLogText to strip control chars and truncate before logging. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hable evict branch computeCacheHash() was hashing all extraHeaders including protected ones (Authorization, Cookie, etc.) that AdcpHttpClient strips before every request. This fragmented the connection cache unnecessarily — agents differing only in their Authorization header would get separate cache entries even though they'd make identical requests. Now filters out ProtectedHeaders.isProtected() entries before hashing. evict(URI agentUri) had an unreachable key.equals(prefix) branch since buildCacheKey() always produces 'agentUri#cacheHash[?headers]' — the bare agentUri string can never equal a cache key. Simplified to a single startsWith(agentUri + "#") predicate with a clarifying comment. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ClientCallContext, MessageSendParams, BiConsumer, and Consumer were imported but not directly referenced — the A2aMessageClient lambda interface uses them internally. Removing to keep -Xlint:all clean. jira-issue: N/A Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
High: fix agent card well-known path from /.well-known/agent.json to /.well-known/agent-card.json (A2A spec default). High: add AdcpHttpClient.getForAgentCard() that forwards auth headers (including Authorization) for private card endpoints while keeping SSRF and redirect-NEVER protection. Wire it into HttpAgentCardLoader.load(). Medium: let A2AError propagate from A2aAgentExecutor.execute() instead of being caught by the broad Exception handler and mapped to internal_error. Also type-check adcp_args (metadata and DataPart) and throw InvalidRequestError for non-object values. Medium: populate AgentSkill entries in A2aServerBuilder.buildAgentCard() from platform.supportedTools() and platform.toolDescriptions(). Skills are sorted for stable, deterministic card output. Medium: replace Runnable::run (inline execution) with a virtual-thread- per-task executor in A2aServerBuilder.build(). Use the lambda form (Thread.ofVirtual().start(task)) instead of Executors.newVirtualThreadPerTask Executor() to avoid an ExecutorService resource leak. Expose injectable agentExecutor() and eventConsumerExecutor() builder methods for tests. Add targeted tests for all five fixes: standard agent card path, auth header forwarding, invalid adcp_args error mapping (metadata + DataPart), skill population in generated cards, and off-caller-thread execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
eeb7f82 to
9578909
Compare
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
Summary
Completes Track 3: A2A transport implementation for both caller and server sides.
Caller side (
adcpmodule)A2aCaller— JSON-RPC tool dispatch over A2A protocol with SSE streaming, SSRF-safe redirect policy, response size limits, and idempotency keyA2aConnectionManager— credential-isolated client cache using HMAC cache hash; protected headers (Authorization,Cookie) stripped from cache keys but forwarded to agent-card discovery; exact-key eviction on transport errorsProtocolClient— A2A dispatch path wired withcomputeCacheHash, evict-and-retry on transient failuresServer side (
adcp-servermodule)A2aServlet— Jakarta servlet bridge for A2A JSON-RPC;AsyncContext-based SSE streaming;writerLockguards concurrent writes; exactly-onceasyncContext.complete()viaAtomicBooleanCAS;completeAsyncholdswriterLockto eliminate write-after-complete raceA2aAgentExecutor— adapts A2A message requests toAdcpPlatform.handleTool; plumbsServerCallContextstate as request headers (primitive values only)A2aAuthProvider— pluggable authentication SPIA2aServerBuilder— fluent builder wiringAgentExecutor+RequestHandlerSecurity hardening (5 audit cycles + 3 code review cycles)
HttpClient, DNS pre-validation,AgentCardURL pinning to validated originadcp_versionfield length capcompleteAsyncholdswriterLockto close the write-after-complete race; subscription backpressure viarequest(SSE_PREFETCH)/request(1)Dependencies
a2a-java-sdk 1.0.0.CR1; upgrade to GA deferred to a follow-up (expected minimal changeset)ROADMAP