Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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