Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1a167b6
Add multi tenant support for REST transport.
bartek-gralewicz Apr 20, 2026
19b3ba3
Resolve merge conflicts.
bartek-gralewicz Apr 21, 2026
939714e
Minor code simplifications after self code review.
bartek-gralewicz Apr 21, 2026
6ffe594
Restore original listTasks params setting.
bartek-gralewicz Apr 21, 2026
4d7d447
Added additional encodeURIComponent for string safety.
bartek-gralewicz Apr 21, 2026
fc37227
Merge remote-tracking branch 'origin/epic/1.0_breaking_changes' into …
bartek-gralewicz Apr 22, 2026
945473c
Add URI encoding for tenant value.
bartek-gralewicz Apr 22, 2026
f72068a
Add initial support for multi-tenancy in json-rpc and grpc transports.
bartek-gralewicz Apr 22, 2026
490562a
Aling all transports to support multi-tenancy.
bartek-gralewicz Apr 22, 2026
922bf8b
Reduce code duplication in grpc_service when extracting tenant value.
bartek-gralewicz Apr 22, 2026
58d877f
Simplify tenant extraction in grpc_service.
bartek-gralewicz Apr 22, 2026
2bbaa93
Add safety throw if task id includes : as it would make tenant url st…
bartek-gralewicz Apr 22, 2026
5792de4
Use nested map instead of relying on a semicolon naming scheme.
bartek-gralewicz Apr 23, 2026
41e6423
Applied nit for the ServerCallContext construction with optional tena…
bartek-gralewicz Apr 23, 2026
a52ecca
Add tenant_transport_decorator so that default tenant is automaticall…
bartek-gralewicz Apr 23, 2026
3f882da
Merge changes from the epic branch.
bartek-gralewicz Apr 23, 2026
8e2aefb
Update applyPathTenant to support query parameters as well. The name …
bartek-gralewicz Apr 23, 2026
42c0e66
Add tenantMiddleware to properly apply tenant logic directly on the h…
bartek-gralewicz Apr 23, 2026
1067cd8
Add drain method to the tenant transport decorator so that no eslint …
bartek-gralewicz Apr 23, 2026
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
4 changes: 2 additions & 2 deletions .betterer.results
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ exports[`TypeScript Strict Mode`] = {
[327, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"],
[350, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"]
],
"src/server/transports/jsonrpc/jsonrpc_transport_handler.ts:1878858438": [
[89, 12, 10, "tsc: Variable \'rpcRequest\' is used before being assigned.", "3927050741"]
"src/server/transports/jsonrpc/jsonrpc_transport_handler.ts:3366537262": [
[90, 12, 10, "tsc: Variable \'rpcRequest\' is used before being assigned.", "3927050741"]
]
}`
};
40 changes: 26 additions & 14 deletions src/client/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AgentCardResolver } from './card-resolver.js';
import { Client, ClientConfig } from './multitransport-client.js';
import { JsonRpcTransportFactory } from './transports/json_rpc_transport.js';
import { RestTransportFactory } from './transports/rest_transport.js';
import { TenantTransportDecorator } from './transports/tenant_transport_decorator.js';
import { TransportFactory } from './transports/transport.js';

export interface ClientFactoryOptions {
Expand Down Expand Up @@ -95,29 +96,40 @@ 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.
*/
async createFromAgentCard(agentCard: AgentCard): Promise<Client> {
const interfaces = agentCard.supportedInterfaces ?? [];
const urlsPerAgentTransports = new CaseInsensitiveMap<string>();
for (const i of interfaces) {
const existing = urlsPerAgentTransports.get(i.protocolBinding);
if (!existing || i.protocolVersion === '1.0') {
urlsPerAgentTransports.set(i.protocolBinding, i.url);

const bestInterfacePerProtocol = new CaseInsensitiveMap<(typeof interfaces)[number]>();
for (const agentInterface of interfaces) {
const existing = bestInterfacePerProtocol.get(agentInterface.protocolBinding);
if (!existing || agentInterface.protocolVersion === '1.0') {
bestInterfacePerProtocol.set(agentInterface.protocolBinding, agentInterface);
}
}

const transportsByPreference = [
...(this.options.preferredTransports ?? []),
...interfaces.map((i) => i.protocolBinding),
];
for (const transport of transportsByPreference) {
const url = urlsPerAgentTransports.get(transport);
const factory = this.transportsByName.get(transport);
if (factory && url) {
return new Client(
await factory.create(url, agentCard),
agentCard,
this.options.clientConfig
);
for (const transportName of transportsByPreference) {
const selectedInterface = bestInterfacePerProtocol.get(transportName);
const factory = this.transportsByName.get(transportName);
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);
}

return new Client(transport, agentCard, this.options.clientConfig);
}
}
throw new Error(
Expand Down
1 change: 1 addition & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
} from './card-resolver.js';
export { Client, type ClientConfig, type RequestOptions } from './multitransport-client.js';
export type { Transport, TransportFactory } from './transports/transport.js';
export { TenantTransportDecorator } from './transports/tenant_transport_decorator.js';
export { ClientFactory, ClientFactoryOptions } from './factory.js';
export { JsonRpcTransportFactory } from './transports/json_rpc_transport.js';
export { RestTransportFactory } from './transports/rest_transport.js';
Expand Down
6 changes: 5 additions & 1 deletion src/client/multitransport-client.ts
Comment thread
bartek-gralewicz marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,17 @@ export class Client {
/**
* If the current agent card supports the extended feature, it will try to fetch the extended agent card from the server,
* Otherwise it will return the current agent card value.
*
* When a default tenant is configured (via `TenantTransportDecorator`, wired
* automatically by `ClientFactory` from `AgentInterface.tenant`), the tenant
* is applied to the request transparently.
*/
async getAgentCard(options?: RequestOptions): Promise<AgentCard> {
if (this.agentCard.capabilities?.extendedAgentCard) {
this.agentCard = await this.executeWithInterceptors(
{ method: 'getAgentCard' },
options,
(_, options) => this.transport.getExtendedAgentCard(options)
(_, options) => this.transport.getExtendedAgentCard({ tenant: '' }, options)
);
}
return this.agentCard;
Expand Down
7 changes: 5 additions & 2 deletions src/client/transports/grpc/grpc_transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,13 @@ export class GrpcTransport implements Transport {
return PROTOCOL_NAME;
}

async getExtendedAgentCard(options?: RequestOptions): Promise<AgentCard> {
async getExtendedAgentCard(
params: GetExtendedAgentCardRequest,
options?: RequestOptions
): Promise<AgentCard> {
const rpcResponse = await this._sendGrpcRequest<GetExtendedAgentCardRequest, AgentCard>(
'getExtendedAgentCard',
{ tenant: '' },
params,
options,
this.grpcClient.getExtendedAgentCard.bind(this.grpcClient)
);
Expand Down
12 changes: 8 additions & 4 deletions src/client/transports/json_rpc_transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Transport, TransportFactory } from './transport.js';
import {
CancelTaskRequest,
DeleteTaskPushNotificationConfigRequest,
GetExtendedAgentCardRequest,
MessageFns,
SendMessageRequest,
SubscribeToTaskRequest,
Expand Down Expand Up @@ -51,12 +52,15 @@ export class JsonRpcTransport implements Transport {
return PROTOCOL_NAME;
}

async getExtendedAgentCard(options?: RequestOptions): Promise<AgentCard> {
const rpcResponse = await this._sendRpcRequest<undefined, AgentCard>(
async getExtendedAgentCard(
params: GetExtendedAgentCardRequest,
options?: RequestOptions
): Promise<AgentCard> {
const rpcResponse = await this._sendRpcRequest<GetExtendedAgentCardRequest, AgentCard>(
'GetExtendedAgentCard',
undefined,
params,
options,
undefined
GetExtendedAgentCardRequest
);
return AgentCard.fromJSON(rpcResponse.result);
}
Expand Down
88 changes: 56 additions & 32 deletions src/client/transports/rest_transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
AgentCard,
CancelTaskRequest,
DeleteTaskPushNotificationConfigRequest,
GetExtendedAgentCardRequest,
GetTaskPushNotificationConfigRequest,
GetTaskRequest,
ListTaskPushNotificationConfigsRequest,
Expand Down Expand Up @@ -60,14 +61,22 @@ export class RestTransport implements Transport {
this.customFetchImpl = options.fetchImpl;
}

private _buildPath(path: string, tenant?: string): string {
return tenant ? '/' + encodeURIComponent(tenant) + path : path;
}

get protocolName(): string {
return PROTOCOL_NAME;
}

async getExtendedAgentCard(options?: RequestOptions): Promise<AgentCard> {
async getExtendedAgentCard(
params: GetExtendedAgentCardRequest,
options?: RequestOptions
): Promise<AgentCard> {
const path = this._buildPath('/extendedAgentCard', params.tenant);
const response = await this._sendRequest<undefined, AgentCard>(
'GET',
'/extendedAgentCard',
path,
undefined,
options,
undefined,
Expand All @@ -81,9 +90,10 @@ export class RestTransport implements Transport {
options?: RequestOptions
): Promise<SendMessageResult> {
const requestBody = params;
const path = this._buildPath('/message:send', params.tenant);
const response = await this._sendRequest<SendMessageRequest, SendMessageResponse>(
'POST',
'/message:send',
path,
requestBody,
options,
SendMessageRequest,
Expand All @@ -97,34 +107,38 @@ export class RestTransport implements Transport {
options?: RequestOptions
): AsyncGenerator<StreamResponse, void, undefined> {
const requestBody = SendMessageRequest.toJSON(params);
yield* this._sendStreamingRequest('/message:stream', requestBody, options);
const path = this._buildPath('/message:stream', params.tenant);
yield* this._sendStreamingRequest(path, requestBody, options);
}

async createTaskPushNotificationConfig(
params: TaskPushNotificationConfig,
options?: RequestOptions
): Promise<TaskPushNotificationConfig> {
const response = await this._sendRequest<
TaskPushNotificationConfig,
TaskPushNotificationConfig
>(
'POST',
const path = this._buildPath(
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs`,
params,
options,
params.tenant
);
const response = await this._sendRequest<
TaskPushNotificationConfig,
TaskPushNotificationConfig
);
>('POST', path, params, options, TaskPushNotificationConfig, TaskPushNotificationConfig);
return response;
}

