Skip to content

Commit 1877877

Browse files
feat: Support multi-tenancy (#419)
# Description Added support for multi-tenancy. ## Multi-Tenancy This handler supports multi-tenant deployments through the `tenant` field present on all request objects (per A2A spec Sections 3.1.x and 4.4.6). The tenant value flows through the system as follows: 1. **Transport layer** extracts tenant from the protocol-specific source: - REST: URL path prefix (`/:tenant/...`) - JSON-RPC: `params.tenant` in the request body - gRPC: `tenant` field in the request message 2. **`ServerCallContext.tenant`** carries the tenant to all downstream components, including `TaskStore`, `PushNotificationStore`, and `AgentExecutor`. 3. **`InMemoryTaskStore`** and **`InMemoryPushNotificationStore`** use `context.tenant` to scope data with composite keys (`{tenant}:{id}`), providing tenant isolation. Similar PR done in Python SDK: a2aproject/a2a-python#758 Fixes #325 🦕
1 parent b36c4f6 commit 1877877

31 files changed

Lines changed: 1571 additions & 253 deletions

.betterer.results

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ exports[`TypeScript Strict Mode`] = {
99
[327, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"],
1010
[350, 15, 4, "tsc: Expected 2 arguments, but got 1.", "2087764327"]
1111
],
12-
"src/server/transports/jsonrpc/jsonrpc_transport_handler.ts:1878858438": [
13-
[89, 12, 10, "tsc: Variable \'rpcRequest\' is used before being assigned.", "3927050741"]
12+
"src/server/transports/jsonrpc/jsonrpc_transport_handler.ts:3366537262": [
13+
[90, 12, 10, "tsc: Variable \'rpcRequest\' is used before being assigned.", "3927050741"]
1414
]
1515
}`
1616
};

src/client/factory.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AgentCardResolver } from './card-resolver.js';
44
import { Client, ClientConfig } from './multitransport-client.js';
55
import { JsonRpcTransportFactory } from './transports/json_rpc_transport.js';
66
import { RestTransportFactory } from './transports/rest_transport.js';
7+
import { TenantTransportDecorator } from './transports/tenant_transport_decorator.js';
78
import { TransportFactory } from './transports/transport.js';
89

910
export interface ClientFactoryOptions {
@@ -95,29 +96,40 @@ export class ClientFactory {
9596

9697
/**
9798
* Creates a new client from the provided agent card.
99+
*
100+
* When the selected `AgentInterface` declares a non-empty `tenant` value
101+
* (per spec Section 4.4.6), the transport is automatically wrapped with a
102+
* {@link TenantTransportDecorator} so the default tenant is applied to every
103+
* request without requiring callers to set it manually.
98104
*/
99105
async createFromAgentCard(agentCard: AgentCard): Promise<Client> {
100106
const interfaces = agentCard.supportedInterfaces ?? [];
101-
const urlsPerAgentTransports = new CaseInsensitiveMap<string>();
102-
for (const i of interfaces) {
103-
const existing = urlsPerAgentTransports.get(i.protocolBinding);
104-
if (!existing || i.protocolVersion === '1.0') {
105-
urlsPerAgentTransports.set(i.protocolBinding, i.url);
107+
108+
const bestInterfacePerProtocol = new CaseInsensitiveMap<(typeof interfaces)[number]>();
109+
for (const agentInterface of interfaces) {
110+
const existing = bestInterfacePerProtocol.get(agentInterface.protocolBinding);
111+
if (!existing || agentInterface.protocolVersion === '1.0') {
112+
bestInterfacePerProtocol.set(agentInterface.protocolBinding, agentInterface);
106113
}
107114
}
115+
108116
const transportsByPreference = [
109117
...(this.options.preferredTransports ?? []),
110118
...interfaces.map((i) => i.protocolBinding),
111119
];
112-
for (const transport of transportsByPreference) {
113-
const url = urlsPerAgentTransports.get(transport);
114-
const factory = this.transportsByName.get(transport);
115-
if (factory && url) {
116-
return new Client(
117-
await factory.create(url, agentCard),
118-
agentCard,
119-
this.options.clientConfig
120-
);
120+
for (const transportName of transportsByPreference) {
121+
const selectedInterface = bestInterfacePerProtocol.get(transportName);
122+
const factory = this.transportsByName.get(transportName);
123+
if (factory && selectedInterface) {
124+
let transport = await factory.create(selectedInterface.url, agentCard);
125+
126+
// If the agent interface declares a default tenant, wrap the transport
127+
// so the tenant is automatically applied to all requests.
128+
if (selectedInterface.tenant) {
129+
transport = new TenantTransportDecorator(transport, selectedInterface.tenant);
130+
}
131+
132+
return new Client(transport, agentCard, this.options.clientConfig);
121133
}
122134
}
123135
throw new Error(

src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
} from './card-resolver.js';
1111
export { Client, type ClientConfig, type RequestOptions } from './multitransport-client.js';
1212
export type { Transport, TransportFactory } from './transports/transport.js';
13+
export { TenantTransportDecorator } from './transports/tenant_transport_decorator.js';
1314
export { ClientFactory, ClientFactoryOptions } from './factory.js';
1415
export { JsonRpcTransportFactory } from './transports/json_rpc_transport.js';
1516
export { RestTransportFactory } from './transports/rest_transport.js';

src/client/multitransport-client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,17 @@ export class Client {
7777
/**
7878
* If the current agent card supports the extended feature, it will try to fetch the extended agent card from the server,
7979
* Otherwise it will return the current agent card value.
80+
*
81+
* When a default tenant is configured (via `TenantTransportDecorator`, wired
82+
* automatically by `ClientFactory` from `AgentInterface.tenant`), the tenant
83+
* is applied to the request transparently.
8084
*/
8185
async getAgentCard(options?: RequestOptions): Promise<AgentCard> {
8286
if (this.agentCard.capabilities?.extendedAgentCard) {
8387
this.agentCard = await this.executeWithInterceptors(
8488
{ method: 'getAgentCard' },
8589
options,
86-
(_, options) => this.transport.getExtendedAgentCard(options)
90+
(_, options) => this.transport.getExtendedAgentCard({ tenant: '' }, options)
8791
);
8892
}
8993
return this.agentCard;

src/client/transports/grpc/grpc_transport.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,13 @@ export class GrpcTransport implements Transport {
6969
return PROTOCOL_NAME;
7070
}
7171

72-
async getExtendedAgentCard(options?: RequestOptions): Promise<AgentCard> {
72+
async getExtendedAgentCard(
73+
params: GetExtendedAgentCardRequest,
74+
options?: RequestOptions
75+
): Promise<AgentCard> {
7376
const rpcResponse = await this._sendGrpcRequest<GetExtendedAgentCardRequest, AgentCard>(
7477
'getExtendedAgentCard',
75-
{ tenant: '' },
78+
params,
7679
options,
7780
this.grpcClient.getExtendedAgentCard.bind(this.grpcClient)
7881
);

src/client/transports/json_rpc_transport.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Transport, TransportFactory } from './transport.js';
1717
import {
1818
CancelTaskRequest,
1919
DeleteTaskPushNotificationConfigRequest,
20+
GetExtendedAgentCardRequest,
2021
MessageFns,
2122
SendMessageRequest,
2223
SubscribeToTaskRequest,
@@ -51,12 +52,15 @@ export class JsonRpcTransport implements Transport {
5152
return PROTOCOL_NAME;
5253
}
5354

54-
async getExtendedAgentCard(options?: RequestOptions): Promise<AgentCard> {
55-
const rpcResponse = await this._sendRpcRequest<undefined, AgentCard>(
55+
async getExtendedAgentCard(
56+
params: GetExtendedAgentCardRequest,
57+
options?: RequestOptions
58+
): Promise<AgentCard> {
59+
const rpcResponse = await this._sendRpcRequest<GetExtendedAgentCardRequest, AgentCard>(
5660
'GetExtendedAgentCard',
57-
undefined,
61+
params,
5862
options,
59-
undefined
63+
GetExtendedAgentCardRequest
6064
);
6165
return AgentCard.fromJSON(rpcResponse.result);
6266
}

src/client/transports/rest_transport.ts

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
AgentCard,
2121
CancelTaskRequest,
2222
DeleteTaskPushNotificationConfigRequest,
23+
GetExtendedAgentCardRequest,
2324
GetTaskPushNotificationConfigRequest,
2425
GetTaskRequest,
2526
ListTaskPushNotificationConfigsRequest,
@@ -60,14 +61,22 @@ export class RestTransport implements Transport {
6061
this.customFetchImpl = options.fetchImpl;
6162
}
6263

64+
private _buildPath(path: string, tenant?: string): string {
65+
return tenant ? '/' + encodeURIComponent(tenant) + path : path;
66+
}
67+
6368
get protocolName(): string {
6469
return PROTOCOL_NAME;
6570
}
6671

67-
async getExtendedAgentCard(options?: RequestOptions): Promise<AgentCard> {
72+
async getExtendedAgentCard(
73+
params: GetExtendedAgentCardRequest,
74+
options?: RequestOptions
75+
): Promise<AgentCard> {
76+
const path = this._buildPath('/extendedAgentCard', params.tenant);
6877
const response = await this._sendRequest<undefined, AgentCard>(
6978
'GET',
70-
'/extendedAgentCard',
79+
path,
7180
undefined,
7281
options,
7382
undefined,
@@ -81,9 +90,10 @@ export class RestTransport implements Transport {
8190
options?: RequestOptions
8291
): Promise<SendMessageResult> {
8392
const requestBody = params;
93+
const path = this._buildPath('/message:send', params.tenant);
8494
const response = await this._sendRequest<SendMessageRequest, SendMessageResponse>(
8595
'POST',
86-
'/message:send',
96+
path,
8797
requestBody,
8898
options,
8999
SendMessageRequest,
@@ -97,34 +107,38 @@ export class RestTransport implements Transport {
97107
options?: RequestOptions
98108
): AsyncGenerator<StreamResponse, void, undefined> {
99109
const requestBody = SendMessageRequest.toJSON(params);
100-
yield* this._sendStreamingRequest('/message:stream', requestBody, options);
110+
const path = this._buildPath('/message:stream', params.tenant);
111+
yield* this._sendStreamingRequest(path, requestBody, options);
101112
}
102113

103114
async createTaskPushNotificationConfig(
104115
params: TaskPushNotificationConfig,
105116
options?: RequestOptions
106117
): Promise<TaskPushNotificationConfig> {
107-
const response = await this._sendRequest<
108-
TaskPushNotificationConfig,
109-
TaskPushNotificationConfig
110-
>(
111-
'POST',
118+
const path = this._buildPath(
112119
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs`,
113-
params,
114-
options,
120+
params.tenant
121+
);
122+
const response = await this._sendRequest<
115123
TaskPushNotificationConfig,
116124
TaskPushNotificationConfig
117-
);
125+
>('POST', path, params, options, TaskPushNotificationConfig, TaskPushNotificationConfig);
118126
return response;
119127
}
120128

