Skip to content

feat: L0 transport — MCP caller, server, error taxonomy, auth (Track 3)#18

Merged
MichielDean merged 25 commits into
mainfrom
track3/transport
May 19, 2026
Merged

feat: L0 transport — MCP caller, server, error taxonomy, auth (Track 3)#18
MichielDean merged 25 commits into
mainfrom
track3/transport

Conversation

@MichielDean
Copy link
Copy Markdown
Collaborator

Summary

Implements Track 3 (L0 Transport: MCP + A2A) — the transport layer that turns generated types (Track 2) into a working caller and agent runtime. This PR delivers all v0.1 milestone components; A2A transport (Phase 7) is deferred to v0.4 per the roadmap.

Closes #17

What's included

Phase 1+2: HTTP foundation + Error taxonomy

  • AdcpHttpClient — SSRF-safe HTTP wrapper with DNS-pin protection via JDK 21 InetAddressResolverProvider
  • DnsPinResolver — DNS resolution with address validation against SsrfPolicy
  • AdcpError hierarchy — Sealed base class with 14 error subclasses matching the TS SDK error taxonomy (ProtocolError, AuthenticationRequiredError, ValidationError, etc.)
  • WwwAuthenticateParser — RFC 9110 §11.6.1 compliant WWW-Authenticate header parser

