feat: add transport diagnostics hook#2274
Conversation
There was a problem hiding this comment.
Clean observability layer that holds the witness-not-translator line — reads a response.clone() and returns the original byte-for-byte, so the diagnostics path can never reshape what the protocol caller sees. Opt-in (no-op when onTransportActivity is unset), correctly composed under the official MCP/A2A SDK fetch slots.
Things I checked
- Witness invariant holds.
wrapFetchWithTransportDiagnosticsreturnsupstream(input, init)'s response untouched; the only read is ofresponse.clone()inresponseBodySnippet. No fabricated fields, no normalization of the returned payload.ad-tech-protocol-expert: sound.javascript-protocol-expert: sound-with-caveats. - SSE stays intact.
isDiagnosticTextContentTypereturns false fortext/event-stream, soresponseBodySnippetbails before.clone()— MCP StreamableHTTP streams are never teed or drained. Same SSE-skip discipline asrawResponseCapture.ts/responseSizeLimit.ts. - Composition order is right. In
mcp.ts:543and912, signing wrapsdiagnosticFetchas upstream, so diagnostics observes the fully-signed request. That makes redaction ofSignature/Signature-Inputload-bearing — andSENSITIVE_KEY_REcatches both (signatureat^…-boundary).mcp-session-idredacted on request and response paths. - Header model is allowlist-only. Only
SAFE_HEADER_NAMES+ correlation headers are emitted; everything else is dropped or redacted.authorization/cookie/set-cookie/x-api-key/x-adcp-auth/proxy-authorizationcannot slip through. listMCPTasks3→4 args — sole non-test callerTaskExecutor.ts:1353updated, newoptionsparam defaults to{}. No broken call sites.- ALS plumbing —
emitTransportActivitypushes the handler promise ontoslot.pendingsynchronously during the fetch;withTransportDiagnostics'sfinallyawaitsPromise.allSettled(slot.pending). Handler rejections are swallowed (.then(noop, noop)) so an observability hook can't alter protocol behavior. - Changeset
.changeset/clean-taxis-observe.mdisminor— correct. Purely additive: new exports (sanitizeTransportHeaders,sanitizeTransportUrl,TransportActivity*types), one optional config field. No wire change, no breaking SDK surface.
Follow-ups (non-blocking — file as issues)
- Redaction is a weaker fork of
redactCredentialPatterns.security-reviewerflagged it and it checks out:src/lib/server/redact.ts:85rule #5 is an unlabeled≥32-char token catch-all thatsanitizeDiagnosticTextdropped. Net effect — a secret value under a benign JSON key ({"value":"sk_live_…"}, a PEM block, a bare JWT) or a raw token interpolated into an error string survives, because the JSON path only redacts on key-name match and the text path only catchesBearer/Basic/URL/labeled-pair shapes. Canonical credential names are redacted, so this isn't a leak of the common case — but before these events feed any sink a partially-trusted operator can read, port rule #5 (plus PEM-----BEGIN/JWT patterns) intosanitizeDiagnosticTextand apply it on the JSON-value path. ReuseredactCredentialPatternsdirectly so the two redactors don't drift. - Two missing regression tests for the headline-risk paths: (a) a
text/event-streamresponse asserting no body read + original stream still consumable, and (b) a>64KBstreaming body hitting thereader.cancel()truncation branch while the SDK still drains the original. Bothcode-reviewerandjavascript-protocol-expertasked for these. A direct assertion thatSignature/Signature-Inputheaders redact would pin the load-bearing regex against futureSENSITIVE_KEY_REedits.
Minor nits (non-blocking)
- Empty content-type defaults to text.
isDiagnosticTextContentTypereturnstrueon!lower(transportDiagnostics.ts), so a bodied response with nocontent-typegets cloned and read. Bounded to 64KB and a conformant MCP server always labels SSE, so it won't hit streaming — but a one-line comment on the intent, or flipping the unknown default to skip, is the safer posture for a transport observer. - Static HMAC salt.
fingerprintDiagnosticValuekeys on the hardcoded'adcp-transport-diagnostics'. The rawidempotency_keynever leaves the process (onlyidempotencyKeyHash), which is fine — idempotency keys aren't credentials. But a per-process random salt would remove the cross-deployment correlation oracle at zero cost to in-process correlation. Worth it only if these events ever reach a shared multi-tenant sink.
Approving on the strength of the witness-clean clone-and-return and the correct after-signing composition order. The redaction catch-all is the one follow-up I'd land before flipping these events on for an untrusted sink — but it doesn't block the hook shipping behind an opt-in flag.
There was a problem hiding this comment.
Approving. Clean additive observability hook that holds the SDK's witness-not-translator line — it emits sanitized copies, never mutates the protocol response or fabricates a field.
Things I checked
- Redaction is load-bearing, not theater. The diagnostic wrapper is the innermost network fetch in both transports, so it sees the fully-assembled request with auth attached —
a2a.ts:303injectsAuthorization/x-adcp-authbeforenetworkFetch,mcp.ts:912sitsdiagnosticFetchunder the RFC 9421 signing fetch.authorization,cookie,proxy-authorization,set-cookie,x-adcp-auth,x-api-key,mcp-session-idare all inSENSITIVE_HEADER_NAMES.signature/signature-inputaren't in the safe allowlist and don't start withx-, so they drop.security-reviewer: no High-severity leak in the deployed config. - Header emission is an allowlist, not a denylist (
transportDiagnostics.tssanitizeTransportHeaders). Unknown sensitive headers (dpop,x-amz-security-token,x-goog-api-key) are dropped, not emitted. Fail-closed beats fail-open — right default for a surface that logs. - SSE is never teed.
responseBodySnippetchecksisDiagnosticTextContentTypeand returnsundefinedfortext/event-streambeforeresponse.clone()is reached. MCP streamable-HTTP streams are untouched; only status/headers fire. Confirmed by bothcode-reviewerandjavascript-protocol-expert. response.clone()doesn't corrupt the consumer. Same proven pattern already shipping inwrapFetchWithCapture("clone before reading so the SDK still gets a consumable body"); original Response returned unconsumed. Diagnostic clone reads the size-limited stream (wrapper sits outsidewrapFetchWithSizeLimit), so a hostile body can't blow memory through diagnostics.- ALS slot lifecycle is sound.
withTransportDiagnosticsawaitsPromise.allSettled(slot.pending)infinally; handlers run as.then(()=>{}, ()=>{})so a throwing handler can't reach the protocol caller. Cached MCP connections resolve the current call's slot at request time, not connection-build time — the E2E test asserts two reused-connection calls emit distinctoperationId/idempotencyKeyHash. protocols/index.ts(+210/-171) is a mechanical reindent. The entirecallToolbody is wrapped one level deeper insidewithTransportDiagnostics({...}, () => withSpan(...)). In-process MCP, OAuth client-credentials refresh, thecallMCPToolWithTasks+ 401 retry, the A2A retry, and the finalelse throw 'Unsupported protocol'are byte-identical. Capability-priming forwardsonTransportActivity/transportActivityContexton the recursivecallTool. No logic changed.- Changeset present,
minor, correct..changeset/clean-taxis-observe.md— new opt-in config + additive exports (sanitizeTransportHeaders,sanitizeTransportUrl,TransportActivity*types). Additive = minor. package.jsonversionis untouched (9.0.0 both sides); the lockfile bump beta.31→9.0.0 just resyncs a stale lockfile on main, not a manual version edit. - Official SDKs preserved. The wrapper is a
typeof fetchdecorator intoStreamableHTTPClientTransport/A2AClient— no custom HTTP or SSE reimplementation.
Follow-ups (non-blocking — file as issues)
durationMson a streamed (SSE) tool is time-to-headers, not time-to-completion, becauseresponse_receivedfires whenupstream()resolves. A one-line doc note on theTransportActivity.durationMsfield would stop a consumer reading it as end-to-end latency.- Body-key redaction gaps (
security-reviewer, defense-in-depth):bearer/assertion/dpop/session[_-]?id/client_assertionaren't inSENSITIVE_KEY_RE. None appear in normal AdCP request/response bodies — auth is header-borne — so no concrete leak today. Worth adding for parity with theCTX-METADATA-SAFETY.mdethos if an adopter ever embeds a secret under such a key.
Minor nits (non-blocking)
- Constant HMAC salt for the idempotency fingerprint.
fingerprintDiagnosticValuesalts with the literal'adcp-transport-diagnostics'. Fine for correlation (one-way, raw key independently redacted), but a per-process random salt would give cross-deployment unlinkability if that ever matters. emitTransportActivityre-reads the slot viagetStore()rather than receiving it. Safe today — the wrapper only runs insidewithTransportDiagnostics— but passing the slot explicitly would be more robust against a future caller emitting outside the scope and silently dropping the pending promise.
The diagnostics-enabled token-exchange path is the one body that legitimately carries client_secret, and ClientCredentialsFlow.ts:199 builds its own fetch that never reads the ALS slot — so it's never observed. Nicely fenced.
Safe to merge.
Summary
Closes #2272.
Adds a public
onTransportActivitydiagnostics hook for outbound MCP and A2A transport requests. Events include request/response/failure phases, HTTP status, duration, method, sanitized URL, safe headers, bounded redacted body snippets, and operation/task/context correlation fields.The hook is wired through
ADCPMultiAgentClient, task execution, governance checks, cached MCP calls, nativetasks/list, and A2A calls while preserving the official protocol SDK clients.Also updates
undicifrom6.25.0to6.27.0so the PR passes the repo high-severity npm audit gate.Safety
Validation
npm run build:libNODE_ENV=test node --test-timeout=60000 --test-force-exit --test test/transport-diagnostics-e2e.test.js test/lib/transport-diagnostics.test.js test/server-a2a-submitted-end-to-end.test.js test/lib/mcp-oauth-connection-cache.test.jsnpm run test:allnpm audit --audit-level=highnpx commitlint --from origin/main --to HEAD --verbose