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
6 changes: 3 additions & 3 deletions .betterer.results
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
//
exports[`TypeScript Strict Mode`] = {
value: `{
"src/server/events/execution_event_bus.ts:150995267": [
[327, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"],
[350, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"]
"src/server/events/execution_event_bus.ts:1102247656": [
[233, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"],
[254, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"]
]
}`
};
76 changes: 20 additions & 56 deletions src/client/auth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,45 @@ export interface HttpHeaders {
}

/**
* Generic interface for handling authentication for HTTP requests.
*
* - For each HTTP request, this handler is called to provide additional headers to the request through
* the headers() function.
* - After the server returns a response, the shouldRetryWithHeaders() function is called. Usually this
* function responds to a 401 or 403 response or JSON-RPC codes, but can respond to any other signal -
* that is an implementation detail of the AuthenticationHandler.
* - If the shouldRetryWithHeaders() function returns new headers, then the request should retried with the provided
* revised headers. These provisional headers may, or may not, be optimistically stored for subsequent requests -
* that is an implementation detail of the AuthenticationHandler.
* - If the request is successful and the onSuccessfulRetry() is defined, then the onSuccessfulRetry() function is
* called with the headers that were used to successfully complete the request. This callback provides an
* opportunity to save the headers for subsequent requests if they were not already saved.
* Pluggable authentication handler for HTTP requests.
*
* - {@link headers} is called before each request to supply additional
* request headers (typically `Authorization`).
* - {@link shouldRetryWithHeaders} is called after every response and
* decides whether the request should be retried with new headers,
* typically in response to a 401 / 403 or a WWW-Authenticate.
* - {@link onSuccessfulRetry}, if defined, is called when a retry
* succeeds, giving the handler a chance to persist the new headers.
*/
export interface AuthenticationHandler {
/**
* Provides additional HTTP request headers.
* @returns HTTP headers which may include Authorization if available.
*/
/** Returns request headers (may include `Authorization`). */
headers: () => Promise<HttpHeaders>;

/**
* For every HTTP response (even 200s) the shouldRetryWithHeaders() method is called.
* This method is supposed to check if the request needs to be retried and if, yes,
* return a set of headers. An A2A server might indicate auth failures in its response
* by JSON-rpc codes, HTTP codes like 401, 403 or headers like WWW-Authenticate.
*
* @param req The RequestInit object used to invoke fetch()
* @param res The fetch Response object
* @returns If the HTTP request should be retried then returns the HTTP headers to use,
* or returns undefined if no retry should be made.
* Called for every response. Returns new headers if the request
* should be retried, or `undefined` to skip the retry.
*/
shouldRetryWithHeaders: (req: RequestInit, res: Response) => Promise<HttpHeaders | undefined>;

/**
* If the last HTTP request using the headers from shouldRetryWithHeaders() was successful, and
* this function is implemented, then it will be called with the headers provided from
* shouldRetryWithHeaders().
*
* This callback allows transient headers to be saved for subsequent requests only when they
* are validated by the server.
* Called when a retry using the headers from
* {@link shouldRetryWithHeaders} succeeded. Lets the handler persist
* those headers for subsequent requests.
*/
onSuccessfulRetry?: (headers: HttpHeaders) => Promise<void>;
}