Phase 3: AgentConfig + Auth layer

  • AgentConfig — Builder-pattern connection config with auth exclusivity validation (can't set both static token and OAuth CC)
  • AuthTokenResolver — Resolves auth headers from config (Bearer, Basic, OAuth CC)
  • Protocol enum — MCP / A2A transport selection
  • AdcpVersion — V3 / V3_1 version constants

Phase 4: MCP Caller (client-side transport)

  • ProtocolClient — Central dispatch: SSRF validation → auth headers → version envelope → MCP/A2A routing
  • McpConnectionManager — LRU-cached MCP connections (max 20), StreamableHTTP → SSE fallback
  • McpCaller — MCP callTool wrapper with response deserialization
  • VersionEnvelope — Injects adcp_major_version + adcp_version into every tool call
  • Added mcp-core + mcp-json-jackson2 as implementation deps to adcp module (with json-schema-validator exclusion to avoid breaking API change)

Phase 5: AdcpClient

  • AdcpClient — User-facing entry point with builder, generic callTool() dispatch, and AutoCloseable lifecycle

Phase 6: MCP Server-side (agent transport)

  • AdcpPlatform — Abstract SPI for agent implementations; supportedTools() + handleTool() dispatch
  • AdcpServerBuilder — Wires AdcpPlatform to McpSyncServer via McpServer.sync(transport).toolCall(...)
  • AdcpContext — Per-request context record (version, headers, principal)

Phase 8: Integration testing

  • ServerBuilderRoundTripTest — 9 tests validating platform → server builder wiring, dispatch, error handling
  • AdcpClientIntegrationTest — Client integration tests against mock-server sidecar (CI-only)

Files changed

New files: 59 (across adcp/ and adcp-server/ modules)
Modified: ROADMAP.md (Track 3 claim), adcp/build.gradle.kts (MCP deps), lockfiles

Testing

  • 155 tests pass in adcp module (10 pre-existing failures are schema bundle tests that require cosign — same on main)
  • 4 tests pass in adcp-server module
  • 12 tests pass in adcp-testing module (9 ServerBuilder + 3 skipped CI-only)
  • All new code compiles with -Werror (zero warnings)

Not included (deferred)

  • Phase 7: A2A transport — deferred to v0.4 per D10. FeatureUnsupportedError thrown if A2A protocol is selected.
  • Named convenience methods (e.g. getProducts(), syncCreatives()) — will be added once the generic callTool() dispatch is proven in CI. The pattern is trivial: each delegates to callNamedTool().
  • OAuth auth-code flow — OAuth CC (client credentials) is implemented; auth-code flow via MCP SDK's OAuthClientProvider is a follow-up.

MichielBugherJelli and others added 6 commits May 18, 2026 17:24
Phase 1: SSRF-safe HTTP client
- AdcpHttpClient with DNS-pin, body cap, redirect:NEVER
- AdcpHttpResponse record with truncation tracking
- DnsPinResolver for DNS resolution + SSRF validation
- SsrfBlockedException for blocked requests

Phase 2: Error taxonomy (14 sealed subclasses of AdcpError)
- ProtocolError, AuthenticationRequiredError, TaskTimeoutError,
  TaskAbortedError, DeferredTaskError, ValidationError,
  ConfigurationError, VersionUnsupportedError, AgentNotFoundError,
  UnsupportedTaskError, FeatureUnsupportedError, ResponseTooLargeError,
  IdempotencyConflictError, IdempotencyExpiredError
- AuthChallengeInfo + OAuthMetadataInfo records
- WwwAuthenticateParser (RFC 9110 §11.6.1)
- Package-info.java for error and auth packages

Also updates ROADMAP.md Track 3 owner and tracks claimed.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- AgentConfig record with builder, auth exclusivity validation,
  and static MCP factory methods
- Protocol enum (MCP, A2A)
- AdcpVersion record with V3/V3_1 constants
- BasicCredentials, OAuthClientCredentials, OAuthTokens records
- AuthTokenResolver: Bearer, Basic, OAuth token → header resolution
  with x-adcp-auth backward compatibility header for static tokens

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…velope

- McpConnectionManager: LRU-cached MCP connections (max 20),
  StreamableHTTP → SSE fallback, evict-and-retry on transport errors
- McpCaller: callTool dispatch, content extraction, deserialization
- ProtocolClient: central dispatch point for all tool calls,
  SSRF URL validation, auth header injection, version envelope merge
- VersionEnvelope: adcp_major_version + adcp_version injection,
  caller args win on collision (conformance override)
- CallToolOptions: per-call timeout, body cap, validation toggle
- DnsPinResolver: made public for cross-package SSRF validation
- adcp/build.gradle.kts: added mcp-core + mcp-json-jackson2 (impl),
  excluded transitive json-schema-validator 2.x to keep pinned 1.5.x

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- AdcpClient: single-agent client, builder pattern, AutoCloseable
- Generic callTool(toolName, args, responseType) method
- callNamedTool(toolName, typedRequest, responseType) for type safety
- Builder: agent, adcpVersion, objectMapper, ssrfPolicy
- Lifecycle: close() evicts cached MCP connections

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements Phase 6 of Track 3: the agent-side MCP server wiring.

- AdcpContext: per-request context record (version, headers, principal)
- AdcpPlatform: abstract SPI that agent adopters extend; supportedTools()
  + handleTool() dispatch. Only supported tools are advertised via
  MCP tools/list.
- AdcpServerBuilder: wires AdcpPlatform to McpSyncServer via
  McpServer.sync(transport).toolCall(...). Extracts version envelope
  from inbound args, serialises responses as TextContent.
- Tests for platform dispatch + supportedTools + error paths.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 8 of Track 3: integration testing.

- ServerBuilderRoundTripTest: 9 tests validating AdcpPlatform →
  AdcpServerBuilder wiring, tool dispatch, error handling, context
  propagation, and version extraction.
- AdcpClientIntegrationTest: client integration tests against the
  @adcp/sdk/mock-server sidecar (enabled when ADCP_MOCK_SERVER_URL
  is set in CI).
- Add adcp-server as testImplementation dep of adcp-testing module.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MichielDean MichielDean requested a review from bokelley as a code owner May 18, 2026 23:42
@MichielDean MichielDean marked this pull request as draft May 19, 2026 00:44
MichielBugherJelli and others added 9 commits May 18, 2026 19:00
Address 22 findings from comprehensive code audit:

CRITICAL:
- McpConnectionManager: fix check-then-act race with ReentrantLock,
  pass auth headers via httpRequestCustomizer, pass connectTimeout
  to transport builders, add volatile closed flag, clean up
  knownStreamableUrls on evict/close
- AdcpHttpClient.close(): call httpClient.close() (was no-op)
- Credential records: override toString() to redact secrets in
  BasicCredentials, OAuthClientCredentials, OAuthTokens
- SsrfBlockedException: remove host from getMessage() to prevent
  information leakage
- AdcpHttpClient.pinUri(): stop rewriting URI with IP (broke HTTPS
  SNI/TLS); validate addresses but keep original hostname

HIGH:
- ProtocolClient.computeTokenHash(): use SHA-256 instead of
  String.hashCode() (32-bit collision risk)
- AdcpServerBuilder: use ObjectMapper for error JSON serialization
  to prevent JSON injection; strip version envelope fields from
  args before passing to platform
- AdcpClient.toArgs(): reuse ObjectMapper field instead of creating
  new instance per call
- McpCaller.extractResponse(): check result.isError() before
  deserializing as success
- AdcpHttpClient: filter protected headers (Host, User-Agent,
  Content-Length, Transfer-Encoding) from caller-supplied map
- BasicCredentials: reject colon in username per RFC 7617 §2
- AuthChallengeInfo: add null validation on scheme
- OAuthMetadataInfo: add null validation on required fields
- AdcpHttpClient.pinUri(): use syntactic IP-literal check instead
  of DNS call for literal addresses

MEDIUM:
- ProtocolClient retry: only retry transport errors (IOException,
  timeout), chain original exception as suppressed
- McpConnectionManager.isAuthError(): match specific patterns
  (HTTP 401, status: 401) instead of bare substring "401"

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address findings from deep security audit of Track 3 code:

HIGH — Must Fix:
- AgentConfig: override toString() to redact authToken and
  webhookSecret (CWE-532 info disclosure via auto-generated toString)
- AgentConfig: reject authToken with CR/LF characters to prevent
  header injection (CWE-113)
- AdcpObjectMapperFactory: reduce limits from 100MB/2000 depth to
  10MB/200 depth to prevent DoS via oversized payloads (CWE-400)
- McpConnectionManager: add CRLF sanitization and protected-header
  filtering on MCP transport headers (CWE-113)
- ProtocolClient.validateUrl(): perform DNS resolution and SSRF
  policy validation for MCP transport path — was previously skipped
  entirely, allowing SSRF via private address hostnames (CWE-918)

MEDIUM — Should Fix:
- ProtocolClient.computeTokenHash(): use full SHA-256 output instead
  of truncated 8 bytes to prevent cache key collisions (CWE-328)
- ProtocolClient: enforce auth-last header merge ordering so
  extraHeaders cannot override Authorization (CWE-287)
- McpCaller: sanitize and truncate remote error text to 500 chars,
  strip control characters to prevent injection into LLM context
- AdcpServerBuilder: split error handling into known (AdcpError →
  safe to surface) vs unknown (Exception → "internal error" only)
  to prevent internal details leaking to remote callers (CWE-209)
- AdcpServerBuilder.extractVersion(): validate major version >= 3
  to prevent protocol downgrade attacks (CWE-757)
- AdcpHttpResponse: defensive clone of body byte[] in compact
  constructor to prevent mutation (CWE-374)
- McpConnectionManager: move closed check inside lock to prevent
  connection creation race after close() (CWE-362)
- AdcpHttpClient/McpConnectionManager: expand protected headers
  to include connection/upgrade
- SsrfBlockedException.host(): restrict to package-private visibility
  to limit hostname exposure (CWE-209)

LOW — Cleanup:
- McpConnectionManager: replace ConcurrentHashMap-backed
  knownStreamableUrls with plain HashSet (always accessed under lock)

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address findings from deep code review of Track 3 implementation:

CRITICAL:
- McpConnectionManager.close(): move `closed = true` inside lock to
  prevent race where concurrent getOrConnect() sees inconsistent state
  (CWE-362)

HIGH:
- AdcpHttpResponse.body(): override record accessor to return defensive
  copy — the compact constructor cloned on construction but the accessor
  still leaked a reference to the internal array (CWE-374)
- AdcpServerBuilder: use platform.toolDescriptions() for MCP tool
  descriptions instead of using tool name as description (hurts LLM
  tool selection)
- AdcpServerBuilder: use safe constant in JSON fallback error path
  instead of string-concatenating e.code() (CWE-116 JSON injection)
- AdcpClient: fail fast with FeatureUnsupportedError when constructed
  with Protocol.A2A instead of silently creating MCP infrastructure
  and failing at callTool() time

MEDIUM:
- AuthChallengeInfo: enforce lowercase scheme in compact constructor
  to match documented contract
- AdcpVersion: validate minorVersion starts with majorVersion to
  prevent inconsistent version objects (e.g., major=3, minor="4.1")
- AgentConfig.toString(): redact extraHeaders values (may contain
  API keys like X-Api-Key)
- Extract shared ProtectedHeaders utility to eliminate duplicate
  PROTECTED_HEADERS constant between AdcpHttpClient and
  McpConnectionManager (drift risk)
- McpConnectionManager.connectWithFallback: extract buildAndInit()
  helper to eliminate triple-duplicated transport construction code
- McpCaller.extractResponse: track first parse error and attach as
  suppressed exception for better diagnostics
- CallToolOptions.Builder.maxResponseBytes: add validation rejecting
  zero and negative values
- McpConnectionManager.isAuthError: check McpError type first before
  falling back to string matching; add TODO for typed status exposure

LOW:
- McpConnectionManager.evictOldest: also clear knownStreamableUrls
  entry during LRU eviction to prevent stale transport preference

Tests: 5 new tests (178 total, 168 passing — 10 pre-existing schema
failures unrelated to Track 3).

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e chain

Address 4 findings from final comprehensive audit:

- WwwAuthenticateParser: use Locale.ROOT in toLowerCase() to prevent
  Turkish-locale JVMs producing incorrect scheme strings like "basıc"
  instead of "basic" (CWE-178 improper case handling)
- OAuthTokens: reject CR/LF in accessToken to match authToken
  validation parity — prevents silent header injection that would
  result in an unauthenticated request instead of a clear error
- McpConnectionManager.buildAndInit: close McpSyncClient if
  initialize() throws to prevent resource leak of HttpClient thread
  pools on repeated connection failures
- AuthenticationRequiredError: add cause-carrying constructor and
  pass original exception from McpConnectionManager auth detection
  so stack traces are preserved for debugging

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- HIGH: Fix IPv4-compatible IPv6 SSRF bypass (::a.b.c.d form)
  StrictSsrfPolicy.unmapIpv4Mapped() now unwraps both ::ffff:a.b.c.d
  (mapped) and ::a.b.c.d (compatible) forms before range checking
- MEDIUM: Add 10MB content size cap in McpCaller.extractResponse()
  to prevent OOM from malicious agents returning oversized TextContent
- MEDIUM: Stop leaking AdcpError.getMessage() to remote callers —
  AdcpServerBuilder now returns only e.code() in error responses
- LOW: Validate extraHeaders for CRLF at AgentConfig construction
  (fail-fast instead of relying solely on downstream sanitization)
- LOW: Validate AdcpVersion.minorVersion format with regex + length
  cap to prevent log injection via crafted version strings
- LOW: Warn via SLF4J when credentials are configured for plaintext
  HTTP agent URIs
- Docs: CallToolOptions Javadoc documents which fields are reserved
  (timeout, maxResponseBytes not yet wired in v0.1)

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…API design

- HIGH: Add inputSchema to MCP tool registration (MCP spec requires it)
- HIGH: Fix WwwAuthenticateParser Locale.ROOT on param key lowercasing
- HIGH: Fix knownStreamableUrls state leak across different token hashes
  (now keyed on full cache key instead of URL alone)
- HIGH: Remove unused imports and fix misleading Javadoc in DnsPinResolver
- HIGH: Fix AdcpPlatform Javadoc (falsely claimed reflection-based discovery)
- MEDIUM: Add AdcpHttpResponse.equals/hashCode using Arrays.equals for body
- MEDIUM: Redact all credentials in AgentConfig.toString() (basicAuth,
  oauthClientCredentials, oauthTokens were previously shown in clear)
- MEDIUM: Add authorization to ProtectedHeaders (prevent silent override)
- MEDIUM: Fix error response shape (was {error:code, code:code}, now
  {error:code, message:msg}) for AdcpError cases
- MEDIUM: Parse string major version values in extractVersion
- MEDIUM: Include oauthClientCredentials in computeTokenHash
- MEDIUM: Use ConfigurationError (not ProtocolError) for builder validation
- MEDIUM: Log warning when non-default CallToolOptions are passed (v0.1)

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k, DoS cap

- Include extraHeaders hash in MCP connection cache key to prevent
  cross-tenant header leakage in multi-tenant scenarios
- Revert authorization from ProtectedHeaders (broke SDK auth path)
- Fix InputStream leak in AdcpHttpClient when readBodyWithCap throws
- Skip null TextContent.text() entries in McpCaller instead of NPE
- Cap adcp_version string to 20 chars to prevent unbounded allocation
- Reject major version > 99 as unsupported

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… retry logic

- Replace global lock with per-key striped locking in McpConnectionManager
  so one slow/unreachable agent doesn't block all others (HEAD-OF-LINE fix)
- Make knownStreamableKeys a ConcurrentHashMap.KeySetView for thread safety
- Cap maxResponseBytes at 64 MB to prevent OOM from misconfigured caps
- Replace fragile contains("Transport") with cause-chain IOException walk
  for correct retry decisions

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Clean up per-key locks after connection attempt completes and clear
the map on close(). Without this, each OAuth token rotation created
an orphaned lock entry that persisted for process lifetime.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@bokelley bokelley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three parallel reviews (security, correctness, protocol). Architecture is sound and on-pattern — sealed AdcpError, JSpecify nullness, no Optional returns, no javax, no Bouncy Castle, builder/AutoCloseable lifecycle all clean. The findings below are about transport-layer details that the five prior adversarial audits look to have missed because the names of things matched the spec while the behavior did not.

Blockers

1. DnsPinResolver doesn't actually pin. AdcpHttpClient.java:188-195 and transport/ProtocolClient.java:166-179 resolve+validate the host, then throw the result away and let the JDK HttpClient re-resolve at connect time. A hostile DNS (multi-A response, low TTL, rebinding) can return a public IP at validation and 169.254.169.254 / 127.0.0.1 micro-seconds later. The class name, the javadoc, and the SSRF policy all read as if pinning is happening — it isn't. Fix: connect against an IP-literal URI with the original Host header + SSLParameters.setServerNames() for SNI (the Go net/http pattern), or hook a custom ProxySelector that hands back a pre-connected socket.

2. ProtectedHeaders is missing authorization, cookie, proxy-authorization. http/ProtectedHeaders.java:15-17 only guards host, user-agent, content-length, transfer-encoding, connection, upgrade. A caller (or a config-loaded extraHeaders) can silently override the SDK-resolved bearer token, or worse, inject a Cookie/Authorization that takes precedence on the redirect path. Add the credential headers to the blocklist.

3. McpCaller.extractResponse ignores structuredContent. McpCaller.java:60-108 only reads TextContent[].text. MCP 2025-06-18 (which the 7.x TS SDK adopted) added CallToolResult.structuredContent as the preferred channel for typed payloads, with content[] reserved for human-visible text. Once an agent upgrades to a spec-conformant server that emits structuredContent, this caller silently drops the typed payload and falls into valueToTree(first). Read structuredContent first.

4. AuthenticationRequiredError.challenge is always null on the MCP path. McpConnectionManager.java:181, 194, 262-281 construct the error with challenge=null, oauthMetadata=null and a TODO(7.2.0-delta). This is the headline 7.x delta this Track owns (ROADMAP §53) — the whole point of the field is to let callers branch on basic vs bearer after a 401. WwwAuthenticateParser exists but is never invoked here. Either land an MCP-SDK PR exposing the raw HttpResponse or do a pre-flight HEAD probe with AdcpHttpClient when an auth error is detected. Shipping the field as part of the public API while it's always null is worse than not shipping the field.

5. OAuth client-credentials token exchange has no implementation. AuthTokenResolver.java:52 comments "token exchange is done upstream before resolve()" — but no upstream code performs it. Configure oauthClientCredentials(...) and the resolver returns an empty header map; the request goes out unauthenticated and fails with a silent 401 in prod. Either implement the CC flow (routed through AdcpHttpClient so the token endpoint is SSRF-checked + HTTPS-enforced + the secret never logged), or have the resolver throw FeatureUnsupportedError so the caller knows.

6. ServerBuilderRoundTripTest doesn't test the builder. Class doc claims "AdcpPlatform → AdcpServerBuilder → MCP server round-trip", but every test calls platform.handleTool(...) directly (adcp-testing/.../ServerBuilderRoundTripTest.java:82, 96, 122, 175). The builder, the version-envelope strip on the server side, and the error-wrapping in handleToolCall are never exercised — this is the assert-output-against-itself pattern CLAUDE.md warns against. Construct AdcpServerBuilder.build() against an in-memory McpServerTransportProvider and dispatch a real CallToolRequest.

7. AdcpClientIntegrationTest.callTool_get_adcp_capabilities_returns_response only assertNotNull(result) (adcp-testing/.../AdcpClientIntegrationTest.java:67). Passes on an empty map. Validate spec shape — at minimum that adcp_version/adcp_major_version round-trip, or that the response carries a capabilities field.

Major

  • WwwAuthenticateParser mishandles quoted-pair escapes (WwwAuthenticateParser.java:30). The "([^\"]*)" regex forbids \", so a header like Bearer realm="abc\", scope="admin" parses as realm=abc\ and re-matches , scope="admin" — server-controlled, propagates into AuthChallengeInfo. Add a per-RFC-5.6.4 quoted-pair handler and a 16-param cap.
  • IPv6 SSRF gaps: StrictSsrfPolicy misses 6to4 (2002::/16 embedding 2002:7f00:0001:: → 127.0.0.1), Teredo (2001::/32), and NAT64 (64:ff9b::/96). It also accepts ambiguous IP literals like http://0177.0.0.1/isIpLiteral() only matches dotted-decimal so getAllByName is called and may parse as octal on some JDKs. Reject ambiguous literals up front and decode/re-evaluate 6to4/Teredo/NAT64 embeds.
  • AdcpPlatform.handleTool(String, Object request, AdcpContext)Object typing forces every adopter to cast. AdcpServerBuilder.java:142 always passes Map<String,Object>. Either tighten the parameter to Map<String,Object>, or split into typed per-tool methods.
  • McpConnectionManager.connectWithFallback retry is wrong direction (McpConnectionManager.java:174-212). MCP spec recommends a content-type probe to pick StreamableHTTP vs SSE — catching exceptions and retrying masks legitimate 5xx errors and double-charges every cold connect. The "retry StreamableHTTP once" branch at line 187+ has no backoff and isn't covered by any test.
  • Per-key lock removal race (McpConnectionManager.java:121). keyLocks.remove(cacheKey, keyLock) after unlock() lets two threads end up holding different ReentrantLock instances for the same key. The double-check cache read at 98-101 saves you most of the time, but parallel connects under load are still possible. Use a refcount and only remove at zero.
  • Cache eviction on token rotation (McpConnectionManager). Rotating OAuthTokens changes tokenHash → new entry → old connection lingers with the stale bearer until LRU evicts. Add invalidateForAgent(URI) or hook eviction off AgentConfig identity.
  • isAuthError substring match (McpConnectionManager.java:266) — getMessage().contains("401") will also match "code": 401234 in a JSON body. Tighten to word-bounded patterns and document the heuristic limitation.
  • AdcpServerBuilder.java:112 registers every tool with a permissive open-object inputSchema. Breaks MCP clients that rely on schemas for argument validation. Expose Map<String, JsonSchema> on AdcpPlatform.
  • ValidationError.field is a single string (error/ValidationError.java:11-19). TS SDK carries a JSON-pointer path array (/products/3/formats/0/duration) and the failing schema URI — needed once Track 9 storyboards land. Wire-compat hazard.
  • VersionEnvelope.mergeInto lets caller args override adcp_major_version (VersionEnvelope.java:49-55). Documented as "conformance override", but the TS SDK does the opposite — caller override here is a footgun where a buggy tool method silently downgrades the protocol. At minimum, log when caller overrides; ideally, SDK wins.
  • computeTokenHash is unsalted SHA-256 of the bearer token (ProtocolClient.java:214-238). Reversible for known token formats (ghp_* etc.); ends up in cache keys that may surface in heap dumps. Use HMAC with a per-process random key, or System.identityHashCode.

Minor / Consider

  • AdcpClient.callTool(...) has no null guard on args; VersionEnvelope.mergeInto.putAll(callerArgs) NPEs. Either require non-null or treat null as empty + test it.
  • BasicCredentials rejects blank passwords (auth/BasicCredentials.java:25-27) — breaks GitHub PAT, Stripe, others that use username=token, password=empty.
  • AdcpHttpClient allows plain http:// with only a warnPlaintextAuth() log. Add a strictMode builder option.
  • McpCaller.readValue with caller-supplied responseType — restrict the ObjectMapper to disable default typing / polymorphic gadgets if responseType could ever be Object.class/Map.class.
  • Protocol.A2A rejection duplicated in AdcpClient.java:51-55 and ProtocolClient.java:96-99 — keep one (prefer the dispatch site so future A2A wiring flips one switch).
  • AdcpServerBuilder.extractVersion rejects major < 3; AdCP back-compat semantics ask for default-to-v1 rather than refusal — confirm against Python adcp.server.
  • AuthTokenResolver emits both Authorization: Bearer and x-adcp-auth for back-compat — confirm 7.2.0 TS still emits both.
  • WwwAuthenticateParser only parses the first challenge — fine for AdCP needs, but javadoc should say so.

Done well (worth keeping)

  • Sealed AdcpError with stable string code() per subclass — cross-language parity is exactly right.
  • AgentConfig.toString, OAuthTokens.toString, BasicCredentials.toString, OAuthClientCredentials.toString all redact secrets. No SLF4J statements log raw credentials.
  • CRLF rejection on tokens + headers (OAuthTokens ctor, AgentConfig.validateExtraHeaders).
  • Cache key includes auth hash → no cross-tenant connection reuse.
  • Redirect.NEVER on both AdcpHttpClient and McpConnectionManager.
  • Body cap + suppressed-exception cleanup on streaming responses.
  • AdcpClient.close() cascades correctly through ProtocolClient.close()McpConnectionManager.close().
  • AdcpPlatform.supportedTools() gating of tools/list advertisement is the right SPI shape (ROADMAP §87).

Recommendation

Request changes. Blockers 1–5 are protocol/security-critical and must land before merge. Blockers 6–7 (test rigor) are easy to address and let the rest of the system actually rely on this layer. Majors can land as follow-ups but should have issues filed before this merges so they aren't lost behind the next track. I'm happy to re-review the SSRF pinning fix, the structuredContent fix, and the challenge population path together as a single follow-up commit.

MichielBugherJelli and others added 2 commits May 19, 2026 09:20
…r leak

- Guard keyLocks.remove() with hasQueuedThreads() to prevent duplicate
  connections when evict+reconnect races with a new per-key lock
- Check closed flag after connecting before inserting into cache to
  prevent resource leak when close() races with connect
- Sanitize server-side AdcpError messages (truncate 500 chars, strip
  control chars) before sending to remote callers (CWE-209)

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…st timeout, error sanitization

Major concurrency redesign of McpConnectionManager:
- Replace per-key ReentrantLock map with fixed-size striped Semaphore[32]
  pool, eliminating the keyLocks cleanup race and unbounded growth
- Semaphore.acquire() is virtual-thread-friendly (no carrier pinning),
  fixing performance DoS under JDK 21 virtual threads
- Add 30s request timeout via requestBuilder on both StreamableHTTP and
  SSE transports, preventing half-open connection DoS where a malicious
  agent accepts TCP but never responds to MCP initialize

Server-side error handling:
- Sanitize AdcpError messages before sending to remote callers: truncate
  to 500 chars and strip control characters to prevent info leakage
  (CWE-209)

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MichielDean MichielDean marked this pull request as ready for review May 19, 2026 15:47
MichielBugherJelli and others added 6 commits May 19, 2026 10:10
Blockers:
- B1: DNS pinning via URI rewriting + Host header injection
- B2: ProtectedHeaders for auth/cookie, filtered before auth merge
- B3: McpCaller reads structuredContent first, falls back to content[]
- B4: HEAD probe for WWW-Authenticate on auth errors
- B5: OAuth CC throws FeatureUnsupportedError (not yet implemented)
- B6: ServerBuilderRoundTripTest with StubMcpTransport
- B7: AdcpClientIntegrationTest with stronger spec assertions

Majors:
- M1: WwwAuthenticateParser quoted-pair fix + 16-param cap
- M2: IPv6 SSRF: 6to4, Teredo, NAT64, octal IP rejection
- M3: AdcpPlatform.handleTool Object → Map<String,Object>
- M5: invalidateForAgent for cache eviction on token rotation
- M6: isAuthError word-bounded pattern matching
- M7: toolSchemas() SPI on AdcpPlatform for typed input schemas
- M8: ValidationError field → path list + schemaUri
- M9: VersionEnvelope SDK wins over caller
- M10: computeTokenHash HMAC with per-process random key

Minors:
- N1: null callerArgs handled in VersionEnvelope
- N2: BasicCredentials allows blank password
- N3: A2A rejection deduplication (kept in ProtocolClient only)

CI:
- Updated dependency lockfiles for 4 modules (MCP SDK transitives)
- connectWithFallback TODO for content-type probe (M4 deferred)

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The current mock-server sidecar is a REST stub, not an MCP server,
so callTool tests fail when run against it. Guard the MCP-specific
test behind a separate env var until the mock-server gains MCP
support.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The schema bundle fetch task shells out to `cosign verify-blob` to
verify Sigstore signatures. Without cosign installed, all 10 schema
tests fail with "Cannot run program cosign". Document the requirement
in CONTRIBUTING.md and CLAUDE.md.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
M4: Replace exception-based StreamableHTTP→SSE fallback with a
content-type probe (POST with initialize to detect transport).
Known-good endpoints skip the probe. Falls back to SSE when the
probe gets 405 or non-JSON response.

N4: Add requireHttps(boolean) to AdcpHttpClient.Builder. When true,
rejects plain http:// for non-loopback hosts with a clear error.
Defaults to false for backward compat. Localhost is always exempt.

N5: Harden ObjectMapper in McpCaller — defensively disables default
typing on a copy to prevent polymorphic deserialization attacks when
responseType is Object.class or Map.class.

N6: Change extractVersion to default to v1 semantics for major<3
instead of throwing VersionUnsupportedError, matching Python
adcp.server back-compat behavior.

Also cleans up duplicate TODO comments in McpConnectionManager (B4).

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract handleToolCall and extractVersion to package-private visibility
so they can be tested directly from AdcpServerBuilderTest. The new test
class exercises:

- Tool dispatch through the builder's handler
- Version envelope stripping before platform dispatch
- Version extraction into AdcpContext
- AdcpError wrapping with stable error codes
- Unexpected exception wrapping without detail leakage
- Null arguments handling
- extractVersion edge cases (string/int major, back-compat, oversized minor)

This completes B6 from bokelley's review: the builder's handleToolCall
code path (version-envelope strip, error wrapping, handler dispatch) is
now fully exercised by tests, not just assertNotNull(server).

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
callTool now accepts @nullable args — null is normalised to Map.of()
before reaching ProtocolClient/VersionEnvelope. Adds a test verifying
null args does not NPE.

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MichielDean
Copy link
Copy Markdown
Collaborator Author

@bokelley ready for review now

Copy link
Copy Markdown
Contributor

@bokelley bokelley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough turn — most of the prior findings landed cleanly. Re-review verifies the following from my earlier pass are addressed:

Fixed correctly:

  • B2 ProtectedHeadersauthorization/cookie/proxy-authorization added (ProtectedHeaders.java:15-18), and ProtocolClient.callTool now filters extraHeaders before merging SDK auth on top (ProtocolClient.java:99-107).
  • B3 structuredContent — read first, fallback to content[] (McpCaller.java:73-92). MCP 2025-06-18 parity.
  • B4 WWW-Authenticate via HEAD probe — McpConnectionManager.probeAndBuildAuthError at line 403–431. Best-effort, documented as such. Note: probing the SSE/streamable endpoint with HEAD often returns 405, not 401 — fine as a best-effort, but consider documenting on AuthenticationRequiredError.challenge that it may be null even when auth is required.
  • B5 OAuth CC → FeatureUnsupportedError is the right interim (AuthTokenResolver.java).
  • B6 ServerBuilderRoundTripTestStubMcpTransport now exercises AdcpServerBuilder.build() for real (ServerBuilderRoundTripTest.java:77-80).
  • M1 WwwAuthenticateParser — quoted-pair + 16-param cap.
  • M2 IPv6 SSRF — 6to4 / Teredo / NAT64 covered; ambiguous IP literals (leading-zero octets) rejected at AdcpHttpClient.java:235-243.
  • M3 AdcpPlatform.handleTool(String, Map<String,Object>, AdcpContext) — typed.
  • M4 Content-type probe replaces exception-based StreamableHTTP/SSE pick (McpConnectionManager.probeSupportsStreamableHttp:301-340).
  • M5 invalidateForAgent(URI) — clears all token-hashed entries for a URI on rotation.
  • M6 isAuthError is now \b401\b word-bounded.
  • M7 toolSchemas() SPI on AdcpPlatform.
  • M8 ValidationError.path (list) + schemaUri.
  • M9 VersionEnvelope — SDK wins, caller override logged at warn. Implementation is clean (VersionEnvelope.java:62-74).
  • M10 HMAC-SHA256 with per-process random key for computeTokenHash (ProtocolClient.java:200-253).
  • Sixth/seventh audit also got the striped-semaphore redesign right — the per-key lock removal race is gone, virtual threads aren't pinned, and closed is re-checked after connect to prevent leaks. Good work.

Blocker (regression introduced by the B1 fix)

B1 fix introduces a TLS hostname-verification break, AND does not actually pin DNS on the runtime path. Two related issues:

(a) URI rewriting breaks HTTPS in AdcpHttpClient. AdcpHttpClient.pinUri() (line 199-217) rewrites https://example.com/path to https://93.184.216.34/path and injects a Host: example.com header. That fixes layer-7 routing, but the JDK HttpClient derives both TLS SNI and the hostname-verifier value from the URI authority, not from the Host header. The cert presented by example.com will fail validation against the IP literal — SSLPeerUnverifiedException. The comment at AdcpHttpClient.java:121 says "TLS SNI matches via SSLParameters", but SSLParameters.setServerNames is never called anywhere in the SDK (grep confirms: only the misleading comment, no setter). The current test suite only exercises SSRF rejection paths — no live HTTPS round-trip — so CI stays green but production HTTPS calls will fail on first contact.

Options (none of them clean in pure JDK 21 HttpClient):

  1. Don't rewrite the URI. Set networkaddress.cache.ttl=0 for the JDK resolver, validate every fresh resolution against SsrfPolicy, and accept the small TOCTOU window — this is what the Python httpx/requests ecosystem does.
  2. Use an SSLContext with a custom SSLSocketFactory that intercepts createSocket(InetAddress, port) to enforce the pin, and let the URI stay as the hostname. Doable but verbose.
  3. Drop the URI rewrite and document that DNS pinning is best-effort, with SsrfPolicy providing the real defense-in-depth. Honest, and matches what option (1) effectively does.

(b) DNS pinning is not applied on the MCP runtime path at all. ProtocolClient.validateUrl() (line 178-194) acknowledges this explicitly: "The MCP transport uses its own HttpClient which re-resolves DNS independently (TOCTOU limitation)". The MCP transport (HttpClientStreamableHttpTransport / HttpClientSseClientTransport in McpConnectionManager.buildAndInit:342-368) builds its own HttpClient — no SSRF policy applied to its resolver, no IP rewrite. The two ad-hoc probes (probeSupportsStreamableHttp:315, probeAndBuildAuthError:411) also call HttpClient.newBuilder() directly, bypassing AdcpHttpClient.

A grep for AdcpHttpClient references in src/main/java confirms it isn't called from anywhere outside its own file. So the broken-but-present DNS pinning is in code that isn't on the runtime tool-call path, and the runtime tool-call path has no DNS pinning at all — only the one-shot validate-at-config-time check, which is the original TOCTOU window the prior review flagged.

Acceptable resolutions, ranked by my preference:

  • Best: route MCP probes and transports through AdcpHttpClient (option 1 above) so a single defended HTTP layer covers every outbound request. The MCP SDK's customizeClient hook lets you supply your own HttpClient — wire your AdcpHttpClient's underlying client through that hook.
  • Acceptable: revert the URI-rewrite, document SsrfPolicy as the actual control, and add a Javadoc warning on DnsPinResolver that it's a one-shot syntactic check, not a true pin.
  • Not acceptable as-is: ship code labelled "DNS pinning" that breaks TLS in one path and isn't applied in the other. That's a worse posture than the prior version because adopters will trust the name.

Minor follow-ups (non-blocking)

  • McpConnectionManager.probeSupportsStreamableHttp (line 301-340): the probe sends a POST with a fabricated initialize JSON-RPC payload. Some MCP servers may actually accept this and complete an initialize half-handshake that the SDK then never finishes — leaking server-side state. Consider using OPTIONS or an empty POST with Content-Length: 0 instead, or document the behavior so server authors aren't surprised.
  • The HEAD probe at line 411 hits the agent base URI; if the agent is at https://agent.example.com/mcp/streamable and HEAD on that path returns 405 (common), the challenge is lost. Worth trying OPTIONS as a secondary fall-back before giving up.
  • validateUrl should reject scheme != http/https to prevent file://, gopher://, etc. from sneaking through (the URI gets to getAllByName(host) even for file://localhost/etc/passwd).
  • The 30s requestTimeout is hard-coded — expose on the McpConnectionManager constructor (or wire from AdcpClient.Builder) so callers running long-poll tool calls don't get cut off.

Recommendation

Request changes — narrowly, on the B1 regression. Everything else is in good shape and I'd merge today if the DNS-pin path either (a) routed through AdcpHttpClient properly with TLS handled, or (b) was honestly downgraded to "validate-at-resolve-time + low DNS TTL" with SsrfPolicy as the documented defense. Happy to fast-review whichever direction you take.

…validation (#19)

Implements issue #16 — Stripe-model per-instance adcpVersion option.

Changes:
- AdcpVersion.of(String): parse release-precision string "3.0" / "3.1"
  into an AdcpVersion. Companion to the existing AdcpVersion(int, String)
  constructor; follows the TS/Python SDK convention for caller-site pinning.

- Build-time AdcpSdkVersion.java: generated from ADCP_VERSION at build time
  (major=3, release="3.0" from "3.0.11"). Eliminates the manually-maintained
  COMPATIBLE_ADCP_VERSIONS list that the Python SDK got burned by — updating
  ADCP_VERSION automatically updates the compatibility constant.

- AdcpClient.Builder.adcpVersion(String): convenience overload that calls
  AdcpVersion.of(). Cross-major pins (e.g. "2.0" on a major-3 SDK) throw
  ConfigurationError at build() time, before any network request.

- AgentConfig.Builder.adcpVersion(String): same string-based overload.

Tests: AdcpVersion.of() parsing, AdcpSdkVersion constant invariants,
AdcpClient cross-major rejection, string-builder acceptance.

Acceptance criteria from #16:
  ✓ AdcpClient.builder().adcpVersion("3.0").build() accepted
  ✓ Outbound requests carry adcp_version at release precision
  ✓ Cross-major mismatch raises ConfigurationError before send
  ✓ Compatibility derived from ADCP_VERSION at build time

jira-issue: ADCP-0017

Co-authored-by: Bugher-Michiel-1124273_TDX <Michiel.Bugher@Tritondigital.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove URI rewriting that broke TLS hostname verification:
- DnsPinResolver no longer rewrites URIs to IP literals; validates at
  resolve time and returns the original URI so HTTPS SNI works correctly
- AdcpHttpClient.pinUri() renamed to validateUri(), scheme check added
- Host header injection removed (no longer needed without rewriting)

Route all MCP probes through AdcpHttpClient for SSRF defense:
- McpConnectionManager accepts AdcpHttpClient via new constructor
- probeSupportsStreamableHttp uses adcpHttpClient.post() with ping
  payload instead of raw HttpClient with initialize (avoids half-handshake)
- probeAndBuildAuthError routes through adcpHttpClient.send() with
  OPTIONS fallback when HEAD returns 405
- MCP transports use clientBuilder(adcpHttpClient.newMcpClientBuilder())
  instead of customizeClient

Additional fixes from review:
- ProtocolClient.validateUrl rejects non-http/https schemes
- AdcpClient.Builder exposes requestTimeout(Duration) for long-poll calls
- AuthenticationRequiredError.challenge javadoc documents null semantics
- AdcpClient.close() properly cascades to AdcpHttpClient

jira-issue: ADCP-0017

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@bokelley bokelley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The B1 regression is fixed. Verified end-to-end:

URI rewriting removed. DnsPinResolver.resolveAndPin now validates addresses against SsrfPolicy and returns without rewriting; callers keep the original URI so TLS SNI and hostname verification continue to work (DnsPinResolver.java:30-44). AdcpHttpClient.validateUri confirms the same and adds the scheme check I asked for (AdcpHttpClient.java:205-228). The Javadoc honestly documents the remaining resolve-to-connect TOCTOU window — that's the right framing for a JDK-21 HttpClient SDK.

MCP transports now route through AdcpHttpClient. McpConnectionManager.buildAndInit uses clientBuilder(adcpHttpClient.newMcpClientBuilder()) on both HttpClientStreamableHttpTransport and HttpClientSseClientTransport (lines 346, 353), inheriting Redirect.NEVER and the connect timeout. probeSupportsStreamableHttp (line 312-337) and probeAndBuildAuthError (line 400-418) both go through adcpHttpClient.post() / adcpHttpClient.send(), so the probes get the same SSRF + scheme + body-cap defenses as everything else.

Other items from the second-round review:

  • StreamableHTTP probe now POSTs a ping, not initialize — no half-handshake leakage. ✓ (line 313-314)
  • HEAD probe falls back to OPTIONS on 405. ✓ (line 405-408)
  • ProtocolClient.validateUrl rejects non-http/https schemes up front. ✓ (ProtocolClient.java:173-177)
  • AdcpClient.Builder.requestTimeout(Duration) plumbed through to McpConnectionManager. ✓ (AdcpClient.java:200-206)
  • AuthenticationRequiredError.challenge() Javadoc explicitly documents the null semantics ("may be null even when auth required — auth scheme could not be determined"). ✓
  • AdcpClient.close() cascades correctly: protocolClient.close() then adcpHttpClient.close() in a try/finally. ✓ (line 141-147)

The acknowledged residual is the per-request validateUrl → MCP-transport connect TOCTOU window. Closing it would need a custom SocketFactory/SSLEngine pipeline that the JDK 21 HttpClient doesn't expose cleanly; documenting the limitation and validating on every callTool is the right tradeoff for v0.1.

Approved, contingent on the existing CI run staying green. Ship it.

@MichielDean MichielDean merged commit a87d8a4 into main May 19, 2026
7 checks passed
@MichielDean MichielDean deleted the track3/transport branch May 19, 2026 17:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[track:transport] Michiel Bugher claims L0 transport: MCP + A2A

3 participants