Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .changeset/rfc-9421-auto-sign-client-transports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
'@adcp/client': minor
---

Auto-apply RFC 9421 request signing to outbound MCP and A2A calls inside
`ProtocolClient` / `AdCPClient`. Follow-up to the signing primitives shipped
previously: the library now wires the signer into `StreamableHTTPClientTransport`
and the A2A `fetchImpl` automatically when an `AgentConfig.request_signing`
block is present.

Behavior:

- On first outbound call for an agent with `request_signing`, the client
fetches `get_adcp_capabilities` (unsigned — the discovery op is exempt) and
caches the seller's `request_signing` capability per-agent with a 300s TTL.
- Subsequent calls consult the cache to decide per-operation whether to
sign — precedence matches the spec: buyer `always_sign` > seller
`required_for` > seller `warn_for` (shadow-mode telemetry) > seller
`supported_for` (buyer opted in via `sign_supported`).
- Content-digest coverage honors the seller's `covers_content_digest` policy
(`required` / `forbidden` / `either`) per-request.
- Transport connection caches disambiguate by a per-key fingerprint (hash of
`kid` + private scalar) so two tenants that misconfigure the same `kid`
but hold distinct private keys cannot collide on a shared cached
transport and sign each other's traffic.
- `get_adcp_capabilities` and MCP/A2A protocol-layer RPCs (`initialize`,
`tools/list`, A2A card discovery) always pass through unsigned.
- OAuth-gated agents with signing: `callMCPToolWithOAuth` threads the
signing context through to the transport fetch, so OAuth flows don't
silently drop signatures.
- Priming failures fail open with a 60s negative cache: a transient seller
discovery outage doesn't wedge every subsequent call. `always_sign` ops
still get signed with sensible content-digest defaults; ops the seller
might have listed in `required_for` reach the wire unsigned and are
rejected visibly with `request_signature_required`, which retries re-prime.
- Concurrent cold-cache fans-out share one `get_adcp_capabilities` fetch
via an in-flight pending-map stored on the `CapabilityCache` instance
itself — so two tenants with separate `CapabilityCache` instances get
independent in-flight tables, and embedders who construct their own
cache don't race against the default cache.
- `AgentSigningContext.invalidate()` evicts this context's capability
entry so callers don't have to rebuild the cache key from the agent's
identifying fields when they want to force a re-prime.
- Signing-reserved headers (`Signature`, `Signature-Input`, `Content-Digest`)
supplied by a caller's `customHeaders` are scrubbed before the signer
runs — a misconfigured header cannot silently break or bypass the RFC
9421 signature output.
- `extractAdcpOperation` throws on unsupported body shapes (Blob, FormData,
ReadableStream) rather than silently passing the request unsigned — the
seller's `required_for` contract is not broken by SDK body-format drift.

New field on `AgentConfig`: `request_signing?: AgentRequestSigningConfig`
(kid, alg, `AdcpPrivateJsonWebKey` with required `d`, agent_url, optional
`always_sign[]` and `sign_supported`).

New sub-barrels:

- `@adcp/client/signing/client` — signer, canonicalization, fetch wrapper,
capability cache, and the auto-wiring helpers a buyer building an
AdCPClient needs.
- `@adcp/client/signing/server` — verifier pipeline, Express-shaped
middleware, JWKS / replay / revocation stores, error taxonomy.

The existing `@adcp/client/signing` barrel continues to export the union of
both sub-barrels, so existing consumers keep working. New code should
import from whichever half matches its role — coding agents reading a file
cold only need to hold one side of the taxonomy.

New exports on `@adcp/client/signing/client`: `CapabilityCache`,
`buildCapabilityCacheKey`, `defaultCapabilityCache`,
`buildAgentSigningContext`, `buildAgentSigningFetch`,
`ensureCapabilityLoaded`, `extractAdcpOperation`, `shouldSignOperation`,
`resolveCoverContentDigest`, `toSignerKey`, `CAPABILITY_OP`,
`CoverContentDigestPredicate`. `AgentSigningContext` gains an
`invalidate()` method. `CachedCapability` gains an optional `staleAt`
deadline for negative-cache entries.