async getTaskPushNotificationConfig(
params: GetTaskPushNotificationConfigRequest,
options?: RequestOptions
): Promise<TaskPushNotificationConfig> {
const response = await this._sendRequest<undefined, TaskPushNotificationConfig>(
const path = this._buildPath(
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs/${encodeURIComponent(
params.id
)}`,
params.tenant
);
Comment thread
bartek-gralewicz marked this conversation as resolved.
const response = await this._sendRequest<void, TaskPushNotificationConfig>(
'GET',
`/tasks/${params.taskId}/pushNotificationConfigs/${params.id}`,
path,
undefined,
options,
undefined,
Expand All @@ -137,9 +151,13 @@ export class RestTransport implements Transport {
params: ListTaskPushNotificationConfigsRequest,
options?: RequestOptions
): Promise<ListTaskPushNotificationConfigsResponse> {
const response = await this._sendRequest<undefined, ListTaskPushNotificationConfigsResponse>(
const path = this._buildPath(
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs`,
params.tenant
);
const response = await this._sendRequest<void, ListTaskPushNotificationConfigsResponse>(
'GET',
`/tasks/${params.taskId}/pushNotificationConfigs`,
path,
undefined,
options,
undefined,
Expand All @@ -152,24 +170,26 @@ export class RestTransport implements Transport {
params: DeleteTaskPushNotificationConfigRequest,
options?: RequestOptions
): Promise<void> {
await this._sendRequest<undefined, void>(
'DELETE',
`/tasks/${params.taskId}/pushNotificationConfigs/${params.id}`,
undefined,
options,
undefined,
undefined
const path = this._buildPath(
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs/${encodeURIComponent(
params.id
)}`,
params.tenant
);
await this._sendRequest<void, void>('DELETE', path, undefined, options, undefined, undefined);
}

async getTask(params: GetTaskRequest, options?: RequestOptions): Promise<Task> {
const queryParams = new URLSearchParams();
if (params.historyLength !== undefined) {
queryParams.set('historyLength', String(params.historyLength));
queryParams.set('historyLength', params.historyLength.toString());
}
const queryString = queryParams.toString();
const path = `/tasks/${params.id}${queryString ? `?${queryString}` : ''}`;
const response = await this._sendRequest<undefined, Task>(
const path = this._buildPath(
`/tasks/${encodeURIComponent(params.id)}${queryString ? `?${queryString}` : ''}`,
params.tenant
);
const response = await this._sendRequest<void, Task>(
'GET',
path,
undefined,
Expand All @@ -181,9 +201,10 @@ export class RestTransport implements Transport {
}

async cancelTask(params: CancelTaskRequest, options?: RequestOptions): Promise<Task> {
const response = await this._sendRequest<undefined, Task>(
const path = this._buildPath(`/tasks/${encodeURIComponent(params.id)}:cancel`, params.tenant);
const response = await this._sendRequest<void, Task>(
'POST',
`/tasks/${params.id}:cancel`,
path,
undefined,
options,
undefined,
Expand All @@ -194,7 +215,6 @@ export class RestTransport implements Transport {

async listTasks(params: ListTasksRequest, options?: RequestOptions): Promise<ListTasksResponse> {
const queryParams = new URLSearchParams();
if (params.tenant) queryParams.set('tenant', params.tenant);
if (params.contextId) queryParams.set('contextId', params.contextId);
if (params.status !== undefined && params.status !== TaskState.TASK_STATE_UNSPECIFIED) {
queryParams.set('status', taskStateToJSON(params.status));
Expand All @@ -209,9 +229,9 @@ export class RestTransport implements Transport {
queryParams.set('includeArtifacts', String(params.includeArtifacts));

const queryString = queryParams.toString();
const path = `/tasks${queryString ? `?${queryString}` : ''}`;
const path = this._buildPath(`/tasks${queryString ? `?${queryString}` : ''}`, params.tenant);

const response = await this._sendRequest<undefined, ListTasksResponse>(
const response = await this._sendRequest<void, ListTasksResponse>(
'GET',
path,
undefined,
Expand All @@ -226,7 +246,11 @@ export class RestTransport implements Transport {
params: SubscribeToTaskRequest,
options?: RequestOptions
): AsyncGenerator<StreamResponse, void, undefined> {
yield* this._sendStreamingRequest(`/tasks/${params.id}:subscribe`, undefined, options);
const path = this._buildPath(
`/tasks/${encodeURIComponent(params.id)}:subscribe`,
params.tenant
);
yield* this._sendStreamingRequest(path, undefined, options);
}

private _fetch(...args: Parameters<typeof fetch>): ReturnType<typeof fetch> {
Expand Down
Loading
Loading