Skip to content

Commit 771836c

Browse files
authored
fix(compat/v0_3): call context builder for compatibility layer (#604)
# Description The v0.3 compat REST router and gRPC service hard-coded `new ServerCallContext(...)` and never invoked an operator-supplied `contextBuilder`. State that `contextBuilder` would inject (tenant, auth, request-id) was silently missing on v0.3 REST and gRPC requests. JSON-RPC was already correct: the v1.0 `jsonRpcHandler` builds the context once and then dispatches to either the v1.0 or v0.3 transport handler based on the JSON-RPC method name, so an injected `contextBuilder` reached v0.3 traffic on that path. This change brings REST and gRPC to parity. - Add `contextBuilder?: ServerCallContextBuilder` to `LegacyRestHandlerOptions` and `LegacyGrpcServiceOptions`. - Route both compat handlers through `options.contextBuilder ?? defaultServerCallContextBuilder`. - Pass `headers` through in both paths so the default builder's `context.state[STATE_HEADERS_KEY]` behavior isn't silently lost on v0.3 requests. - Fix `LegacyGrpcServiceOptions` docstring that falsely claimed "Same shape as v1.0 `GrpcServiceOptions`". The v1.0 Express `restHandler` auto-forwards its `contextBuilder` into the compat sub-router via structural typing (no v1.0-side change). gRPC operators pass the same builder to `legacyGrpcService` next to `grpcService`. Fixes #600
1 parent 35a64b2 commit 771836c

6 files changed

Lines changed: 356 additions & 66 deletions

File tree

docs/compatibility-v0_3.md

Lines changed: 91 additions & 48 deletions
Large diffs are not rendered by default.

src/compat/v0_3/README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ This directory (`src/compat/v0_3/`) provides the foundational data representatio
66

77
The compat layer is shipped as six subpath exports off `@a2a-js/sdk`. Each subpath carries only the peer dependencies (`express`, `@grpc/grpc-js`) its runtime needs, so a Workers consumer that only opts into the compat-aware client transports never has to pull in Node-only modules.
88

9-
| Subpath | What it exports | Peer deps |
10-
| :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------ |
11-
| `@a2a-js/sdk/compat/v0_3` | v0.3 protocol constants (`A2A_LEGACY_PROTOCOL_VERSION`, `LEGACY_HTTP_EXTENSION_HEADER`, `LEGACY_JSON_CONTENT_TYPE`, the `LEGACY_METHOD_*` literals) and method-name translators (`legacyJsonRpcToV1Method`, `v1MethodToLegacyJsonRpc`, `legacyJsonRpcToLegacyGrpcMethod`, `legacyGrpcToLegacyJsonRpcMethod`, `legacyGrpcToV1Method`, `v1MethodToLegacyGrpc`, `isLegacyJsonRpcMethod`, `isV1JsonRpcMethod`). | none (Workers-safe) |
12-
| `@a2a-js/sdk/compat/v0_3/server` | Framework-agnostic transport handlers (`LegacyJsonRpcTransportHandler`, `LegacyRestTransportHandler`, `toLegacyHTTPError`), push-notification factory (`createLegacyAwarePushNotificationSender`) and serializer (`V03PushNotificationSerializer`), and the `LegacyA2AError` class. Mount the transport handlers from any HTTP runtime (Express, Fastify, Hono, Cloudflare Workers, …). | none (Workers-safe) |
13-
| `@a2a-js/sdk/compat/v0_3/server/express` | Express routers (`legacyAgentCardRouter`, `legacyRestRouter`) that wrap the handlers above with the v0.3 well-known agent-card and REST endpoint paths. Header-based dispatch on `A2A-Version`. | `express` |
14-
| `@a2a-js/sdk/compat/v0_3/server/grpc` | v0.3 gRPC service factory (`legacyGrpcService`), service descriptor (`LegacyA2AService`), and options type. Register alongside the v1.0 `grpcService` on the same gRPC `Server`. | `@grpc/grpc-js` |
15-
| `@a2a-js/sdk/compat/v0_3/client` | Card-resolver helpers (`isLegacyAgentCard`, `parseLegacyAgentCard`) and the v0.3 JSON-RPC + REST client transports (`LegacyJsonRpcTransport`, `LegacyRestTransport`). | none (Workers-safe) |
16-
| `@a2a-js/sdk/compat/v0_3/client/grpc` | v0.3 gRPC client transport (`LegacyGrpcTransport`), instantiated by the v1.0 `GrpcTransportFactory` when `legacyCompat: { enabled: true }` and the matched `AgentInterface.protocolVersion` falls in `[0.3, 1.0)`. | `@grpc/grpc-js` |
9+
| Subpath | What it exports | Peer deps |
10+
| :--------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------ |
11+
| `@a2a-js/sdk/compat/v0_3` | v0.3 protocol constants (`A2A_LEGACY_PROTOCOL_VERSION`, `LEGACY_HTTP_EXTENSION_HEADER`, `LEGACY_JSON_CONTENT_TYPE`, the `LEGACY_METHOD_*` literals) and method-name translators (`legacyJsonRpcToV1Method`, `v1MethodToLegacyJsonRpc`, `legacyJsonRpcToLegacyGrpcMethod`, `legacyGrpcToLegacyJsonRpcMethod`, `legacyGrpcToV1Method`, `v1MethodToLegacyGrpc`, `isLegacyJsonRpcMethod`, `isV1JsonRpcMethod`). | none (Workers-safe) |
12+
| `@a2a-js/sdk/compat/v0_3/server` | Framework-agnostic transport handlers (`LegacyJsonRpcTransportHandler`, `LegacyRestTransportHandler`, `toLegacyHTTPError`), push-notification factory (`createLegacyAwarePushNotificationSender`) and serializer (`V03PushNotificationSerializer`), and the `LegacyA2AError` class. Mount the transport handlers from any HTTP runtime (Express, Fastify, Hono, Cloudflare Workers, …). | none (Workers-safe) |
13+
| `@a2a-js/sdk/compat/v0_3/server/express` | Express routers (`legacyAgentCardRouter`, `legacyRestRouter`) that wrap the handlers above with the v0.3 well-known agent-card and REST endpoint paths. Header-based dispatch on `A2A-Version`. | `express` |
14+
| `@a2a-js/sdk/compat/v0_3/server/grpc` | v0.3 gRPC service factory (`legacyGrpcService`), service descriptor (`LegacyA2AService`), and options type. Register alongside the v1.0 `grpcService` on the same gRPC `Server`. | `@grpc/grpc-js` |
15+
| `@a2a-js/sdk/compat/v0_3/client` | Card-resolver helpers (`isLegacyAgentCard`, `parseLegacyAgentCard`) and the v0.3 JSON-RPC + REST client transports (`LegacyJsonRpcTransport`, `LegacyRestTransport`). | none (Workers-safe) |
16+
| `@a2a-js/sdk/compat/v0_3/client/grpc` | v0.3 gRPC client transport (`LegacyGrpcTransport`), instantiated by the v1.0 `GrpcTransportFactory` when `legacyCompat: { enabled: true }` and the matched `AgentInterface.protocolVersion` falls in `[0.3, 1.0)`. | `@grpc/grpc-js` |
1717

1818
The bidirectional v0.3 ↔ v1.0 payload translators in `./translate/` are intentionally NOT part of the public surface and may change without a major-version bump.
1919

@@ -67,6 +67,8 @@ Operators advertise a binding at v0.3 by declaring a per-interface `protocolVers
6767

6868
The v1.0 gRPC service factory (`src/server/grpc/grpc_service.ts`) intentionally does **not** carry a `legacyCompat` option; v0.3 gRPC clients are served by importing `legacyGrpcService` from `@a2a-js/sdk/compat/v0_3/server/grpc` and registering it alongside the v1.0 `grpcService` on the same `Server`. (The v1.0 `@a2a-js/sdk/server/grpc` barrel does not re-export `legacyGrpcService`; the explicit compat import keeps `@grpc/grpc-js` out of the v1.0 dependency graph for operators who only deploy the v1.0 service.)
6969

70+
Executor-facing hooks (`userBuilder`, `contextBuilder`) are wire-version-agnostic by design. The Express `restHandler` and `jsonRpcHandler` forward these hooks into their compat sub-routers automatically; `legacyGrpcService` accepts the same options as `grpcService` so operators can pass the same builder to both `Server.addService` calls. Every code path — v1.0 and v0.3 — invokes `options.contextBuilder ?? defaultServerCallContextBuilder` before dispatching to the `A2ARequestHandler`.
71+
7072
### JSON-RPC method dispatch
7173

7274
`jsonRpcHandler({ legacyCompat: { enabled: true } })` routes each request body based on its `method` field:

src/compat/v0_3/server/express/rest_handler.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import express, {
1414

1515
import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../../../constants.js';
1616
import { Extensions } from '../../../../extensions.js';
17-
import { ServerCallContext } from '../../../../server/context.js';
17+
import {
18+
ServerCallContext,
19+
ServerCallContextBuilder,
20+
defaultServerCallContextBuilder,
21+
} from '../../../../server/context.js';
1822
import { UserBuilder, delegateAsyncIterator } from '../../../../server/express/common.js';
1923
import { type A2ARequestHandler } from '../../../../server/request_handler/a2a_request_handler.js';
2024
import { SSE_HEADERS, formatSSEEvent, formatSSEErrorEvent } from '../../../../sse_utils.js';
@@ -49,6 +53,14 @@ import {
4953
export interface LegacyRestHandlerOptions {
5054
requestHandler: A2ARequestHandler;
5155
userBuilder: UserBuilder;
56+
/**
57+
* Custom builder for the per-request {@link ServerCallContext}. When
58+
* omitted, {@link defaultServerCallContextBuilder} is used, which
59+
* populates `context.state[STATE_HEADERS_KEY]` with the raw request
60+
* headers. Auto-forwarded from the v1.0 `restHandler` options when
61+
* this router is mounted via `legacyCompat: { enabled: true }`.
62+
*/
63+
contextBuilder?: ServerCallContextBuilder;
5264
}
5365

5466
/** Converts JSON parse errors from `express.json()` to v0.3-shaped 400 responses. */
@@ -118,11 +130,13 @@ export function legacyRestRouter(options: LegacyRestHandlerOptions): RequestHand
118130
const buildContext = async (req: Request): Promise<ServerCallContext> => {
119131
const user = await options.userBuilder(req);
120132
const requestedVersion = req.header(A2A_VERSION_HEADER) || A2A_LEGACY_PROTOCOL_VERSION;
121-
const context = new ServerCallContext({
122-
requestedExtensions: Extensions.parseServiceParameter(
133+
const ctxBuilder = options.contextBuilder ?? defaultServerCallContextBuilder;
134+
const context = ctxBuilder({
135+
extensions: Extensions.parseServiceParameter(
123136
req.header(LEGACY_HTTP_EXTENSION_HEADER) ?? req.header(HTTP_EXTENSION_HEADER)
124137
),
125138
user,
139+
headers: req.headers,
126140
requestedVersion,
127141
tenant: (req.params.tenant as string) || undefined,
128142
});

src/compat/v0_3/server/grpc/grpc_service.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import {
3333
} from '../../grpc/pb/a2a.js';
3434
import { Empty } from '../../grpc/pb/google/protobuf/empty.js';
3535
import { A2ARequestHandler } from '../../../../server/request_handler/a2a_request_handler.js';
36-
import { ServerCallContext } from '../../../../server/context.js';
36+
import {
37+
ServerCallContext,
38+
ServerCallContextBuilder,
39+
defaultServerCallContextBuilder,
40+
} from '../../../../server/context.js';
3741
import { Extensions } from '../../../../extensions.js';
3842
import { UserBuilder } from './common.js';
3943
import { A2A_VERSION_HEADER, HTTP_EXTENSION_HEADER } from '../../../../constants.js';
@@ -78,10 +82,18 @@ import type {
7882
TaskPushNotificationConfig as V1TaskPushNotificationConfig,
7983
} from '../../../../types/pb/a2a.js';
8084

81-
/** Same shape as v1.0 `GrpcServiceOptions`. */
85+
/** Options for the legacy v0.3 gRPC service handler. */
8286
export interface LegacyGrpcServiceOptions {
8387
requestHandler: A2ARequestHandler;
8488
userBuilder: UserBuilder;
89+
/**
90+
* Custom builder for the per-request {@link ServerCallContext}. When
91+
* omitted, {@link defaultServerCallContextBuilder} is used, which
92+
* populates `context.state[STATE_HEADERS_KEY]` with the raw request
93+
* metadata. Provide the same builder passed to the v1.0 `grpcService`
94+
* so v0.3 traffic sees the same context shape.
95+
*/
96+
contextBuilder?: ServerCallContextBuilder;
8597
}
8698

8799
/**
@@ -107,7 +119,12 @@ export function legacyGrpcService(options: LegacyGrpcServiceOptions): A2AService
107119
converter: (res: TCoreRes) => TPbRes
108120
): Promise<void> => {
109121
try {
110-
const context = await _buildContext(call, options.userBuilder, requestHandler);
122+
const context = await _buildContext(
123+
call,
124+
options.userBuilder,
125+
requestHandler,
126+
options.contextBuilder
127+
);
111128
const coreRequest = parser(call.request);
112129
const result = await handler(coreRequest, context);
113130
call.sendMetadata(buildMetadata(context));
@@ -126,7 +143,12 @@ export function legacyGrpcService(options: LegacyGrpcServiceOptions): A2AService
126143
converter: (res: TCoreRes) => TPbRes
127144
): Promise<void> => {
128145
try {
129-
const context = await _buildContext(call, options.userBuilder, requestHandler);
146+
const context = await _buildContext(
147+
call,
148+
options.userBuilder,
149+
requestHandler,
150+
options.contextBuilder
151+
);
130152
const coreRequest = parser(call.request);
131153
const stream = handler(coreRequest, context);
132154
call.sendMetadata(buildMetadata(context));
@@ -461,7 +483,8 @@ const mapToError = (error: unknown): Partial<grpc.ServiceError> => {
461483
const _buildContext = async (
462484
call: grpc.ServerUnaryCall<unknown, unknown> | grpc.ServerWritableStream<unknown, unknown>,
463485
userBuilder: UserBuilder,
464-
requestHandler: A2ARequestHandler
486+
requestHandler: A2ARequestHandler,
487+
contextBuilder?: ServerCallContextBuilder
465488
): Promise<ServerCallContext> => {
466489
const user = await userBuilder(call);
467490
// Accept both v0.3's `X-A2A-Extensions` and v1.0's `A2A-Extensions`.
@@ -475,9 +498,17 @@ const _buildContext = async (
475498
const versionHeaders = call.metadata.get(A2A_VERSION_HEADER.toLowerCase());
476499
const requestedVersion = versionHeaders.length > 0 ? versionHeaders[0].toString() : undefined;
477500

478-
const context = new ServerCallContext({
479-
requestedExtensions: Extensions.parseServiceParameter(extensionString),
501+
// Convert gRPC metadata to the transport-agnostic RequestHeaders shape.
502+
const headers: Record<string, string | string[] | undefined> = {};
503+
for (const [key, value] of Object.entries(call.metadata.getMap())) {
504+
headers[key] = value.toString();
505+
}
506+
507+
const ctxBuilder = contextBuilder ?? defaultServerCallContextBuilder;
508+
const context = ctxBuilder({
509+
extensions: Extensions.parseServiceParameter(extensionString),
480510
user,
511+
headers,
481512
requestedVersion,
482513
});
483514

test/compat/v0_3/server/grpc/grpc_service.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
type Task as V1Task,
1717
} from '../../../../../src/types/pb/a2a.js';
1818
import { decodeErrorInfo, decodeStatus } from '../../../../../src/errors/grpc/index.js';
19+
import {
20+
STATE_HEADERS_KEY,
21+
ServerCallContextBuilder,
22+
defaultServerCallContextBuilder,
23+
} from '../../../../../src/server/context.js';
1924

2025
// v0.3 GRPC interface so validateVersion accepts the defaulted '0.3'.
2126
const testAgentCard: V1AgentCard = {
@@ -508,6 +513,89 @@ describe('legacyGrpcService', () => {
508513
});
509514
});
510515

516+
describe('context builder', () => {
517+
// Regression test: a custom `contextBuilder` set on the
518+
// v0.3 compat gRPC service used to be silently dropped; the service
519+
// hard-coded `new ServerCallContext(...)` and ignored operator hooks.
520+
521+
it('invokes the operator-supplied contextBuilder on each call', async () => {
522+
const contextBuilder = vi.fn(defaultServerCallContextBuilder);
523+
const svc = legacyGrpcService({
524+
requestHandler: mockRequestHandler,
525+
userBuilder: async () => ({ id: 'test-user' }) as any,
526+
contextBuilder,
527+
});
528+
(mockRequestHandler.getTask as Mock).mockResolvedValue(v1Task('t-1'));
529+
530+
const call = createMockUnaryCall({ name: 'tasks/t-1', historyLength: 0 });
531+
const callback = vi.fn();
532+
await svc.getTask(call, callback);
533+
534+
expect(contextBuilder).toHaveBeenCalledTimes(1);
535+
const opts = contextBuilder.mock.calls[0]![0];
536+
expect(opts.requestedVersion).to.equal('0.3');
537+
// Metadata (converted to the transport-agnostic RequestHeaders
538+
// shape) must be forwarded so the operator can key off it and
539+
// so the default builder's header-stashing keeps working.
540+
assert.isDefined(opts.headers);
541+
expect(opts.headers[A2A_VERSION_HEADER.toLowerCase()]).to.equal('0.3');
542+
});
543+
544+
it('lets a custom contextBuilder inject tenant into context.state', async () => {
545+
// Mirrors the reporter's use case: pull a tenant identifier off
546+
// a metadata header and stash it in `state` so the TaskStore /
547+
// PushNotificationStore downstream can read it.
548+
const tenantBuilder: ServerCallContextBuilder = (opts) => {
549+
const ctx = defaultServerCallContextBuilder(opts);
550+
const tenantHeader = opts.headers['x-tenant-id'];
551+
ctx.state.set('tenantId', typeof tenantHeader === 'string' ? tenantHeader : undefined);
552+
return ctx;
553+
};
554+
const svc = legacyGrpcService({
555+
requestHandler: mockRequestHandler,
556+
userBuilder: async () => ({ id: 'test-user' }) as any,
557+
contextBuilder: tenantBuilder,
558+
});
559+
let observedTenant: unknown;
560+
(mockRequestHandler.getTask as Mock).mockImplementation(async (_req, ctx) => {
561+
observedTenant = ctx.state.get('tenantId');
562+
return v1Task('t-tenant');
563+
});
564+
565+
const call = createMockUnaryCall(
566+
{ name: 'tasks/t-tenant', historyLength: 0 },
567+
{ 'x-tenant-id': 'acme' }
568+
);
569+
const callback = vi.fn();
570+
await svc.getTask(call, callback);
571+
572+
expect(observedTenant).to.equal('acme');
573+
});
574+
575+
it('falls back to the default builder when contextBuilder is omitted', async () => {
576+
// Without a custom builder, `defaultServerCallContextBuilder`
577+
// must still run — this pre-populates `context.state[STATE_HEADERS_KEY]`
578+
// with the raw metadata, mirroring the v1.0 gRPC path exactly.
579+
let observedHeaders: unknown;
580+
(mockRequestHandler.getTask as Mock).mockImplementation(async (_req, ctx) => {
581+
observedHeaders = ctx.state.get(STATE_HEADERS_KEY);
582+
return v1Task('t-default');
583+
});
584+
585+
const call = createMockUnaryCall(
586+
{ name: 'tasks/t-default', historyLength: 0 },
587+
{ 'x-custom': 'v' }
588+
);
589+
const callback = vi.fn();
590+
await handler.getTask(call, callback);
591+
592+
assert.isDefined(observedHeaders);
593+
const headers = observedHeaders as Record<string, string>;
594+
expect(headers[A2A_VERSION_HEADER.toLowerCase()]).to.equal('0.3');
595+
expect(headers['x-custom']).to.equal('v');
596+
});
597+
});
598+
511599
describe('version validation', () => {
512600
it('rejects requests for a v1.0 version when the agent only declares v0.3 gRPC', async () => {
513601
const call = createMockUnaryCall(

0 commit comments

Comments
 (0)