`createSigningFetch` now accepts `coverContentDigest` as either `boolean`
or `(url, init) => boolean` so the seller policy can be resolved per
request without rebuilding the wrapper.
16 changes: 16 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@
"import": "./dist/lib/signing/index.js",
"require": "./dist/lib/signing/index.js",
"types": "./dist/lib/signing/index.d.ts"
},
"./signing/client": {
"import": "./dist/lib/signing/client.js",
"require": "./dist/lib/signing/client.js",
"types": "./dist/lib/signing/client.d.ts"
},
"./signing/server": {
"import": "./dist/lib/signing/server.js",
"require": "./dist/lib/signing/server.js",
"types": "./dist/lib/signing/server.d.ts"
}
},
"typesVersions": {
Expand All @@ -61,6 +71,12 @@
],
"signing": [
"dist/lib/signing/index.d.ts"
],
"signing/client": [
"dist/lib/signing/client.d.ts"
],
"signing/server": [
"dist/lib/signing/server.d.ts"
]
}
},
Expand Down
64 changes: 48 additions & 16 deletions src/lib/protocols/a2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AuthenticationRequiredError, is401Error } from '../errors';
import { discoverOAuthMetadata } from '../auth/oauth/discovery';
import { withSpan, injectTraceHeaders } from '../observability/tracing';
import { isAgentCardPath, buildCardUrls } from '../utils/a2a-discovery';
import { buildAgentSigningFetch, type AgentSigningContext } from '../signing/client';