121129
async getTaskPushNotificationConfig(
122130
params: GetTaskPushNotificationConfigRequest,
123131
options?: RequestOptions
124132
): Promise<TaskPushNotificationConfig> {
125-
const response = await this._sendRequest<undefined, TaskPushNotificationConfig>(
133+
const path = this._buildPath(
134+
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs/${encodeURIComponent(
135+
params.id
136+
)}`,
137+
params.tenant
138+
);
139+
const response = await this._sendRequest<void, TaskPushNotificationConfig>(
126140
'GET',
127-
`/tasks/${params.taskId}/pushNotificationConfigs/${params.id}`,
141+
path,
128142
undefined,
129143
options,
130144
undefined,
@@ -137,9 +151,13 @@ export class RestTransport implements Transport {
137151
params: ListTaskPushNotificationConfigsRequest,
138152
options?: RequestOptions
139153
): Promise<ListTaskPushNotificationConfigsResponse> {
140-
const response = await this._sendRequest<undefined, ListTaskPushNotificationConfigsResponse>(
154+
const path = this._buildPath(
155+
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs`,
156+
params.tenant
157+
);
158+
const response = await this._sendRequest<void, ListTaskPushNotificationConfigsResponse>(
141159
'GET',
142-
`/tasks/${params.taskId}/pushNotificationConfigs`,
160+
path,
143161
undefined,
144162
options,
145163
undefined,
@@ -152,24 +170,26 @@ export class RestTransport implements Transport {
152170
params: DeleteTaskPushNotificationConfigRequest,
153171
options?: RequestOptions
154172
): Promise<void> {
155-
await this._sendRequest<undefined, void>(
156-
'DELETE',
157-
`/tasks/${params.taskId}/pushNotificationConfigs/${params.id}`,
158-
undefined,
159-
options,
160-
undefined,
161-
undefined
173+
const path = this._buildPath(
174+
`/tasks/${encodeURIComponent(params.taskId)}/pushNotificationConfigs/${encodeURIComponent(
175+
params.id
176+
)}`,
177+
params.tenant
162178
);
179+
await this._sendRequest<void, void>('DELETE', path, undefined, options, undefined, undefined);
163180
}
164181

165182
async getTask(params: GetTaskRequest, options?: RequestOptions): Promise<Task> {
166183
const queryParams = new URLSearchParams();
167184
if (params.historyLength !== undefined) {
168-
queryParams.set('historyLength', String(params.historyLength));
185+
queryParams.set('historyLength', params.historyLength.toString());
169186
}
170187
const queryString = queryParams.toString();
171-
const path = `/tasks/${params.id}${queryString ? `?${queryString}` : ''}`;
172-
const response = await this._sendRequest<undefined, Task>(
188+
const path = this._buildPath(
189+
`/tasks/${encodeURIComponent(params.id)}${queryString ? `?${queryString}` : ''}`,
190+
params.tenant
191+
);
192+
const response = await this._sendRequest<void, Task>(
173193
'GET',
174194
path,
175195
undefined,
@@ -181,9 +201,10 @@ export class RestTransport implements Transport {
181201
}
182202

183203
async cancelTask(params: CancelTaskRequest, options?: RequestOptions): Promise<Task> {
184-
const response = await this._sendRequest<undefined, Task>(
204+
const path = this._buildPath(`/tasks/${encodeURIComponent(params.id)}:cancel`, params.tenant);
205+
const response = await this._sendRequest<void, Task>(
185206
'POST',
186-
`/tasks/${params.id}:cancel`,
207+
path,
187208
undefined,
188209
options,
189210
undefined,
@@ -194,7 +215,6 @@ export class RestTransport implements Transport {
194215

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

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

214-
const response = await this._sendRequest<undefined, ListTasksResponse>(
234+
const response = await this._sendRequest<void, ListTasksResponse>(
215235
'GET',
216236
path,
217237
undefined,
@@ -226,7 +246,11 @@ export class RestTransport implements Transport {
226246
params: SubscribeToTaskRequest,
227247
options?: RequestOptions
228248
): AsyncGenerator<StreamResponse, void, undefined> {
229-
yield* this._sendStreamingRequest(`/tasks/${params.id}:subscribe`, undefined, options);
249+
const path = this._buildPath(
250+
`/tasks/${encodeURIComponent(params.id)}:subscribe`,
251+
params.tenant
252+
);
253+
yield* this._sendStreamingRequest(path, undefined, options);
230254
}
231255

232256
private _fetch(...args: Parameters<typeof fetch>): ReturnType<typeof fetch> {

0 commit comments

Comments
 (0)