/**
* Higher-order function that wraps fetch with authentication handling logic.
* Returns a new fetch function that automatically handles authentication retries for 401/403 responses.
*
* @param fetchImpl The underlying fetch implementation to wrap
* @param authHandler Authentication handler for managing auth headers and retries
* @returns A new fetch function with authentication handling capabilities
*
* Usage examples:
* - const authFetch = createAuthHandlingFetch(fetch, authHandler);
* - const response = await authFetch(url, options);
* - const response = await authFetch(url); // Direct function call
* Wraps `fetch` with authentication handling. The returned function
* injects headers from `authHandler.headers()`, retries when
* `authHandler.shouldRetryWithHeaders` returns new headers, and notifies
* via `onSuccessfulRetry` when the retry succeeds.
*/
export function createAuthenticatingFetchWithRetry(
fetchImpl: typeof fetch,
authHandler: AuthenticationHandler
): typeof fetch {
/**
* Executes a fetch request with authentication handling.
* If the auth handler provides new headers for the shouldRetryWithHeaders() function,
* then the request is retried.
* @param url The URL to fetch
* @param init The fetch request options
* @returns A Promise that resolves to the Response
*/
async function authFetch(url: RequestInfo | URL, init?: RequestInit): Promise<Response> {
// Merge auth headers with provided headers
const authHeaders = (await authHandler.headers()) || {};
const mergedInit: RequestInit = {
...(init || {}),
Expand All @@ -87,10 +53,8 @@ export function createAuthenticatingFetchWithRetry(

let response = await fetchImpl(url, mergedInit);

// Check if the auth handler wants to retry the request with new headers
const updatedHeaders = await authHandler.shouldRetryWithHeaders(mergedInit, response);
if (updatedHeaders) {
// Retry request with revised headers
const retryInit: RequestInit = {
...(init || {}),
headers: {
Expand All @@ -101,14 +65,14 @@ export function createAuthenticatingFetchWithRetry(
response = await fetchImpl(url, retryInit);

if (response.ok && authHandler.onSuccessfulRetry) {
await authHandler.onSuccessfulRetry(updatedHeaders); // Remember headers that worked
await authHandler.onSuccessfulRetry(updatedHeaders);
}
}

return response;
}

// Copy fetch properties to maintain compatibility
// Preserve fetch's own properties so the wrapped function is a drop-in.
Object.setPrototypeOf(authFetch, Object.getPrototypeOf(fetchImpl));
Object.defineProperties(authFetch, Object.getOwnPropertyDescriptors(fetchImpl));

Expand Down
60 changes: 18 additions & 42 deletions src/client/card-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,32 @@ export interface AgentCardResolverOptions {
path?: string;
fetchImpl?: typeof fetch;
/**
* Enables the v0.3 protocol compatibility layer.
*
* When enabled, the resolver inspects each fetched agent-card
* payload; if its shape matches v0.3 (top-level `url` without
* `supportedInterfaces`, `preferredTransport`,
* `additionalInterfaces`, `supportsAuthenticatedExtendedCard`, or a
* `protocolVersion` in `[0.3, 1.0)`), it is translated to the v1.0
* proto shape via `toCoreAgentCard`. Each synthesized
* Enables the v0.3 protocol compatibility layer. When enabled, the
* resolver detects v0.3-shaped card payloads and translates them to
* the v1.0 proto shape via `toCoreAgentCard`. Each synthesized
* `AgentInterface` is stamped with `protocolVersion: '0.3'` so that
* a `JsonRpcTransportFactory` configured with
* `legacyCompat: { enabled: true }` selects the compat transport
* automatically.
* a transport factory configured with `legacyCompat: { enabled: true }`
* selects the compat transport automatically.
*
* The discovery request itself always announces the SDK's native
* v1.0 in the `A2A-Version` header — detection of v0.3 servers is
* based on the response shape (see {@link resolve}), not on the
* request value. This avoids a downgrade dance when both client
* and server speak v1.0 natively but both have legacyCompat
* enabled.
* Detection is based on the response shape, not the request, so the
* discovery request always announces the SDK's native v1.0 in the
* `A2A-Version` header.
*
* Default: omitted (treated as disabled). When disabled, the v0.3
* compat module is never loaded.
* Default: omitted (disabled).
*/
legacyCompat?: { enabled: boolean };
}

export interface AgentCardResolver {
/**
* Fetches the agent card based on provided base URL and path,
*/
resolve(baseUrl: string, path?: string): Promise<AgentCard>;
}

export class DefaultAgentCardResolver implements AgentCardResolver {
constructor(public readonly options?: AgentCardResolverOptions) {}

/**
* Fetches the agent card based on provided base URL and path.
* Path is selected in the following order:
* 1) path parameter
* 2) path from options
* 3) .well-known/agent-card.json
* Fetches the agent card. Path is selected in this order:
* `path` parameter → `options.path` → `/.well-known/agent-card.json`.
*/
async resolve(baseUrl: string, path?: string): Promise<AgentCard> {
const agentCardUrl = new URL(path ?? this.options?.path ?? AGENT_CARD_PATH, baseUrl);
Expand All @@ -69,22 +53,14 @@ export class DefaultAgentCardResolver implements AgentCardResolver {
}

/*
* In the v0.3.0 specification, there was a structural drift between the JSON Schema data model
* and the Protobuf-based data model for AgentCards.
* The JSON Schema format uses a `"type"` discriminator (e.g., `{"type": "openIdConnect"}`),
* while the Protobuf JSON representation uses the `oneof` field name as the discriminator
* (e.g., `{"openIdConnectSecurityScheme": {...}}`).
*
* The A2A SDK internal logic expects the JSON Schema-based format. This fallback detection
* allows us to parse cards served by endpoints returning the Protobuf JSON structure by
* identifying the lack of the "type" field in security schemes or the presence of the
* "schemes" wrapper in security entries, and normalizing it before use.
* In v0.3 there was structural drift between the JSON Schema data
* model and the Protobuf-based data model for AgentCards: JSON Schema
* uses a `"type"` discriminator, while Protobuf JSON uses the `oneof`
* field name. The SDK expects the JSON Schema format; this fallback
* detects the Protobuf JSON shape and normalizes it before use.
*
* When `legacyCompat: { enabled: true }`, this method also detects
* v0.3-shaped cards and translates them via the compat
* module so the rest of the client stack sees a uniform v1.0
* representation with `protocolVersion: '0.3'` stamped on every
* synthesized interface.
* v0.3-shaped cards and translates them via the compat module.
*/
private normalizeAgentCard(card: unknown): AgentCard {
if (this.options?.legacyCompat?.enabled) {
Expand Down Expand Up @@ -132,7 +108,7 @@ export class DefaultAgentCardResolver implements AgentCardResolver {
const schemes = Object.values(securitySchemes);
if (schemes.length > 0) {
const first = schemes[0];
// Proto JSON maps use the oneof field name directly rather than a "type" property
// Proto JSON uses the oneof field name directly rather than a "type" property.
return first && typeof first === 'object' && !('type' in first);
}
}
Expand Down
51 changes: 22 additions & 29 deletions src/client/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,27 @@ import { TransportFactory } from './transports/transport.js';

export interface ClientFactoryOptions {
/**
* Transport factories to use.
* Effectively defines transports supported by this client factory.
* Transport factories to use. Effectively defines transports supported
* by this client factory.
*/
transports: TransportFactory[];

/**
* Client config to be used for clients created by this factory.
*/
/** Client config used for clients created by this factory. */
clientConfig?: ClientConfig;

/**
* Transport preferences to override ones defined by the agent card.
* If no matches are found among preferred transports, agent card values are used next.
* Transport preferences overriding those defined by the agent card.
* If no matches are found among preferred transports, agent card
* values are used next.
*/
preferredTransports?: TransportProtocolName[];

/**
* Used for createFromAgentCardUrl to download agent card.
*/
/** Used by `createFromUrl` to download the agent card. */
cardResolver?: AgentCardResolver;
}

export const ClientFactoryOptions = {
/**
* SDK default options for {@link ClientFactory}.
*/
/** SDK default options for {@link ClientFactory}. */
default: {
transports: [new JsonRpcTransportFactory(), new RestTransportFactory()],
} as Readonly<ClientFactoryOptions>,
Expand Down Expand Up @@ -95,12 +90,10 @@ export class ClientFactory {
}

/**
* Creates a new client from the provided agent card.
*
* When the selected `AgentInterface` declares a non-empty `tenant` value
* (per spec Section 4.4.6), the transport is automatically wrapped with a
* {@link TenantTransportDecorator} so the default tenant is applied to every
* request without requiring callers to set it manually.
* Creates a new client from the provided agent card. When the selected
* `AgentInterface` declares a non-empty `tenant`, the transport is
* wrapped with a {@link TenantTransportDecorator} so the default tenant
* is applied to every request.
*/
async createFromAgentCard(agentCard: AgentCard): Promise<Client> {
const interfaces = agentCard.supportedInterfaces ?? [];
Expand All @@ -123,8 +116,6 @@ export class ClientFactory {
if (factory && selectedInterface) {
let transport = await factory.create(selectedInterface.url, agentCard);

// If the agent interface declares a default tenant, wrap the transport
// so the tenant is automatically applied to all requests.
if (selectedInterface.tenant) {
transport = new TenantTransportDecorator(transport, selectedInterface.tenant);
}
Expand All @@ -139,15 +130,18 @@ export class ClientFactory {
}

/**
* Downloads agent card using AgentCardResolver from options
* and creates a new client from the downloaded card.
* Downloads the agent card using the configured {@link AgentCardResolver}
* and creates a new client from it.
*
* @example
* ```ts
* const factory = new ClientFactory(); // use default options and default {@link AgentCardResolver}.
* const client1 = await factory.createFromUrl('https://example.com'); // /.well-known/agent-card.json is used by default
* const client2 = await factory.createFromUrl('https://example.com', '/my-agent-card.json'); // specify custom path
* const client3 = await factory.createFromUrl('https://example.com/my-agent-card.json', ''); // specify full URL and set path to empty
* const factory = new ClientFactory();
* // /.well-known/agent-card.json is used by default.
* const client = await factory.createFromUrl('https://example.com');
* // Custom path.
* const client2 = await factory.createFromUrl('https://example.com', '/my-card.json');
* // Full URL with empty path.
* const client3 = await factory.createFromUrl('https://example.com/my-card.json', '');
* ```
*/
async createFromUrl(baseUrl: string, path?: string): Promise<Client> {
Expand Down Expand Up @@ -200,8 +194,7 @@ function mergeArrays<T>(
}

/**
* A Map that normalizes string keys to uppercase for case-insensitive lookups.
* This prevents errors from inconsistent casing in protocol names.
* Map that uppercases string keys so protocol-name lookups are case-insensitive.
*/
class CaseInsensitiveMap<T> extends Map<string, T> {
private normalizeKey(key: string): string {
Expand Down
4 changes: 1 addition & 3 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
/**
* Client entry point for the A2A Server V2 library.
*/
/** Client entry point for the A2A SDK. */

export * from './auth-handler.js';
export {
Expand Down
Loading
Loading