if (!A2AClient) {
throw new Error('A2A SDK client is required. Please install @a2a-js/sdk');
Expand Down Expand Up @@ -40,13 +41,13 @@ const callContextStorage = new AsyncLocalStorage<A2ACallContext>();
const a2aClientCache = new Map<string, InstanceType<typeof A2AClient>>();
const pendingA2AClients = new Map<string, Promise<InstanceType<typeof A2AClient>>>();

function a2aCacheKey(agentUrl: string, authToken?: string): string {
if (!authToken) return agentUrl;
function a2aCacheKey(agentUrl: string, authToken?: string, signingCacheKey?: string): string {
// 64-bit hash prefix — cache key disambiguator, not a security boundary.
// The cached client closes over the full authToken; a hypothetical hash
// collision still sends the original token, not the colliding one.
const tokenHash = createHash('sha256').update(authToken).digest('hex').slice(0, 16);
return `${agentUrl}::${tokenHash}`;
const tokenSuffix = authToken ? `::${createHash('sha256').update(authToken).digest('hex').slice(0, 16)}` : '';
const signingSuffix = signingCacheKey ? `::${signingCacheKey}` : '';
return `${agentUrl}${tokenSuffix}${signingSuffix}`;
}

/**
Expand All @@ -61,16 +62,17 @@ export function closeA2AConnections(): void {

async function getOrCreateA2AClient(
agentUrl: string,
authToken: string | undefined
authToken: string | undefined,
signingContext: AgentSigningContext | undefined
): Promise<InstanceType<typeof A2AClient>> {
const cacheKey = a2aCacheKey(agentUrl, authToken);
const cacheKey = a2aCacheKey(agentUrl, authToken, signingContext?.cacheKey);
const cached = a2aClientCache.get(cacheKey);
if (cached) return cached;

const pending = pendingA2AClients.get(cacheKey);
if (pending) return pending;

const promise = createA2AClient(agentUrl, authToken)
const promise = createA2AClient(agentUrl, authToken, signingContext)
.then(client => {
a2aClientCache.set(cacheKey, client);
return client;
Expand All @@ -85,9 +87,10 @@ async function getOrCreateA2AClient(

async function createA2AClient(
agentUrl: string,
authToken: string | undefined
authToken: string | undefined,
signingContext: AgentSigningContext | undefined
): Promise<InstanceType<typeof A2AClient>> {
const fetchImpl = buildFetchImpl(authToken);
const fetchImpl = buildFetchImpl(authToken, signingContext);
const cardUrls = buildCardUrls(agentUrl);

const context = callContextStorage.getStore();
Expand All @@ -113,8 +116,12 @@ async function createA2AClient(
return client;
}

function buildFetchImpl(authToken: string | undefined) {
return async (url: string | URL | Request, options?: RequestInit) => {
function buildFetchImpl(authToken: string | undefined, signingContext: AgentSigningContext | undefined) {
// Inner fetch handles auth/header injection and 401 detection. If the
// agent has request-signing configured, we wrap it with the AdCP signing
// fetch so the signature covers the exact bytes we're about to send (auth
// headers included, since the signer re-reads the final header record).
const baseFetch = async (url: string | URL | Request, options?: RequestInit) => {
const context = callContextStorage.getStore();

const existingHeaders: Record<string, string> = {};
Expand Down Expand Up @@ -170,6 +177,20 @@ function buildFetchImpl(authToken: string | undefined) {

return response;
};

if (!signingContext) return baseFetch;

// The signing wrapper assembles headers into the signature base. We invoke
// it first so the signer sees the caller-supplied headers; baseFetch then
// overlays auth/trace headers afterwards — A2A's auth scheme (bearer) is
// not among the MANDATORY_COMPONENTS and is injected by the counterparty's
// transport layer, not signed.
const signingFetch = buildAgentSigningFetch({
upstream: (input, init) => baseFetch(input as any, init),
signing: signingContext.signing,
getCapability: signingContext.getCapability,
});
return signingFetch;
}

export async function callA2ATool(
Expand All @@ -179,7 +200,8 @@ export async function callA2ATool(
authToken?: string,
debugLogs: DebugLogEntry[] = [],
pushNotificationConfig?: PushNotificationConfig,
customHeaders?: Record<string, string>
customHeaders?: Record<string, string>,
signingContext?: AgentSigningContext
): Promise<unknown> {
return withSpan(
'adcp.a2a.call_tool',
Expand All @@ -194,7 +216,16 @@ export async function callA2ATool(
got401Ref: { value: false },
};
return callContextStorage.run(context, () =>
callA2AToolImpl(agentUrl, toolName, parameters, authToken, debugLogs, pushNotificationConfig, context)
callA2AToolImpl(
agentUrl,
toolName,
parameters,
authToken,
debugLogs,
pushNotificationConfig,
context,
signingContext
)
);
}
);
Expand All @@ -207,10 +238,11 @@ async function callA2AToolImpl(
authToken: string | undefined,
debugLogs: DebugLogEntry[],
pushNotificationConfig: PushNotificationConfig | undefined,
context: A2ACallContext
context: A2ACallContext,
signingContext: AgentSigningContext | undefined
): Promise<unknown> {
try {
const client = await getOrCreateA2AClient(agentUrl, authToken);
const client = await getOrCreateA2AClient(agentUrl, authToken, signingContext);

const requestPayload: {
message: {
Expand Down Expand Up @@ -281,7 +313,7 @@ async function callA2AToolImpl(
} catch (error: unknown) {
if (is401Error(error, context.got401Ref.value)) {
// Evict this cache entry — token may have expired or been revoked.
a2aClientCache.delete(a2aCacheKey(agentUrl, authToken));
a2aClientCache.delete(a2aCacheKey(agentUrl, authToken, signingContext?.cacheKey));

debugLogs.push({
type: 'error',
Expand Down
36 changes: 34 additions & 2 deletions src/lib/protocols/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { createNonInteractiveOAuthProvider } from '../auth/oauth';
import { validateAgentUrl } from '../validation';
import { withSpan } from '../observability/tracing';
import { ADCP_MAJOR_VERSION } from '../version';
import { buildAgentSigningContext, CAPABILITY_OP, ensureCapabilityLoaded } from '../signing/client';

/**
* Universal protocol client - automatically routes to the correct protocol implementation
Expand Down Expand Up @@ -79,6 +80,27 @@ export class ProtocolClient {

const authToken = getAuthToken(agent);

// RFC 9421 signing context. Built once per call; the transport
// attaches a fetch wrapper that reads the cached capability on every
// outbound request. `get_adcp_capabilities` is exempt from signing
// (it's the discovery call itself) and also triggers cache priming
// for any other op on agents with `request_signing` configured.
const signingContext = buildAgentSigningContext(agent);
if (signingContext && toolName !== CAPABILITY_OP) {
await ensureCapabilityLoaded(agent, signingContext, primeArgs =>
ProtocolClient.callTool(
agent,
CAPABILITY_OP,
primeArgs,
debugLogs,
undefined,
undefined,
undefined,
serverVersion
)
);
}

// Declare AdCP major version on every request so sellers can validate compatibility.
// Skip for v2 servers — they don't recognise the field and strict-schema agents reject it.
const argsWithVersion = serverVersion === 'v2' ? args : { adcp_major_version: ADCP_MAJOR_VERSION, ...args };
Expand Down Expand Up @@ -116,12 +138,21 @@ export class ProtocolClient {
authProvider,
debugLogs,
customHeaders: agent.headers,
signingContext,
});
}

// Use callMCPToolWithTasks which auto-detects server tasks capability
// and falls back to standard callTool when tasks are not supported
return callMCPToolWithTasks(agent.agent_uri, toolName, argsWithWebhook, authToken, debugLogs, agent.headers);
return callMCPToolWithTasks(
agent.agent_uri,
toolName,
argsWithWebhook,
authToken,
debugLogs,
agent.headers,
signingContext ? { signingContext } : undefined
);
} else if (agent.protocol === 'a2a') {
// For A2A, pass pushNotificationConfig separately (not in skill parameters)
return callA2ATool(
Expand All @@ -131,7 +162,8 @@ export class ProtocolClient {
authToken,
debugLogs,
pushNotificationConfig,
agent.headers
agent.headers,
signingContext
);
} else {
throw new Error(`Unsupported protocol: ${agent.protocol}`);
Expand Down
Loading
Loading