From 3675bbf6e855451c431146f7df8df075bf9f160a Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 28 Aug 2026 13:14:39 +0200 Subject: [PATCH 01/10] feat(config): add a2a protocol configuration and exposure --- config.example.yaml | 5 ++ demo/dashboard/src/lib/types.ts | 3 + src/config/schema.ts | 76 ++++++++++++++++-- src/core/domain/common.ts | 2 +- tests/integration/mcp-over-gateway.test.ts | 12 ++- tests/unit/cli/doctor.test.ts | 6 +- tests/unit/cli/fixtures.ts | 1 + tests/unit/cli/validate.test.ts | 6 +- tests/unit/config/schema.test.ts | 91 +++++++++++++++++++++- tests/unit/gateway/helpers.ts | 6 +- 10 files changed, 192 insertions(+), 16 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 87eae34..0c25c19 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -44,6 +44,11 @@ protocols: mcp: enabled: true mountPath: /mcp + # A2A (Agent2Agent) is experimental and off by default. When enabled the + # adapter also serves the spec-fixed /.well-known/agent-card.json. + a2a: + enabled: false + mountPath: /a2a resources: # --- free resource: proves the gateway fronts an existing API ------------- diff --git a/demo/dashboard/src/lib/types.ts b/demo/dashboard/src/lib/types.ts index e60d904..e0c81eb 100644 --- a/demo/dashboard/src/lib/types.ts +++ b/demo/dashboard/src/lib/types.ts @@ -70,6 +70,9 @@ export interface WellKnownDocument { readonly protocols: { readonly http: { readonly enabled: boolean }; readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; + // Optional here, not in the gateway config: this mirrors a wire document + // that an older gateway may not carry. + readonly a2a?: { readonly enabled: boolean; readonly mountPath: string }; }; readonly adapters: readonly AdapterWithHealth[]; readonly paymentProviders: readonly AdapterDescriptor[]; diff --git a/src/config/schema.ts b/src/config/schema.ts index 4ca2624..d881a9e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -113,15 +113,19 @@ const StorageSchema = z .strict(); /** - * Every path the gateway registers itself (`src/gateway/routes.ts`). An MCP - * mount that equals one of these makes Fastify's `.all()` a duplicate of the - * registered route; one that is a path-prefix of them swallows their 404s - * through the mount's `${mountPath}/*` wildcard. + * Every path the gateway registers itself (`src/gateway/routes.ts`), plus the + * fixed discovery paths adapters own. An adapter mount that equals one of + * these makes Fastify's `.all()` a duplicate of the registered route; one that + * is a path-prefix of them swallows their 404s through the mount's + * `${mountPath}/*` wildcard. */ const RESERVED_GATEWAY_PATHS = [ '/health', '/ready', '/.well-known/agent-commerce', + // Fixed by the A2A specification, so it is the adapter's to serve and never + // a configurable mount's to claim. + '/.well-known/agent-card.json', '/api/resources', '/api/resources/:id/invoke', '/api/receipts', @@ -137,7 +141,7 @@ const RESERVED_GATEWAY_PATHS = [ * Fastify pattern syntax (`:param`, `*`) is rejected rather than supported: * the mount registers its own wildcard, so a pattern here has no meaning. */ -const McpMountPathSchema = z +const MountPathSchema = z .string() .min(1) .refine((value) => value.startsWith('/'), { @@ -165,12 +169,24 @@ const ProtocolsSchema = z mcp: z .object({ enabled: BooleanOrString, - mountPath: McpMountPathSchema, + mountPath: MountPathSchema, }) .strict(), + // Optional block: A2A is off unless an operator asks for it, and an + // existing config predating the adapter stays valid. + a2a: z + .object({ + enabled: BooleanOrString, + mountPath: MountPathSchema.optional(), + }) + .strict() + .optional(), }) .strict(); +/** Applied when `protocols.a2a` is absent or names no mount. */ +const DEFAULT_A2A_MOUNT_PATH = '/a2a'; + const BackendMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); const BackendHandlerSchema = z @@ -314,6 +330,7 @@ export interface GatewayConfig { readonly protocols: { readonly http: { readonly enabled: boolean }; readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; + readonly a2a: { readonly enabled: boolean; readonly mountPath: string }; }; /** Canonical resources, already normalised. */ readonly resources: readonly CommerceResource[]; @@ -466,7 +483,7 @@ function toBoolean(value: boolean | string, path: string): boolean { // Business-rule validation + normalisation into the canonical shape. // --------------------------------------------------------------------------- -const SUPPORTED_PROTOCOLS = new Set(['http', 'mcp']); +const SUPPORTED_PROTOCOLS = new Set(['http', 'mcp', 'a2a']); const SUPPORTED_PAYMENT_METHODS = new Set(['x402']); function normalise(raw: RawConfig): GatewayConfig { @@ -476,7 +493,12 @@ function normalise(raw: RawConfig): GatewayConfig { enabled: toBoolean(raw.protocols.mcp.enabled, 'protocols.mcp.enabled'), mountPath: raw.protocols.mcp.mountPath, }, + a2a: { + enabled: toBoolean(raw.protocols.a2a?.enabled ?? false, 'protocols.a2a.enabled'), + mountPath: raw.protocols.a2a?.mountPath ?? DEFAULT_A2A_MOUNT_PATH, + }, }; + validateMountPaths(protocols); const x402Raw = raw.payments.x402; const facilitator: X402FacilitatorConfig | undefined = @@ -576,6 +598,37 @@ function normalise(raw: RawConfig): GatewayConfig { interface NormalisedProtocols { readonly http: { readonly enabled: boolean }; readonly mcp: { readonly enabled: boolean; readonly mountPath: string }; + readonly a2a: { readonly enabled: boolean; readonly mountPath: string }; +} + +/** + * Two enabled mounts may not overlap: each registers a `${mountPath}/*` + * wildcard, so a shared prefix means one adapter silently answers for the + * other. Disabled protocols mount nothing and are not compared. + */ +function validateMountPaths(protocols: NormalisedProtocols): void { + const mounts = ( + [ + ['mcp', protocols.mcp], + ['a2a', protocols.a2a], + ] as const + ).filter(([, p]) => p.enabled); + + for (let i = 0; i < mounts.length; i += 1) { + for (let j = i + 1; j < mounts.length; j += 1) { + const [nameA, a] = mounts[i]!; + const [nameB, b] = mounts[j]!; + const baseA = a.mountPath.replace(/\/+$/, ''); + const baseB = b.mountPath.replace(/\/+$/, ''); + if (baseA === baseB || baseA.startsWith(`${baseB}/`) || baseB.startsWith(`${baseA}/`)) { + throw new CommerceError( + 'CONFIG_INVALID', + `protocols.${nameA}.mountPath ("${a.mountPath}") collides with protocols.${nameB}.mountPath ("${b.mountPath}") — each mount registers a wildcard, so overlapping prefixes cannot both be served`, + { details: { path: `protocols.${nameA}.mountPath` } }, + ); + } + } + } } interface NormalisedX402 { @@ -614,7 +667,7 @@ function normaliseResource( const hint = protocol === 'ucp' ? ' (UCP is planned, not supported in this release)' : ''; throw new CommerceError( 'CONFIG_INVALID', - `Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: http, mcp.`, + `Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: http, mcp, a2a.`, { details: { path: `resources.${id}.expose`, resourceId: id, protocol } }, ); } @@ -635,6 +688,13 @@ function normaliseResource( ); } } + if (entry.expose.includes('a2a') && !protocols.a2a.enabled) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" is exposed via "a2a" but protocols.a2a.enabled is false`, + { details: { path: `resources.${id}.expose`, resourceId: id } }, + ); + } if (entry.expose.includes('http') && !protocols.http.enabled) { throw new CommerceError( 'CONFIG_INVALID', diff --git a/src/core/domain/common.ts b/src/core/domain/common.ts index ed715cd..34c3716 100644 --- a/src/core/domain/common.ts +++ b/src/core/domain/common.ts @@ -15,7 +15,7 @@ export type JsonSchema = Record; /** Protocol surfaces a resource can be exposed through in this release. */ -export type ProtocolName = 'http' | 'mcp'; +export type ProtocolName = 'http' | 'mcp' | 'a2a'; /** Payment methods a resource can accept in this release. */ export type PaymentMethodName = 'x402'; diff --git a/tests/integration/mcp-over-gateway.test.ts b/tests/integration/mcp-over-gateway.test.ts index 252ef00..2975379 100644 --- a/tests/integration/mcp-over-gateway.test.ts +++ b/tests/integration/mcp-over-gateway.test.ts @@ -122,7 +122,11 @@ describe('MCP over the real gateway (Fastify body-parsing regression)', () => { merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, - protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + }, resources: [ { id: 'weather_basic', @@ -181,7 +185,11 @@ describe('MCP over the real gateway (Fastify body-parsing regression)', () => { merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, - protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + }, resources: [], payments: {}, }, diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 06c0ca1..3e7f738 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -700,7 +700,11 @@ describe('runDoctor — additional derivation and error-recovery branches', () = fetchImpl: healthyFetch(), loadConfig: async () => makeGatewayConfig({ - protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: false, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + }, }), createStore: () => makeFakeReceiptStore(), }, diff --git a/tests/unit/cli/fixtures.ts b/tests/unit/cli/fixtures.ts index 7c02102..6183d6c 100644 --- a/tests/unit/cli/fixtures.ts +++ b/tests/unit/cli/fixtures.ts @@ -33,6 +33,7 @@ export function makeGatewayConfig(overrides: Partial = {}): Gatew protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, }, resources: [makeResource()], payments: {}, diff --git a/tests/unit/cli/validate.test.ts b/tests/unit/cli/validate.test.ts index da161c7..9a34376 100644 --- a/tests/unit/cli/validate.test.ts +++ b/tests/unit/cli/validate.test.ts @@ -85,7 +85,11 @@ describe('runValidate — with an injected loader (isolated branch coverage)', ( await runValidate({}, io, { loadConfig: async () => makeGatewayConfig({ - protocols: { http: { enabled: false }, mcp: { enabled: false, mountPath: '/mcp' } }, + protocols: { + http: { enabled: false }, + mcp: { enabled: false, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + }, }), }); expect(io.out.join('\n')).toContain('protocols: http=off mcp=off'); diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index 2ca8334..3ddab03 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -965,7 +965,7 @@ describe('parseConfig', () => { } }); - it('rejects an expose value outside [http, mcp], mentioning UCP is planned', () => { + it('rejects an expose value outside [http, mcp, a2a], mentioning UCP is planned', () => { const raw = validRawConfig(); (raw['resources'] as { weather_basic: { expose: string[] } }).weather_basic.expose = [ 'http', @@ -986,7 +986,7 @@ describe('parseConfig', () => { const raw = validRawConfig(); (raw['resources'] as { weather_basic: { expose: string[] } }).weather_basic.expose = [ 'http', - 'a2a', + 'grpc', ]; expectConfigInvalid(() => parseConfig(raw, {})); try { @@ -1378,3 +1378,90 @@ describe('protocols.mcp.mountPath', () => { expect(config.protocols.mcp.mountPath).toBe('/agents/mcp'); }); }); + +describe('protocols.a2a', () => { + function withA2a(a2a: unknown): Record { + const raw = validRawConfig(); + if (a2a === undefined) delete (raw['protocols'] as Record)['a2a']; + else (raw['protocols'] as Record)['a2a'] = a2a; + return raw; + } + + it('is disabled on the default mount when the block is absent', () => { + const config = parseConfig(withA2a(undefined), {}); + expect(config.protocols.a2a).toEqual({ enabled: false, mountPath: '/a2a' }); + }); + + it('applies the default mount when the block names no mountPath', () => { + const config = parseConfig(withA2a({ enabled: true }), {}); + expect(config.protocols.a2a).toEqual({ enabled: true, mountPath: '/a2a' }); + }); + + it('accepts a custom mount', () => { + const config = parseConfig(withA2a({ enabled: true, mountPath: '/agents/a2a' }), {}); + expect(config.protocols.a2a.mountPath).toBe('/agents/a2a'); + }); + + it.each([ + ['no leading slash', 'a2a'], + ['a Fastify parameter', '/a2a/:id'], + ['whitespace', '/a2a path'], + ['a route the gateway serves', '/health'], + ])('rejects a malformed mount: %s', (_label, mountPath) => { + expectConfigInvalid(() => parseConfig(withA2a({ enabled: true, mountPath }), {})); + }); + + // The card path is fixed by the A2A spec and served by the adapter itself. + it.each([ + ['the agent card path itself', '/.well-known/agent-card.json'], + ['a prefix of it', '/.well-known'], + ])('rejects %s as a configurable mount', (_label, mountPath) => { + expectConfigInvalid(() => parseConfig(withA2a({ enabled: true, mountPath }), {})); + const raw = validRawConfig(); + (raw['protocols'] as { mcp: Record }).mcp['mountPath'] = mountPath; + expectConfigInvalid(() => parseConfig(raw, {})); + }); + + it('rejects an unknown key inside the block', () => { + expectConfigInvalid(() => parseConfig(withA2a({ enabled: true, streaming: true }), {})); + }); + + it('accepts expose: [a2a] when enabled', () => { + const raw = withA2a({ enabled: true }); + (raw['resources'] as { weather_basic: { expose: string[] } }).weather_basic.expose = [ + 'http', + 'a2a', + ]; + const config = parseConfig(raw, {}); + expect(config.resources.find((r) => r.id === 'weather_basic')?.exposedVia).toEqual([ + 'http', + 'a2a', + ]); + }); + + it('rejects expose: [a2a] when protocols.a2a.enabled is false', () => { + const raw = withA2a({ enabled: false }); + (raw['resources'] as { weather_basic: { expose: string[] } }).weather_basic.expose = ['a2a']; + expectConfigInvalid(() => parseConfig(raw, {})); + try { + parseConfig(raw, {}); + } catch (error) { + if (isCommerceError(error)) expect(error.message).toContain('a2a'); + } + }); + + // Each mount registers a `${mountPath}/*` wildcard, so an overlap means one + // adapter answers for the other. + it.each([ + ['an identical mount', '/mcp'], + ['a mount nested under the mcp one', '/mcp/a2a'], + ['a mount the mcp one nests under', '/'], + ])('rejects %s while mcp is enabled', (_label, mountPath) => { + expectConfigInvalid(() => parseConfig(withA2a({ enabled: true, mountPath }), {})); + }); + + it('allows a colliding mount while a2a is disabled, since nothing is mounted', () => { + const config = parseConfig(withA2a({ enabled: false, mountPath: '/mcp' }), {}); + expect(config.protocols.a2a.enabled).toBe(false); + }); +}); diff --git a/tests/unit/gateway/helpers.ts b/tests/unit/gateway/helpers.ts index df232f9..bfbb1df 100644 --- a/tests/unit/gateway/helpers.ts +++ b/tests/unit/gateway/helpers.ts @@ -212,7 +212,11 @@ export function makeGatewayConfig(overrides: Partial = {}): Gatew merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, - protocols: { http: { enabled: true }, mcp: { enabled: true, mountPath: '/mcp' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: false, mountPath: '/a2a' }, + }, resources: [ { id: 'weather_basic', From 255be1987f3d4ef057300eef5b2bee71fcf9d661 Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 28 Aug 2026 15:54:35 +0200 Subject: [PATCH 02/10] refactor(gateway): support adapter-owned discovery routes --- docs/contract-surface.txt | 9 +- docs/contracts.md | 2 + src/config/schema.ts | 6 +- src/core/interfaces/protocol-adapter.ts | 21 +++ src/core/public-types.ts | 1 + src/gateway/adapters.ts | 160 ++++++++++++----- tests/unit/gateway/adapters.test.ts | 227 +++++++++++++++++++++++- 7 files changed, 378 insertions(+), 48 deletions(-) diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt index 2787153..325d86a 100644 --- a/docs/contract-surface.txt +++ b/docs/contract-surface.txt @@ -1,6 +1,6 @@ # Semantic surface of src/core/public-types.ts # Generated by scripts/contract-surface.mjs — do not edit by hand. -# 69 exported symbols. +# 70 exported symbols. interface AdapterDescriptor { readonly capabilities: ReadonlyArray; @@ -19,6 +19,12 @@ interface AdapterHealth { readonly status: "pass" | "warn" | "fail"; } +interface AdapterHttpRoute { + handleHttp: (req: IncomingMessage, res: ServerResponse) => Promise; + readonly method: "GET" | "POST"; + readonly path: string; + } + interface BackendExecutor { call: (handler: BackendHandler, request: BackendRequest) => Promise; } @@ -156,6 +162,7 @@ interface ExecutionPipeline { interface HttpProtocolAdapter { handleHttp: (req: IncomingMessage, res: ServerResponse) => Promise; health: () => Promise; + readonly additionalHttpRoutes?: ReadonlyArray; readonly descriptor: AdapterDescriptor; readonly mountPath: string; readonly name: ProtocolName; diff --git a/docs/contracts.md b/docs/contracts.md index 0716cc3..fe30ea3 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -72,6 +72,8 @@ the generated file is right and this table is stale. - **Value change + additive (x402 v2):** `PAYMENT_HEADER` is now `payment-signature` and `PAYMENT_RESPONSE_HEADER` is now `payment-response`, matching the x402 v2 HTTP binding; the v1 `x-payment` / `x-payment-response` pair is no longer accepted. New `PAYMENT_REQUIRED_HEADER` (`payment-required`) carries the base64 challenge on a 402. Wire-breaking by definition, and safe only because no release exists yet. - **Additive:** `PaymentChallenge.envelope` and `PaymentRequiredEnvelope.payment.envelope` — the provider's own challenge document, verbatim (x402 v2's `PaymentRequired`). `accepts` is the offer list inside it; the envelope also carries the protocol version and the resource description that v1 kept per-requirement. Built once by the provider so the HTTP and MCP surfaces cannot describe different challenges. - **Type change (x402, non-frozen surface):** `X402ProviderOptions.facilitator` is now `X402FacilitatorConfig`; `mode: 'remote'` gained a required `auth`, and `allowMainnet` was added. `mode: 'remote'` previously parsed but was rejected at config load and threw `PROTOCOL_UNSUPPORTED` at request time, so no working configuration changes shape. +- **Additive:** `ProtocolName` gains `'a2a'`; config gains `protocols.a2a` (disabled by default, mount `/a2a`) and accepts `expose: [a2a]`. +- **Additive:** `AdapterHttpRoute` and the optional `HttpProtocolAdapter.additionalHttpRoutes`. A protocol whose specification pins a discovery URL outside the adapter's mount (A2A's `/.well-known/agent-card.json`) declares it instead of the gateway growing a per-protocol route conditional. Fixed routes get the mount's guarantees — unconsumed body, concurrency cap, failure isolation — and two adapters claiming one path is rejected before either starts. - **Removed from the wire:** `/.well-known/agent-commerce` no longer publishes `payments.x402.facilitator.url`. A facilitator endpoint can carry a tenant path or an API key, exactly like `rpcUrl`, which the same route already withholds. It gained `payments.x402.mode` (`local` | `testnet` | `mainnet`) instead — chain id 84532 belongs to both the local dev chain and public Base Sepolia, so the network id alone cannot say which one a client is talking to. --- diff --git a/src/config/schema.ts b/src/config/schema.ts index d881a9e..3fca809 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -614,10 +614,8 @@ function validateMountPaths(protocols: NormalisedProtocols): void { ] as const ).filter(([, p]) => p.enabled); - for (let i = 0; i < mounts.length; i += 1) { - for (let j = i + 1; j < mounts.length; j += 1) { - const [nameA, a] = mounts[i]!; - const [nameB, b] = mounts[j]!; + for (const [index, [nameA, a]] of mounts.entries()) { + for (const [nameB, b] of mounts.slice(index + 1)) { const baseA = a.mountPath.replace(/\/+$/, ''); const baseB = b.mountPath.replace(/\/+$/, ''); if (baseA === baseB || baseA.startsWith(`${baseB}/`) || baseB.startsWith(`${baseA}/`)) { diff --git a/src/core/interfaces/protocol-adapter.ts b/src/core/interfaces/protocol-adapter.ts index 9cd9909..07eb06d 100644 --- a/src/core/interfaces/protocol-adapter.ts +++ b/src/core/interfaces/protocol-adapter.ts @@ -67,6 +67,27 @@ export interface HttpProtocolAdapter extends ProtocolAdapter { /** Path prefix the gateway mounts this adapter at, e.g. '/mcp'. */ readonly mountPath: string; handleHttp(req: IncomingMessage, res: ServerResponse): Promise; + /** + * Fixed paths this adapter owns *outside* its mount, for protocols whose + * specification pins a discovery URL (A2A's `/.well-known/agent-card.json`). + * The gateway mounts them with the same guarantees as `mountPath` — same + * unconsumed body, same concurrency cap, same failure isolation — so the + * gateway needs no per-protocol routing knowledge. + * + * Omitted by adapters that need none. + */ + readonly additionalHttpRoutes?: readonly AdapterHttpRoute[]; +} + +/** + * One fixed, method-scoped route owned by an adapter. Unlike `mountPath` it + * registers no wildcard: it matches exactly the path given. + */ +export interface AdapterHttpRoute { + readonly method: 'GET' | 'POST'; + /** Absolute gateway path, e.g. '/.well-known/agent-card.json'. */ + readonly path: string; + handleHttp(req: IncomingMessage, res: ServerResponse): Promise; } export function isHttpProtocolAdapter(adapter: ProtocolAdapter): adapter is HttpProtocolAdapter { diff --git a/src/core/public-types.ts b/src/core/public-types.ts index 302f258..1ee5a43 100644 --- a/src/core/public-types.ts +++ b/src/core/public-types.ts @@ -87,6 +87,7 @@ export type { BackendExecutor, BackendRequest, BackendResponse } from './interfa export type { Logger } from './interfaces/logger.js'; export { NOOP_LOGGER } from './interfaces/logger.js'; export type { + AdapterHttpRoute, HttpProtocolAdapter, ProtocolAdapter, ProtocolAdapterContext, diff --git a/src/gateway/adapters.ts b/src/gateway/adapters.ts index d945877..be3c46b 100644 --- a/src/gateway/adapters.ts +++ b/src/gateway/adapters.ts @@ -71,6 +71,12 @@ export interface StartAndMountOptions { export async function startAndMountAdapters( options: StartAndMountOptions, ): Promise { + // Before anything starts: two adapters claiming the same path is a + // composition-root bug, not a runtime condition. Fastify would only notice + // it inside deferred route registration and fail `server.ready()` with an + // opaque FST_ERR_DUPLICATED_ROUTE naming neither adapter. + assertNoRouteConflicts(options.adapters); + const runtimes: AdapterRuntime[] = []; for (const adapter of options.adapters) { @@ -104,6 +110,61 @@ export async function startAndMountAdapters( return runtimes; } +interface RouteClaim { + readonly adapter: string; + readonly path: string; + /** A mount also owns everything below its path, through its `/*` wildcard. */ + readonly wildcard: boolean; + readonly method: 'ALL' | 'GET' | 'POST'; +} + +function routeClaims(adapters: readonly ProtocolAdapter[]): RouteClaim[] { + const claims: RouteClaim[] = []; + for (const adapter of adapters) { + if (!isHttpProtocolAdapter(adapter)) continue; + claims.push({ + adapter: adapter.name, + path: adapter.mountPath.replace(/\/+$/, ''), + wildcard: true, + method: 'ALL', + }); + for (const route of adapter.additionalHttpRoutes ?? []) { + claims.push({ + adapter: adapter.name, + path: route.path.replace(/\/+$/, ''), + wildcard: false, + method: route.method, + }); + } + } + return claims; +} + +function claimsCollide(a: RouteClaim, b: RouteClaim): boolean { + if (a.wildcard && (b.path === a.path || b.path.startsWith(`${a.path}/`))) return true; + if (b.wildcard && a.path.startsWith(`${b.path}/`)) return true; + return a.path === b.path && (a.method === b.method || a.method === 'ALL' || b.method === 'ALL'); +} + +/** + * Only *cross-adapter* claims conflict. An adapter serving a fixed route under + * its own mount is fine — Fastify prefers a static route over a wildcard — and + * is how a protocol that pins a sub-path stays self-contained. + */ +function assertNoRouteConflicts(adapters: readonly ProtocolAdapter[]): void { + const claims = routeClaims(adapters); + for (const [index, a] of claims.entries()) { + for (const b of claims.slice(index + 1)) { + if (a.adapter === b.adapter || !claimsCollide(a, b)) continue; + throw new CommerceError( + 'CONFIG_INVALID', + `Protocol adapters "${a.adapter}" and "${b.adapter}" both claim the HTTP path "${b.path}"; each adapter path must be served by exactly one adapter`, + { details: { adapters: [a.adapter, b.adapter], path: b.path } }, + ); + } + } +} + /** * The body limit and the per-tool-call semaphore * (`protocol-mcp`'s MAX_CONCURRENT_TOOL_CALLS) both bound *what happens @@ -130,6 +191,10 @@ function mountHttpAdapter( ): void { const mountPath = adapter.mountPath; const wildcard = mountPath.endsWith('/') ? `${mountPath}*` : `${mountPath}/*`; + const additionalRoutes = adapter.additionalHttpRoutes ?? []; + // One counter for the whole adapter, mount and fixed routes alike: the cap + // bounds what this adapter can be made to parse concurrently, and a second + // door into the same adapter would be a way around it. let inFlight = 0; void server.register(async (instance) => { @@ -148,52 +213,63 @@ function mountHttpAdapter( done(null); }); - const handler = async (request: FastifyRequest, reply: FastifyReply): Promise => { - if (inFlight >= MOUNT_MAX_CONCURRENT_REQUESTS) { - // Reject before the SDK ever sees the body — the whole point is to - // never let a request past this line start the parse that spikes - // memory. No body-limit enforcement needed either: nothing here has - // read a byte of it yet. - const error = new CommerceError( - 'GATEWAY_BUSY', - 'Too many requests already parsing on this mount; retry shortly.', - ); - reply - .status(error.httpStatus) - .header('retry-after', String(MOUNT_BUSY_RETRY_AFTER_SECONDS)) - .send(toErrorEnvelope(error)); - return; - } - inFlight += 1; - try { - const stopEnforcing = enforceMountBodyLimit( - request.raw, - reply.raw, - MOUNT_BODY_LIMIT_BYTES, - logger, - ); + const makeHandler = + (handleHttp: (req: IncomingMessage, res: ServerResponse) => Promise) => + async (request: FastifyRequest, reply: FastifyReply): Promise => { + if (inFlight >= MOUNT_MAX_CONCURRENT_REQUESTS) { + // Reject before the SDK ever sees the body — the whole point is to + // never let a request past this line start the parse that spikes + // memory. No body-limit enforcement needed either: nothing here has + // read a byte of it yet. + const error = new CommerceError( + 'GATEWAY_BUSY', + 'Too many requests already parsing on this mount; retry shortly.', + ); + reply + .status(error.httpStatus) + .header('retry-after', String(MOUNT_BUSY_RETRY_AFTER_SECONDS)) + .send(toErrorEnvelope(error)); + return; + } + inFlight += 1; try { - await adapter.handleHttp(request.raw, reply.raw); - } catch (error) { - logger.error({ err: describeError(error) }, 'Protocol adapter request handler threw'); - if (!reply.raw.headersSent) { - reply.raw.statusCode = 500; - reply.raw.end(); + const stopEnforcing = enforceMountBodyLimit( + request.raw, + reply.raw, + MOUNT_BODY_LIMIT_BYTES, + logger, + ); + try { + await handleHttp(request.raw, reply.raw); + } catch (error) { + logger.error({ err: describeError(error) }, 'Protocol adapter request handler threw'); + if (!reply.raw.headersSent) { + reply.raw.statusCode = 500; + reply.raw.end(); + } } + stopEnforcing(); + reply.hijack(); + } finally { + // Always released, even on a throw above — a stranded count would + // shrink the effective cap by one forever and eventually 503 every + // request on this mount permanently, which is worse than the + // problem this exists to solve. + inFlight -= 1; } - stopEnforcing(); - reply.hijack(); - } finally { - // Always released, even on a throw above — a stranded count would - // shrink the effective cap by one forever and eventually 503 every - // request on this mount permanently, which is worse than the - // problem this exists to solve. - inFlight -= 1; - } - }; + }; - instance.all(mountPath, handler); - instance.all(wildcard, handler); + const mountHandler = makeHandler((req, res) => adapter.handleHttp(req, res)); + instance.all(mountPath, mountHandler); + instance.all(wildcard, mountHandler); + + for (const route of additionalRoutes) { + instance.route({ + method: route.method, + url: route.path, + handler: makeHandler((req, res) => route.handleHttp(req, res)), + }); + } }); } diff --git a/tests/unit/gateway/adapters.test.ts b/tests/unit/gateway/adapters.test.ts index 8dfbc1e..5bd55ac 100644 --- a/tests/unit/gateway/adapters.test.ts +++ b/tests/unit/gateway/adapters.test.ts @@ -1,12 +1,14 @@ import * as http from 'node:http'; -import Fastify from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, expect, it, vi } from 'vitest'; import type { Clock, EventSink, ExecutionPipeline, + HttpProtocolAdapter, IdGenerator, Logger, + ProtocolAdapter, ProtocolAdapterContext, ResourceRegistry, } from '../../../src/core/index.js'; @@ -528,3 +530,226 @@ describe('startAndMountAdapters / adapter isolation', () => { expect(stopped).toEqual(['good']); }); }); + +describe('adapter-owned additional HTTP routes', () => { + /** A protocol whose spec pins a discovery URL outside its own mount. */ + function cardAdapter(overrides: Partial = {}): HttpProtocolAdapter { + return createFakeHttpAdapter({ + name: 'a2a', + mountPath: '/fake', + additionalHttpRoutes: [ + { + method: 'GET', + path: '/.well-known/fake-card.json', + handleHttp: async (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"card":true}'); + }, + }, + ], + ...overrides, + }); + } + + async function mount(adapters: readonly ProtocolAdapter[]): Promise { + const server = Fastify({ logger: false }); + await startAndMountAdapters({ + server, + adapters, + context: fakeContext(), + logger: NOOP_LOGGER, + clock, + }); + await server.ready(); + return server; + } + + it('mounts the primary mount and the fixed route', async () => { + const server = await mount([cardAdapter()]); + + expect((await server.inject({ method: 'GET', url: '/fake' })).payload).toBe( + 'fake-adapter-response', + ); + const card = await server.inject({ method: 'GET', url: '/.well-known/fake-card.json' }); + expect(card.statusCode).toBe(200); + expect(card.payload).toBe('{"card":true}'); + + await server.close(); + }); + + it('scopes a fixed route to its declared method', async () => { + const server = await mount([cardAdapter()]); + const res = await server.inject({ method: 'POST', url: '/.well-known/fake-card.json' }); + expect(res.statusCode).toBe(404); + await server.close(); + }); + + it('hands a POST body to a fixed route unconsumed, like the mount', async () => { + const sent = JSON.stringify({ hello: 'world' }); + let seen = ''; + const adapter = cardAdapter({ + additionalHttpRoutes: [ + { + method: 'POST', + path: '/.well-known/fake-card.json', + handleHttp: async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + seen = Buffer.concat(chunks).toString('utf8'); + res.writeHead(200); + res.end(); + }, + }, + ], + }); + const server = await mount([adapter]); + + const res = await server.inject({ + method: 'POST', + url: '/.well-known/fake-card.json', + headers: { 'content-type': 'application/json' }, + payload: sent, + }); + expect(res.statusCode).toBe(200); + expect(seen).toBe(sent); + + await server.close(); + }); + + it('isolates a throwing fixed-route handler, leaving the mount serving', async () => { + const adapter = cardAdapter({ + additionalHttpRoutes: [ + { + method: 'GET', + path: '/.well-known/fake-card.json', + handleHttp: async () => { + throw new Error('card generation blew up'); + }, + }, + ], + }); + const server = await mount([adapter]); + + expect( + (await server.inject({ method: 'GET', url: '/.well-known/fake-card.json' })).statusCode, + ).toBe(500); + expect((await server.inject({ method: 'GET', url: '/fake' })).statusCode).toBe(200); + + await server.close(); + }); + + it('mounts no fixed route for an adapter that failed to start', async () => { + const server = Fastify({ logger: false }); + const runtimes = await startAndMountAdapters({ + server, + adapters: [ + cardAdapter({ + start: async () => { + throw new Error('nope'); + }, + }), + createFakeHttpAdapter({ name: 'mcp', mountPath: '/mcp' }), + ], + context: fakeContext(), + logger: NOOP_LOGGER, + clock, + }); + await server.ready(); + + expect(runtimes[0]?.startFailure?.status).toBe('fail'); + expect( + (await server.inject({ method: 'GET', url: '/.well-known/fake-card.json' })).statusCode, + ).toBe(404); + expect((await server.inject({ method: 'GET', url: '/fake' })).statusCode).toBe(404); + // The healthy adapter is untouched by its neighbour's failure. + expect((await server.inject({ method: 'GET', url: '/mcp' })).statusCode).toBe(200); + + await server.close(); + }); + + // Fastify would only notice these inside deferred route registration and + // fail server.ready() with an FST_ERR_DUPLICATED_ROUTE naming no adapter. + it('rejects two adapters claiming the same fixed route, before either starts', async () => { + const server = Fastify({ logger: false }); + const started: string[] = []; + const first = cardAdapter({ start: async () => void started.push('a2a') }); + const second = createFakeHttpAdapter({ + name: 'mcp', + mountPath: '/mcp', + start: async () => void started.push('mcp'), + additionalHttpRoutes: [ + { + method: 'GET', + path: '/.well-known/fake-card.json', + handleHttp: async (_req, res) => { + res.end(); + }, + }, + ], + }); + + await expect( + startAndMountAdapters({ + server, + adapters: [first, second], + context: fakeContext(), + logger: NOOP_LOGGER, + clock, + }), + ).rejects.toMatchObject({ code: 'CONFIG_INVALID' }); + expect(started).toEqual([]); + + await server.close(); + }); + + it('rejects a fixed route swallowed by another adapter mount wildcard', async () => { + const server = Fastify({ logger: false }); + const nested = createFakeHttpAdapter({ + name: 'mcp', + mountPath: '/mcp', + additionalHttpRoutes: [ + { + method: 'GET', + path: '/fake/card', + handleHttp: async (_req, res) => { + res.end(); + }, + }, + ], + }); + + await expect( + startAndMountAdapters({ + server, + adapters: [cardAdapter(), nested], + context: fakeContext(), + logger: NOOP_LOGGER, + clock, + }), + ).rejects.toMatchObject({ code: 'CONFIG_INVALID' }); + + await server.close(); + }); + + it('allows an adapter to serve a fixed route under its own mount', async () => { + const server = await mount([ + cardAdapter({ + additionalHttpRoutes: [ + { + method: 'GET', + path: '/fake/card', + handleHttp: async (_req, res) => { + res.writeHead(200); + res.end('own-sub-route'); + }, + }, + ], + }), + ]); + + expect((await server.inject({ method: 'GET', url: '/fake/card' })).payload).toBe( + 'own-sub-route', + ); + await server.close(); + }); +}); From 17e089b6b4e4f06193945e27e89c2efcf8a68519 Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 28 Aug 2026 18:12:26 +0200 Subject: [PATCH 03/10] feat(a2a): add v1 descriptor and agent card discovery --- src/protocols/a2a/adapter.ts | 170 ++++++++++++++ src/protocols/a2a/agent-card.ts | 85 +++++++ src/protocols/a2a/constants.ts | 33 +++ src/protocols/a2a/descriptor.ts | 57 +++++ src/protocols/a2a/index.ts | 19 ++ src/protocols/a2a/types.ts | 56 +++++ tests/integration/a2a-over-gateway.test.ts | 105 +++++++++ tests/unit/protocols-a2a/agent-card.test.ts | 244 ++++++++++++++++++++ 8 files changed, 769 insertions(+) create mode 100644 src/protocols/a2a/adapter.ts create mode 100644 src/protocols/a2a/agent-card.ts create mode 100644 src/protocols/a2a/constants.ts create mode 100644 src/protocols/a2a/descriptor.ts create mode 100644 src/protocols/a2a/index.ts create mode 100644 src/protocols/a2a/types.ts create mode 100644 tests/integration/a2a-over-gateway.test.ts create mode 100644 tests/unit/protocols-a2a/agent-card.test.ts diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts new file mode 100644 index 0000000..08c8837 --- /dev/null +++ b/src/protocols/a2a/adapter.ts @@ -0,0 +1,170 @@ +/** + * A2A (Agent2Agent) protocol adapter — experimental. + * + * Serves two paths: the configured mount (`/a2a`), where the JSON-RPC endpoint + * lives, and the specification-fixed `/.well-known/agent-card.json`, declared + * through `additionalHttpRoutes` so the gateway needs no A2A-specific routing. + * + * Only the synchronous `SendMessage` path is in scope; every other A2A method + * is listed in `descriptor.unsupported` rather than half-served. This file + * never calls a merchant backend and never inspects a payment object. + */ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + type AdapterDescriptor, + type AdapterHealth, + type AdapterHttpRoute, + type CommerceResource, + type HttpProtocolAdapter, + type ProtocolAdapterContext, + toCommerceError, +} from '../../core/index.js'; +import { PACKAGE_VERSION } from '../../version.js'; +import { buildAgentCard } from './agent-card.js'; +import { + A2A_AGENT_CARD_PATH, + A2A_DEFAULT_AGENT_NAME, + A2A_DEFAULT_MOUNT_PATH, + A2A_JSON_MEDIA_TYPE, +} from './constants.js'; +import { buildDescriptor } from './descriptor.js'; +import type { A2aAgentCard } from './types.js'; + +export interface A2aAdapterOptions { + readonly mountPath?: string; + /** Agent name published on the card. */ + readonly agentName?: string; + readonly agentDescription?: string; + /** Version published on the card. Defaults to this package's version. */ + readonly agentVersion?: string; +} + +const DEFAULT_AGENT_DESCRIPTION = + 'Agent Commerce Gateway — canonical commerce resources exposed as A2A skills.'; + +export class A2aProtocolAdapter implements HttpProtocolAdapter { + readonly name = 'a2a' as const; + readonly mountPath: string; + readonly descriptor: AdapterDescriptor; + readonly additionalHttpRoutes: readonly AdapterHttpRoute[]; + + private readonly agentName: string; + private readonly agentDescription: string; + private readonly agentVersion: string; + + private context: ProtocolAdapterContext | undefined; + private started = false; + private skills: readonly CommerceResource[] = []; + // Built once at start: resources are fixed at config load, and a card + // rebuilt per request would let a discovery GET do work a caller controls + // the cost of. + private card: A2aAgentCard | undefined; + + constructor(options: A2aAdapterOptions = {}) { + this.mountPath = options.mountPath ?? A2A_DEFAULT_MOUNT_PATH; + this.agentName = options.agentName ?? A2A_DEFAULT_AGENT_NAME; + this.agentDescription = options.agentDescription ?? DEFAULT_AGENT_DESCRIPTION; + this.agentVersion = options.agentVersion ?? PACKAGE_VERSION; + this.descriptor = buildDescriptor(PACKAGE_VERSION); + this.additionalHttpRoutes = [ + { + method: 'GET', + path: A2A_AGENT_CARD_PATH, + handleHttp: (req, res) => this.handleAgentCard(req, res), + }, + ]; + } + + async start(context: ProtocolAdapterContext): Promise { + this.context = context; + this.started = false; + + let resources: readonly CommerceResource[] = []; + try { + resources = context.resources.listExposedVia('a2a'); + } catch (err) { + context.logger.error( + { err: toCommerceError(err).toInfo() }, + 'a2a adapter: failed to list a2a-exposed resources', + ); + resources = []; + } + + this.skills = resources; + this.card = buildAgentCard({ + name: this.agentName, + description: this.agentDescription, + version: this.agentVersion, + publicBaseUrl: context.publicBaseUrl, + mountPath: this.mountPath, + resources, + }); + this.started = true; + + context.logger.info( + { mountPath: this.mountPath, cardPath: A2A_AGENT_CARD_PATH, skillCount: resources.length }, + 'a2a adapter started', + ); + } + + /** `GET /.well-known/agent-card.json`. */ + async handleAgentCard(req: IncomingMessage, res: ServerResponse): Promise { + try { + if (!this.started || this.card === undefined) { + this.writeJson(res, 503, { error: 'A2A adapter is not running.' }); + return; + } + if (req.method !== 'GET') { + this.writeJson(res, 405, { error: 'Method not allowed. The Agent Card is read-only.' }); + return; + } + this.writeJson(res, 200, this.card); + } catch (err) { + this.context?.logger.error( + { err: toCommerceError(err).toInfo() }, + 'a2a adapter: agent card request failed', + ); + this.writeJson(res, 500, { error: 'Internal server error.' }); + } + } + + async handleHttp(_req: IncomingMessage, res: ServerResponse): Promise { + // The JSON-RPC endpoint arrives with the SendMessage transport. Until + // then this answers honestly rather than 404-ing a path the card + // advertises. + this.writeJsonRpcError(res, 501, 'A2A SendMessage is not implemented yet.'); + } + + async health(): Promise { + const checkedAt = this.context?.clock.nowIso() ?? new Date().toISOString(); + if (!this.started || this.card === undefined) { + return { status: 'fail', detail: 'A2A adapter has not been started.', checkedAt }; + } + return { status: 'pass', detail: `${this.skills.length} skill(s) published.`, checkedAt }; + } + + async stop(): Promise { + this.started = false; + this.card = undefined; + this.skills = []; + this.context = undefined; + } + + private writeJson(res: ServerResponse, status: number, body: unknown): void { + if (res.headersSent) return; + res.writeHead(status, { 'content-type': A2A_JSON_MEDIA_TYPE }); + res.end(JSON.stringify(body)); + } + + private writeJsonRpcError(res: ServerResponse, status: number, message: string): void { + this.writeJson(res, status, { + jsonrpc: '2.0', + id: null, + error: { code: -32601, message }, + }); + } +} + +export function createA2aAdapter(options: A2aAdapterOptions = {}): A2aProtocolAdapter { + return new A2aProtocolAdapter(options); +} diff --git a/src/protocols/a2a/agent-card.ts b/src/protocols/a2a/agent-card.ts new file mode 100644 index 0000000..d45a1cc --- /dev/null +++ b/src/protocols/a2a/agent-card.ts @@ -0,0 +1,85 @@ +/** + * Builds the A2A v1 Agent Card from canonical resources. + * + * One skill per resource exposed via `expose: [a2a]`, skill id = resource id. + * The card is a *discovery* document: it says what exists and how to reach the + * endpoint, not how to call a skill. The invocation envelope is the adapter's + * concern. + */ +import type { CommerceResource } from '../../core/index.js'; +import { A2A_JSON_MEDIA_TYPE, A2A_PROTOCOL_BINDING, A2A_PROTOCOL_VERSION } from './constants.js'; +import type { A2aAgentCard, A2aAgentSkill } from './types.js'; + +export interface AgentCardOptions { + readonly name: string; + readonly description: string; + /** Version of this gateway build, not of the protocol. */ + readonly version: string; + /** Externally reachable gateway base URL, from configuration. */ + readonly publicBaseUrl: string; + /** Gateway path the JSON-RPC endpoint is mounted at. */ + readonly mountPath: string; + readonly resources: readonly CommerceResource[]; +} + +/** + * A base URL may or may not carry a trailing slash and a mount always starts + * with one; concatenating them naively yields `https://host//a2a`, which is a + * different path to every router that sees it. + */ +export function endpointUrl(publicBaseUrl: string, mountPath: string): string { + const base = publicBaseUrl.replace(/\/+$/, ''); + const path = mountPath.startsWith('/') ? mountPath : `/${mountPath}`; + return `${base}${path.replace(/\/+$/, '')}`; +} + +/** + * Free/paid is on the card because a caller choosing between skills should not + * have to attempt a call to discover one costs money. The price itself is in + * the description, where a human-readable amount belongs. + */ +function skillTags(resource: CommerceResource): string[] { + return ['agent-commerce', resource.pricing.type === 'free' ? 'free' : 'paid']; +} + +function skillDescription(resource: CommerceResource): string { + const base = resource.description ?? resource.name; + if (resource.pricing.type === 'fixed') { + return `${base} Costs ${resource.pricing.amount} ${resource.pricing.currency} per call.`; + } + if (resource.pricing.type === 'dynamic') { + return `${base} Requires payment (amount determined at request time).`; + } + return base; +} + +export function buildAgentSkill(resource: CommerceResource): A2aAgentSkill { + return { + id: resource.id, + name: resource.name, + description: skillDescription(resource), + tags: skillTags(resource), + inputModes: [A2A_JSON_MEDIA_TYPE], + outputModes: [A2A_JSON_MEDIA_TYPE], + }; +} + +export function buildAgentCard(options: AgentCardOptions): A2aAgentCard { + return { + protocolVersion: A2A_PROTOCOL_VERSION, + name: options.name, + description: options.description, + version: options.version, + supportedInterfaces: [ + { + url: endpointUrl(options.publicBaseUrl, options.mountPath), + protocolBinding: A2A_PROTOCOL_BINDING, + protocolVersion: A2A_PROTOCOL_VERSION, + }, + ], + capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false }, + defaultInputModes: [A2A_JSON_MEDIA_TYPE], + defaultOutputModes: [A2A_JSON_MEDIA_TYPE], + skills: options.resources.map(buildAgentSkill), + }; +} diff --git a/src/protocols/a2a/constants.ts b/src/protocols/a2a/constants.ts new file mode 100644 index 0000000..92905b7 --- /dev/null +++ b/src/protocols/a2a/constants.ts @@ -0,0 +1,33 @@ +/** + * A2A pins, kept in one place so nothing infers one version from another. + * + * The specification revision and the protocol negotiation version are + * different values that look similar: `1.0.0` names the document this adapter + * was written against, `1.0` is what a client negotiates on the wire. Neither + * is this package's version — that is `PACKAGE_VERSION`. + */ + +/** A2A specification revision this adapter targets. */ +export const A2A_SPEC_VERSION = '1.0.0'; + +/** Protocol negotiation version carried on the wire. */ +export const A2A_PROTOCOL_VERSION = '1.0'; + +/** The only transport binding this adapter serves. */ +export const A2A_PROTOCOL_BINDING = 'JSONRPC'; + +/** + * Fixed by the A2A specification: a client fetches the card from this exact + * path, so it is not configurable. `src/config` reserves it against adapter + * mounts for the same reason. + */ +export const A2A_AGENT_CARD_PATH = '/.well-known/agent-card.json'; + +/** Mount serving the JSON-RPC endpoint, unless configuration says otherwise. */ +export const A2A_DEFAULT_MOUNT_PATH = '/a2a'; + +/** Content type on both sides of every supported A2A exchange. */ +export const A2A_JSON_MEDIA_TYPE = 'application/json'; + +/** Card identity when the operator names none. Matches the MCP server name. */ +export const A2A_DEFAULT_AGENT_NAME = 'agent-commerce'; diff --git a/src/protocols/a2a/descriptor.ts b/src/protocols/a2a/descriptor.ts new file mode 100644 index 0000000..7ae84da --- /dev/null +++ b/src/protocols/a2a/descriptor.ts @@ -0,0 +1,57 @@ +/** + * Adapter self-description. + * + * `supportedSpec` is the A2A specification revision (`1.0.0`), never the + * negotiation version (`1.0`) and never this package's version. Status is + * `experimental` and stays that way until the unsupported list below shrinks + * on purpose rather than by omission. + */ +import type { AdapterDescriptor } from '../../core/index.js'; +import { A2A_SPEC_VERSION } from './constants.js'; + +/** What this adapter actually implements. */ +export const A2A_CAPABILITIES: readonly string[] = ['agent-card', 'jsonrpc', 'SendMessage']; + +/** + * Everything an A2A client may reasonably expect and will not get here. + * Complete on purpose: a short list reads as "mostly compatible", which is + * exactly the blanket claim alpha honesty forbids. `doctor` and + * `/.well-known/agent-commerce` surface this verbatim. + */ +export const A2A_UNSUPPORTED: readonly string[] = [ + // Methods, named as the protocol names them. + 'SendStreamingMessage', + 'GetTask', + 'ListTasks', + 'CancelTask', + 'SubscribeToTask', + 'CreateTaskPushNotificationConfig', + 'GetTaskPushNotificationConfig', + 'ListTaskPushNotificationConfigs', + 'DeleteTaskPushNotificationConfig', + 'GetExtendedAgentCard', + // Transports other than the one binding served. + 'HTTP+JSON/REST binding', + 'gRPC binding', + // Behaviours. + 'SSE', + 'long-running task persistence', + 'task resumption', + 'push notifications', + 'multi-turn conversational continuation', + 'authenticated extended agent cards', + 'A2A authentication schemes', + 'artifact types beyond Agent Commerce outcome data', +]; + +export function buildDescriptor(implementationVersion: string): AdapterDescriptor { + return { + name: 'a2a', + kind: 'protocol', + implementationVersion, + supportedSpec: A2A_SPEC_VERSION, + capabilities: A2A_CAPABILITIES, + status: 'experimental', + unsupported: A2A_UNSUPPORTED, + }; +} diff --git a/src/protocols/a2a/index.ts b/src/protocols/a2a/index.ts new file mode 100644 index 0000000..56c797b --- /dev/null +++ b/src/protocols/a2a/index.ts @@ -0,0 +1,19 @@ +/** + * src/protocols/a2a + * + * A2A (Agent2Agent) v1 adapter: publishes canonical resources as A2A skills on + * the specification-fixed Agent Card path and serves the JSON-RPC endpoint at + * its mount. Experimental — see `descriptor.ts` for what it does not do. + */ + +export type { A2aAdapterOptions } from './adapter.js'; +export { A2aProtocolAdapter, createA2aAdapter } from './adapter.js'; +export { + A2A_AGENT_CARD_PATH, + A2A_DEFAULT_MOUNT_PATH, + A2A_PROTOCOL_BINDING, + A2A_PROTOCOL_VERSION, + A2A_SPEC_VERSION, +} from './constants.js'; +export { A2A_CAPABILITIES, A2A_UNSUPPORTED } from './descriptor.js'; +export type { A2aAgentCard, A2aAgentSkill } from './types.js'; diff --git a/src/protocols/a2a/types.ts b/src/protocols/a2a/types.ts new file mode 100644 index 0000000..f8ddbb9 --- /dev/null +++ b/src/protocols/a2a/types.ts @@ -0,0 +1,56 @@ +/** + * A2A v1 wire shapes, hand-written and confined to this directory. + * + * Deliberately not imported from `@a2a-js/sdk`: the SDK is a test-only + * dependency (conformance asserts these shapes against it), never a runtime + * one, so a consumer installing the gateway does not install an A2A SDK to + * serve an Agent Card. Only the subset this adapter emits is modelled. + */ + +/** + * One transport a client can reach this agent through. A2A v1 replaced the + * single top-level `url` with this list; emitting the old field would tell a + * v1 client the card was written for an earlier revision. + */ +export interface A2aAgentInterface { + readonly url: string; + readonly protocolBinding: string; + readonly protocolVersion: string; +} + +export interface A2aAgentCapabilities { + readonly streaming: boolean; + readonly pushNotifications: boolean; + readonly extendedAgentCard: boolean; +} + +/** + * A discovery descriptor, not a dispatch identifier: A2A has no `skillId` on + * a request, so `id` is what a caller names inside the invocation envelope + * (see the adapter), and it is the canonical resource id verbatim. + * + * Core A2A v1 `AgentSkill` has no input-schema field. One is not invented + * here — a non-standard property would be ignored by conformant clients and + * would misrepresent the card as carrying more than the protocol defines. + */ +export interface A2aAgentSkill { + readonly id: string; + readonly name: string; + readonly description: string; + readonly tags: readonly string[]; + readonly inputModes: readonly string[]; + readonly outputModes: readonly string[]; +} + +export interface A2aAgentCard { + readonly protocolVersion: string; + readonly name: string; + readonly description: string; + /** Version of the agent implementation, not of the protocol. */ + readonly version: string; + readonly supportedInterfaces: readonly A2aAgentInterface[]; + readonly capabilities: A2aAgentCapabilities; + readonly defaultInputModes: readonly string[]; + readonly defaultOutputModes: readonly string[]; + readonly skills: readonly A2aAgentSkill[]; +} diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts new file mode 100644 index 0000000..eb8e900 --- /dev/null +++ b/tests/integration/a2a-over-gateway.test.ts @@ -0,0 +1,105 @@ +/** + * The Agent Card fetched the way a client fetches it: over a real + * `createGateway()` Fastify instance with a real `createA2aAdapter()` mounted. + * A card built correctly but never reachable at its fixed path is not + * discovery, and only a test that traverses the gateway can tell the two + * apart. + * + * Fakes: ReceiptStore only. The adapter and the gateway are the real ones. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import type { GatewayConfig } from '../../src/config/index.js'; +import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; +import { createA2aAdapter } from '../../src/protocols/a2a/index.js'; +import type { A2aAgentCard } from '../../src/protocols/a2a/types.js'; +import { createMcpAdapter } from '../../src/protocols/mcp/index.js'; +import { createFakeStore } from '../unit/gateway/helpers.js'; + +process.env['NODE_ENV'] = 'test'; + +let gateway: GatewayInstance | undefined; + +afterEach(async () => { + await gateway?.close().catch(() => {}); + gateway = undefined; +}); + +function config(): GatewayConfig { + return { + version: 1, + merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: true, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, + }, + resources: [ + { + id: 'weather_basic', + name: 'Basic Weather', + description: 'Current weather for a city.', + inputSchema: { type: 'object', properties: { city: { type: 'string' } } }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/weather/{city}' }, + pricing: { type: 'free' }, + exposedVia: ['http', 'mcp', 'a2a'], + paymentMethods: [], + }, + { + id: 'mcp_only', + name: 'MCP Only', + inputSchema: { type: 'object', properties: {} }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/mcp-only' }, + pricing: { type: 'free' }, + exposedVia: ['mcp'], + paymentMethods: [], + }, + ], + payments: {}, + }; +} + +async function startGateway(): Promise { + gateway = await createGateway({ + config: config(), + store: createFakeStore(), + paymentProviders: [], + protocolAdapters: [createMcpAdapter(), createA2aAdapter()], + }); + return gateway; +} + +describe('A2A agent card over the real gateway', () => { + it('serves the card at the spec-fixed path with the gateway public base URL', async () => { + const gw = await startGateway(); + + const res = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-card.json' }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('application/json'); + const card = res.json(); + expect(card.protocolVersion).toBe('1.0'); + expect(card.supportedInterfaces).toEqual([ + { + url: 'http://localhost:8080/a2a', + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ]); + expect(card.skills.map((s) => s.id)).toEqual(['weather_basic']); + expect(card).not.toHaveProperty('url'); + }); + + it('leaves the gateway own well-known document and the MCP mount untouched', async () => { + const gw = await startGateway(); + + const wellKnown = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }); + expect(wellKnown.statusCode).toBe(200); + + // POST is MCP's only method; a 200 here would mean the A2A card route had + // swallowed the neighbouring mount. + const mcp = await gw.server.inject({ method: 'GET', url: '/mcp' }); + expect(mcp.statusCode).toBe(405); + }); +}); diff --git a/tests/unit/protocols-a2a/agent-card.test.ts b/tests/unit/protocols-a2a/agent-card.test.ts new file mode 100644 index 0000000..62e2e44 --- /dev/null +++ b/tests/unit/protocols-a2a/agent-card.test.ts @@ -0,0 +1,244 @@ +/** + * Agent Card construction and adapter lifecycle, driven directly. The route + * itself is exercised over the real gateway in + * tests/integration/a2a-over-gateway.test.ts. + */ +import { describe, expect, it } from 'vitest'; +import { createResourceRegistry } from '../../../src/core/execution/index.js'; +import type { + Clock, + CommerceResource, + EventSink, + ExecutionPipeline, + IdGenerator, + Logger, + ProtocolAdapterContext, + ResourceRegistry, +} from '../../../src/core/index.js'; +import { buildAgentCard, endpointUrl } from '../../../src/protocols/a2a/agent-card.js'; +import { createA2aAdapter } from '../../../src/protocols/a2a/index.js'; +import type { A2aAgentCard } from '../../../src/protocols/a2a/types.js'; + +const NOOP_LOGGER: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => NOOP_LOGGER, +}; + +const clock: Clock = { + now: () => new Date('2026-01-01T00:00:00.000Z'), + nowIso: () => '2026-01-01T00:00:00.000Z', + monotonicMs: () => 0, +}; + +function resource(overrides: Partial = {}): CommerceResource { + return { + id: 'weather_basic', + name: 'Basic Weather', + description: 'Current weather for a city.', + inputSchema: { type: 'object', properties: { city: { type: 'string' } } }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/weather/{city}' }, + pricing: { type: 'free' }, + exposedVia: ['a2a'], + paymentMethods: [], + ...overrides, + }; +} + +const paid = resource({ + id: 'market_report', + name: 'Premium Market Report', + description: 'Latest market analysis.', + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['a2a'], + paymentMethods: ['x402'], +}); + +function context(resources: readonly CommerceResource[]): ProtocolAdapterContext { + return { + pipeline: { + execute: async () => { + throw new Error('unused in phase 3'); + }, + } as unknown as ExecutionPipeline, + resources: createResourceRegistry(resources) as ResourceRegistry, + events: { emit: async () => {} } as EventSink, + logger: NOOP_LOGGER, + clock, + ids: { next: () => 'id' } as IdGenerator, + publicBaseUrl: 'https://gateway.example.com', + }; +} + +async function cardFrom( + resources: readonly CommerceResource[], + options: { mountPath?: string } = {}, +): Promise { + const adapter = createA2aAdapter(options); + await adapter.start(context(resources)); + const res = await callCardRoute(adapter); + expect(res.status).toBe(200); + return JSON.parse(res.body) as A2aAgentCard; +} + +/** Drives the fixed route's handler with a minimal fake req/res pair. */ +async function callCardRoute( + adapter: ReturnType, + method = 'GET', +): Promise<{ status: number; body: string }> { + const route = adapter.additionalHttpRoutes[0]; + if (route === undefined) throw new Error('no agent card route declared'); + let body = ''; + let status = 0; + const res = { + headersSent: false, + writeHead(code: number) { + status = code; + return res; + }, + end(chunk?: string) { + body = chunk ?? ''; + return res; + }, + }; + await route.handleHttp( + { method } as never, + res as unknown as Parameters[1], + ); + return { status, body }; +} + +describe('A2A agent card', () => { + it('declares the fixed card path as a GET route', () => { + const adapter = createA2aAdapter(); + expect(adapter.additionalHttpRoutes).toHaveLength(1); + expect(adapter.additionalHttpRoutes[0]?.method).toBe('GET'); + expect(adapter.additionalHttpRoutes[0]?.path).toBe('/.well-known/agent-card.json'); + }); + + it('publishes only a2a-exposed resources as skills', async () => { + const card = await cardFrom([ + resource(), + resource({ id: 'mcp_only', exposedVia: ['mcp'] }), + paid, + ]); + expect(card.skills.map((s) => s.id)).toEqual(['weather_basic', 'market_report']); + }); + + it('uses the canonical resource id as the skill id', async () => { + const card = await cardFrom([paid]); + expect(card.skills[0]?.id).toBe('market_report'); + expect(card.skills[0]?.name).toBe('Premium Market Report'); + }); + + it('combines the public base URL with the mount, with the pinned binding and version', async () => { + const card = await cardFrom([resource()], { mountPath: '/agents/a2a' }); + expect(card.supportedInterfaces).toEqual([ + { + url: 'https://gateway.example.com/agents/a2a', + protocolBinding: 'JSONRPC', + protocolVersion: '1.0', + }, + ]); + }); + + it('never emits the obsolete top-level endpoint url', async () => { + const card = await cardFrom([resource()]); + expect(card).not.toHaveProperty('url'); + expect(card).not.toHaveProperty('preferredTransport'); + }); + + it('adds no non-standard inputSchema to a skill', async () => { + const card = await cardFrom([paid]); + expect(card.skills[0]).not.toHaveProperty('inputSchema'); + expect(Object.keys(card.skills[0] ?? {}).sort()).toEqual([ + 'description', + 'id', + 'inputModes', + 'name', + 'outputModes', + 'tags', + ]); + }); + + it('declares JSON content modes and no streaming, push or extended card', async () => { + const card = await cardFrom([resource()]); + expect(card.defaultInputModes).toEqual(['application/json']); + expect(card.defaultOutputModes).toEqual(['application/json']); + expect(card.capabilities).toEqual({ + streaming: false, + pushNotifications: false, + extendedAgentCard: false, + }); + }); + + it('marks a paid skill as paid and names its price', async () => { + const card = await cardFrom([resource(), paid]); + expect(card.skills[0]?.tags).toContain('free'); + expect(card.skills[1]?.tags).toContain('paid'); + expect(card.skills[1]?.description).toContain('0.01 USDC'); + }); + + it.each([ + ['https://gateway.example.com/', '/a2a', 'https://gateway.example.com/a2a'], + ['https://gateway.example.com', '/a2a/', 'https://gateway.example.com/a2a'], + ['https://gateway.example.com//', '/a2a', 'https://gateway.example.com/a2a'], + ])('joins %s + %s without a doubled slash', (base, mount, expected) => { + expect(endpointUrl(base, mount)).toBe(expected); + }); + + it('builds an empty skill list rather than failing when nothing is exposed', () => { + const card = buildAgentCard({ + name: 'agent-commerce', + description: 'test', + version: '0.0.0-test', + publicBaseUrl: 'https://gateway.example.com', + mountPath: '/a2a', + resources: [], + }); + expect(card.skills).toEqual([]); + }); +}); + +describe('A2A adapter lifecycle', () => { + it('reports the pinned spec revision, experimental status and a complete unsupported list', () => { + const { descriptor } = createA2aAdapter(); + expect(descriptor.name).toBe('a2a'); + expect(descriptor.supportedSpec).toBe('1.0.0'); + expect(descriptor.status).toBe('experimental'); + expect(descriptor.capabilities).toEqual(['agent-card', 'jsonrpc', 'SendMessage']); + expect(descriptor.unsupported).toContain('SendStreamingMessage'); + expect(descriptor.unsupported).toContain('GetTask'); + expect(descriptor.unsupported).toContain('gRPC binding'); + }); + + it('fails health before start and passes after, counting skills', async () => { + const adapter = createA2aAdapter(); + expect((await adapter.health()).status).toBe('fail'); + + await adapter.start(context([resource(), paid])); + const healthy = await adapter.health(); + expect(healthy.status).toBe('pass'); + expect(healthy.detail).toContain('2 skill(s)'); + + await adapter.stop(); + expect((await adapter.health()).status).toBe('fail'); + }); + + it('serves 503 for the card once stopped, never a stale one', async () => { + const adapter = createA2aAdapter(); + await adapter.start(context([resource()])); + await adapter.stop(); + const res = await callCardRoute(adapter); + expect(res.status).toBe(503); + expect(res.body).not.toContain('weather_basic'); + }); + + it('refuses a non-GET on the card route', async () => { + const adapter = createA2aAdapter(); + await adapter.start(context([resource()])); + expect((await callCardRoute(adapter, 'POST')).status).toBe(405); + }); +}); From 0801e6dd8d1cc3d96f129881a94166c239570845 Mon Sep 17 00:00:00 2001 From: Revinand Date: Fri, 28 Aug 2026 19:54:06 +0200 Subject: [PATCH 04/10] feat(a2a): define canonical resource invocation mapping --- src/protocols/a2a/index.ts | 2 + src/protocols/a2a/message-mapping.ts | 174 ++++++++++++++++ .../protocols-a2a/message-mapping.test.ts | 193 ++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 src/protocols/a2a/message-mapping.ts create mode 100644 tests/unit/protocols-a2a/message-mapping.test.ts diff --git a/src/protocols/a2a/index.ts b/src/protocols/a2a/index.ts index 56c797b..3a4a609 100644 --- a/src/protocols/a2a/index.ts +++ b/src/protocols/a2a/index.ts @@ -16,4 +16,6 @@ export { A2A_SPEC_VERSION, } from './constants.js'; export { A2A_CAPABILITIES, A2A_UNSUPPORTED } from './descriptor.js'; +export type { A2aInvocation } from './message-mapping.js'; +export { A2A_USER_ROLE, parseInvocation } from './message-mapping.js'; export type { A2aAgentCard, A2aAgentSkill } from './types.js'; diff --git a/src/protocols/a2a/message-mapping.ts b/src/protocols/a2a/message-mapping.ts new file mode 100644 index 0000000..06b099b --- /dev/null +++ b/src/protocols/a2a/message-mapping.ts @@ -0,0 +1,174 @@ +/** + * The Agent Commerce invocation envelope for A2A. + * + * A2A has no `skillId` on a request: `SendMessage` carries a message, not a + * tool call, so which canonical resource a caller wants has to be stated + * somewhere the protocol leaves open. That place is a structured data part: + * + * ```json + * { "message": { "role": "ROLE_USER", "messageId": "msg-1", + * "parts": [{ "data": { "resource": "market_report", + * "input": { "symbol": "ETH" } }, + * "mediaType": "application/json" }] } } + * ``` + * + * A payment proof rides in the reserved `_payment` input field, exactly as it + * does over MCP — there is deliberately no second, A2A-specific payment + * representation to keep in sync. + * + * The accepted shape is narrow on purpose. Everything richer that A2A allows + * (text parts, files, multi-part messages, task continuation) is rejected with + * a code that says which of the two it is: `INPUT_INVALID` for an envelope + * that is malformed, `PROTOCOL_UNSUPPORTED` for one that is a legal A2A + * message this adapter does not serve. Guessing at intent — picking the first + * data part out of several, say — would make a caller's mistake look like a + * successful, possibly *paid*, call for something they did not ask for. + */ +import { z } from 'zod'; +import { CommerceError } from '../../core/index.js'; +import { A2A_JSON_MEDIA_TYPE } from './constants.js'; + +/** The only role a request message may carry. A2A v1 spells roles this way. */ +export const A2A_USER_ROLE = 'ROLE_USER'; + +/** What a supported envelope reduces to. Nothing protocol-shaped survives. */ +export interface A2aInvocation { + readonly resourceId: string; + readonly input: Record; + /** Client-assigned message id, echoed back on the response when present. */ + readonly messageId?: string; +} + +/** + * Shape only — every semantic rule is checked below, where the failure can + * name itself. Parts stay untyped records: classifying one is what tells a + * file part apart from a malformed one, and zod would collapse both into the + * same union failure. + */ +const UnknownRecord = z.record(z.string(), z.unknown()); + +const MessageSchema = z.object({ + role: z.string(), + messageId: z.string().optional(), + parts: z.array(UnknownRecord), + taskId: z.string().optional(), + contextId: z.string().optional(), + referenceTaskIds: z.array(z.string()).optional(), +}); + +const ParamsSchema = z.object({ + message: MessageSchema, + taskId: z.string().optional(), + contextId: z.string().optional(), +}); + +function invalid(message: string): CommerceError { + return new CommerceError('INPUT_INVALID', message); +} + +function unsupported(message: string): CommerceError { + return new CommerceError('PROTOCOL_UNSUPPORTED', message); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Continuation is refused rather than ignored: a caller resuming a task would + * otherwise get a fresh, independently billed execution back and no signal + * that their task id meant nothing here. + */ +function assertNoContinuation(params: z.infer): void { + const message = params.message; + if (params.taskId !== undefined || message.taskId !== undefined) { + throw unsupported('Task continuation is not supported: send a request with no taskId.'); + } + if (params.contextId !== undefined || message.contextId !== undefined) { + throw unsupported( + 'Multi-turn conversational continuation is not supported: send a request with no contextId.', + ); + } + if (message.referenceTaskIds !== undefined && message.referenceTaskIds.length > 0) { + throw unsupported('Referencing previous tasks is not supported.'); + } +} + +/** Names the part kind so a caller learns which of theirs is the problem. */ +function assertSupportedPart(part: Record): void { + if ('file' in part) { + throw unsupported('File and URL parts are not supported: send a structured data part.'); + } + if ('text' in part) { + throw unsupported('Text parts are not supported: send a structured data part.'); + } + if (!('data' in part)) { + throw invalid('Message part carries no "data": send a structured data part.'); + } + const mediaType = part['mediaType']; + if (mediaType !== undefined && mediaType !== A2A_JSON_MEDIA_TYPE) { + throw unsupported( + `Media type "${String(mediaType)}" is not supported: parts must be ${A2A_JSON_MEDIA_TYPE}.`, + ); + } +} + +/** + * Turns `SendMessage` params into a resource id and an input object, or throws + * a `CommerceError`. Pure: it resolves nothing, checks no resource exists and + * touches no payment — the pipeline owns all three. + */ +export function parseInvocation(rawParams: unknown): A2aInvocation { + const parsed = ParamsSchema.safeParse(rawParams); + if (!parsed.success) { + throw invalid( + 'Request must carry a "message" with a "role" and a "parts" array of structured data parts.', + ); + } + const params = parsed.data; + const message = params.message; + + assertNoContinuation(params); + + if (message.role !== A2A_USER_ROLE) { + throw invalid(`Unsupported message role "${message.role}": only ${A2A_USER_ROLE} is accepted.`); + } + if (message.parts.length === 0) { + throw invalid('Message carries no parts: send exactly one structured data part.'); + } + if (message.parts.length > 1) { + throw unsupported( + `Multi-part messages are not supported: send exactly one structured data part (received ${message.parts.length}).`, + ); + } + + const part = message.parts[0]; + if (part === undefined) throw invalid('Message carries no parts.'); + assertSupportedPart(part); + + const data = part['data']; + if (!isPlainObject(data)) { + throw invalid('Message part "data" must be a JSON object.'); + } + + const resourceId = data['resource']; + if (typeof resourceId !== 'string') { + throw invalid('Message part data must carry a "resource" string naming a canonical resource.'); + } + if (resourceId.length === 0) { + throw invalid('Message part data "resource" must not be empty.'); + } + + const rawInput = data['input']; + // Absent means "no arguments", which is a real case for a zero-input + // resource. Present-but-not-an-object is a mistake, never an empty call. + if (rawInput !== undefined && !isPlainObject(rawInput)) { + throw invalid('Message part data "input" must be a JSON object.'); + } + + return { + resourceId, + input: rawInput ?? {}, + ...(message.messageId !== undefined ? { messageId: message.messageId } : {}), + }; +} diff --git a/tests/unit/protocols-a2a/message-mapping.test.ts b/tests/unit/protocols-a2a/message-mapping.test.ts new file mode 100644 index 0000000..a38c92d --- /dev/null +++ b/tests/unit/protocols-a2a/message-mapping.test.ts @@ -0,0 +1,193 @@ +/** + * The invocation envelope: what the adapter accepts, and what it refuses. + * + * Terminology, deliberately: a rejected `resource` names an *unknown canonical + * resource*, never an "unknown skill" — A2A skills are discovery descriptors, + * not dispatch identifiers, and resolution happens in the pipeline anyway. + */ +import { describe, expect, it } from 'vitest'; +import { isCommerceError, PAYMENT_INPUT_FIELD } from '../../../src/core/index.js'; +import { parseInvocation } from '../../../src/protocols/a2a/message-mapping.js'; + +function envelope(data: unknown, overrides: Record = {}): unknown { + return { + message: { + role: 'ROLE_USER', + messageId: 'msg-1', + parts: [{ data, mediaType: 'application/json' }], + ...overrides, + }, + }; +} + +function expectRejected(params: unknown, code: 'INPUT_INVALID' | 'PROTOCOL_UNSUPPORTED'): void { + try { + parseInvocation(params); + expect.unreachable(); + } catch (error) { + expect(isCommerceError(error)).toBe(true); + if (isCommerceError(error)) expect(error.code).toBe(code); + } +} + +describe('parseInvocation — accepted envelope', () => { + it('maps a valid resource envelope to a resource id and input', () => { + expect( + parseInvocation(envelope({ resource: 'market_report', input: { symbol: 'ETH' } })), + ).toEqual({ + resourceId: 'market_report', + input: { symbol: 'ETH' }, + messageId: 'msg-1', + }); + }); + + it('treats an absent input as no arguments', () => { + expect(parseInvocation(envelope({ resource: 'ping' }))).toEqual({ + resourceId: 'ping', + input: {}, + messageId: 'msg-1', + }); + }); + + it('carries a payment proof through in the reserved input field, untouched', () => { + const result = parseInvocation( + envelope({ + resource: 'market_report', + input: { symbol: 'ETH', [PAYMENT_INPUT_FIELD]: 'base64-proof' }, + }), + ); + expect(result.input[PAYMENT_INPUT_FIELD]).toBe('base64-proof'); + }); + + it('accepts a part with no declared media type', () => { + const params = { message: { role: 'ROLE_USER', parts: [{ data: { resource: 'ping' } }] } }; + expect(parseInvocation(params).resourceId).toBe('ping'); + }); + + it('omits messageId when the client sent none', () => { + const params = { message: { role: 'ROLE_USER', parts: [{ data: { resource: 'ping' } }] } }; + expect(parseInvocation(params)).not.toHaveProperty('messageId'); + }); +}); + +describe('parseInvocation — malformed envelopes', () => { + it.each([ + ['not an object', 42], + ['no message', {}], + ['no parts array', { message: { role: 'ROLE_USER' } }], + ['no role', { message: { parts: [] } }], + ])('rejects %s', (_label, params) => { + expectRejected(params, 'INPUT_INVALID'); + }); + + it('rejects an empty parts array', () => { + expectRejected({ message: { role: 'ROLE_USER', parts: [] } }, 'INPUT_INVALID'); + }); + + it('rejects a part whose data is not an object', () => { + expectRejected(envelope('market_report'), 'INPUT_INVALID'); + expectRejected(envelope(['market_report']), 'INPUT_INVALID'); + expectRejected(envelope(null), 'INPUT_INVALID'); + }); + + it('rejects a missing resource', () => { + expectRejected(envelope({ input: { symbol: 'ETH' } }), 'INPUT_INVALID'); + }); + + it('rejects an empty resource', () => { + expectRejected(envelope({ resource: '' }), 'INPUT_INVALID'); + }); + + it('rejects a non-string resource', () => { + expectRejected(envelope({ resource: 7 }), 'INPUT_INVALID'); + }); + + it.each([ + ['a string', 'ETH'], + ['an array', ['ETH']], + ['null', null], + ])('rejects an input that is %s', (_label, input) => { + expectRejected(envelope({ resource: 'market_report', input }), 'INPUT_INVALID'); + }); + + it('rejects an unsupported role', () => { + expectRejected(envelope({ resource: 'ping' }, { role: 'ROLE_AGENT' }), 'INPUT_INVALID'); + expectRejected(envelope({ resource: 'ping' }, { role: 'user' }), 'INPUT_INVALID'); + }); +}); + +describe('parseInvocation — legal A2A this adapter does not serve', () => { + it('rejects a file part', () => { + const params = { + message: { role: 'ROLE_USER', parts: [{ file: { uri: 'https://example.com/a.pdf' } }] }, + }; + expectRejected(params, 'PROTOCOL_UNSUPPORTED'); + }); + + it('rejects a raw binary file part', () => { + const params = { message: { role: 'ROLE_USER', parts: [{ file: { bytes: 'AAAA' } }] } }; + expectRejected(params, 'PROTOCOL_UNSUPPORTED'); + }); + + it('rejects a text part', () => { + const params = { message: { role: 'ROLE_USER', parts: [{ text: 'get me the report' }] } }; + expectRejected(params, 'PROTOCOL_UNSUPPORTED'); + }); + + it('rejects multiple input parts rather than picking one', () => { + const params = { + message: { + role: 'ROLE_USER', + parts: [ + { data: { resource: 'weather_basic', input: {} } }, + { data: { resource: 'market_report', input: {} } }, + ], + }, + }; + expectRejected(params, 'PROTOCOL_UNSUPPORTED'); + }); + + it('rejects a data part alongside a text part', () => { + const params = { + message: { + role: 'ROLE_USER', + parts: [{ text: 'please' }, { data: { resource: 'market_report' } }], + }, + }; + expectRejected(params, 'PROTOCOL_UNSUPPORTED'); + }); + + it('rejects a non-JSON media type', () => { + const params = { + message: { + role: 'ROLE_USER', + parts: [{ data: { resource: 'ping' }, mediaType: 'application/xml' }], + }, + }; + expectRejected(params, 'PROTOCOL_UNSUPPORTED'); + }); + + it.each([ + ['a params-level taskId', { taskId: 'task-1' }], + ['a params-level contextId', { contextId: 'ctx-1' }], + ])('rejects %s', (_label, extra) => { + expectRejected( + { ...(envelope({ resource: 'ping' }) as object), ...extra }, + 'PROTOCOL_UNSUPPORTED', + ); + }); + + it.each([ + ['a message-level taskId', { taskId: 'task-1' }], + ['a message-level contextId', { contextId: 'ctx-1' }], + ['referenced tasks', { referenceTaskIds: ['task-1'] }], + ])('rejects %s', (_label, overrides) => { + expectRejected(envelope({ resource: 'ping' }, overrides), 'PROTOCOL_UNSUPPORTED'); + }); + + it('accepts an empty referenceTaskIds array, which continues nothing', () => { + expect( + parseInvocation(envelope({ resource: 'ping' }, { referenceTaskIds: [] })).resourceId, + ).toBe('ping'); + }); +}); From 79db5640e8cea168aaf88b37ee7e0f9d783398bc Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 30 Aug 2026 12:00:28 +0200 Subject: [PATCH 05/10] feat(a2a): implement v1 json-rpc SendMessage transport --- src/protocols/a2a/adapter.ts | 175 +++++++++++++++++++-- src/protocols/a2a/constants.ts | 29 ++++ src/protocols/a2a/descriptor.ts | 16 +- src/protocols/a2a/index.ts | 2 + src/protocols/a2a/jsonrpc.ts | 126 +++++++++++++++ tests/integration/a2a-over-gateway.test.ts | 150 ++++++++++++++++++ 6 files changed, 475 insertions(+), 23 deletions(-) create mode 100644 src/protocols/a2a/jsonrpc.ts diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts index 08c8837..e268992 100644 --- a/src/protocols/a2a/adapter.ts +++ b/src/protocols/a2a/adapter.ts @@ -14,6 +14,7 @@ import { type AdapterDescriptor, type AdapterHealth, type AdapterHttpRoute, + CommerceError, type CommerceResource, type HttpProtocolAdapter, type ProtocolAdapterContext, @@ -26,10 +27,33 @@ import { A2A_DEFAULT_AGENT_NAME, A2A_DEFAULT_MOUNT_PATH, A2A_JSON_MEDIA_TYPE, + A2A_METHOD_SEND_MESSAGE, + A2A_PROTOCOL_VERSION, + A2A_UNSUPPORTED_METHODS, + A2A_VERSION_HEADER, } from './constants.js'; import { buildDescriptor } from './descriptor.js'; +import { + A2A_ERROR_UNSUPPORTED_OPERATION, + JSONRPC_INTERNAL_ERROR, + JSONRPC_INVALID_PARAMS, + JSONRPC_INVALID_REQUEST, + JSONRPC_METHOD_NOT_FOUND, + JSONRPC_PARSE_ERROR, + type JsonRpcId, + jsonRpcError, + parseJsonRpcRequest, +} from './jsonrpc.js'; +import { parseInvocation } from './message-mapping.js'; import type { A2aAgentCard } from './types.js'; +/** + * The gateway mount already destroys a connection whose body passes its cap, + * so this is a second line rather than the only one — it bounds what this + * adapter buffers if it is ever mounted somewhere without that guard. + */ +const MAX_REQUEST_BODY_BYTES = 256 * 1024; + export interface A2aAdapterOptions { readonly mountPath?: string; /** Agent name published on the card. */ @@ -128,11 +152,132 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { } } - async handleHttp(_req: IncomingMessage, res: ServerResponse): Promise { - // The JSON-RPC endpoint arrives with the SendMessage transport. Until - // then this answers honestly rather than 404-ing a path the card - // advertises. - this.writeJsonRpcError(res, 501, 'A2A SendMessage is not implemented yet.'); + /** `POST ` — the A2A JSON-RPC endpoint. */ + async handleHttp(req: IncomingMessage, res: ServerResponse): Promise { + try { + if (!this.started) { + this.writeJson( + res, + 503, + jsonRpcError(null, JSONRPC_INTERNAL_ERROR, 'A2A adapter is not running.'), + ); + return; + } + if (req.method !== 'POST') { + this.writeJson( + res, + 405, + jsonRpcError( + null, + JSONRPC_INVALID_REQUEST, + 'Method not allowed. This endpoint only accepts POST.', + ), + ); + return; + } + + const version = req.headers[A2A_VERSION_HEADER]; + const declared = Array.isArray(version) ? version[0] : version; + if (declared !== A2A_PROTOCOL_VERSION) { + // Missing counts as unsupported: a client that negotiates no version + // is speaking an older convention, and answering it as if it were v1 + // would be guessing on its behalf. + this.writeJson( + res, + 200, + jsonRpcError( + null, + A2A_ERROR_UNSUPPORTED_OPERATION, + `Unsupported A2A protocol version. Send the ${A2A_VERSION_HEADER} header with "${A2A_PROTOCOL_VERSION}".`, + ), + ); + return; + } + + let body: string; + try { + body = await readBody(req, MAX_REQUEST_BODY_BYTES); + } catch { + this.writeJson( + res, + 200, + jsonRpcError(null, JSONRPC_PARSE_ERROR, 'Could not read the request body.'), + ); + return; + } + + const parsed = parseJsonRpcRequest(body); + if (!parsed.ok) { + this.writeJson(res, 200, jsonRpcError(parsed.id, parsed.error.code, parsed.error.message)); + return; + } + this.writeJson( + res, + 200, + await this.dispatch(parsed.request.id, parsed.request.method, parsed.request.params), + ); + } catch (err) { + // Nothing from `err` reaches the client: stack, exception name and any + // upstream detail stay in the log. + this.context?.logger.error( + { err: toCommerceError(err).toInfo() }, + 'a2a adapter: request handling failed', + ); + this.writeJson( + res, + 200, + jsonRpcError(null, JSONRPC_INTERNAL_ERROR, 'Internal server error.'), + ); + } + } + + private async dispatch( + id: JsonRpcId, + method: string, + params: unknown, + ): Promise> { + if (A2A_UNSUPPORTED_METHODS.includes(method)) { + return jsonRpcError( + id, + A2A_ERROR_UNSUPPORTED_OPERATION, + `A2A method "${method}" is not supported by this deployment.`, + ); + } + if (method !== A2A_METHOD_SEND_MESSAGE) { + return jsonRpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method "${method}".`); + } + + let invocation: ReturnType; + try { + invocation = parseInvocation(params); + } catch (err) { + const error = toCommerceError(err); + const code = + error.code === 'PROTOCOL_UNSUPPORTED' + ? A2A_ERROR_UNSUPPORTED_OPERATION + : JSONRPC_INVALID_PARAMS; + // CommerceError messages are written for a client; nothing else is + // relayed. + return jsonRpcError(id, code, error.message); + } + + return this.execute(id, invocation); + } + + /** + * Replaced by pipeline execution; the transport above is complete and + * tested without it, which is the point of the split. + */ + private async execute( + id: JsonRpcId, + invocation: ReturnType, + ): Promise> { + void invocation; + return jsonRpcError( + id, + JSONRPC_INTERNAL_ERROR, + 'Resource execution over A2A is not available yet.', + ); } async health(): Promise { @@ -155,14 +300,22 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { res.writeHead(status, { 'content-type': A2A_JSON_MEDIA_TYPE }); res.end(JSON.stringify(body)); } +} - private writeJsonRpcError(res: ServerResponse, status: number, message: string): void { - this.writeJson(res, status, { - jsonrpc: '2.0', - id: null, - error: { code: -32601, message }, - }); +/** + * Reads the unconsumed request stream the gateway hands over. Stops at the cap + * rather than buffering whatever arrives. + */ +async function readBody(req: IncomingMessage, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buffer = chunk as Buffer; + total += buffer.length; + if (total > maxBytes) throw new CommerceError('INPUT_INVALID', 'Request body too large.'); + chunks.push(buffer); } + return Buffer.concat(chunks).toString('utf8'); } export function createA2aAdapter(options: A2aAdapterOptions = {}): A2aProtocolAdapter { diff --git a/src/protocols/a2a/constants.ts b/src/protocols/a2a/constants.ts index 92905b7..c236957 100644 --- a/src/protocols/a2a/constants.ts +++ b/src/protocols/a2a/constants.ts @@ -31,3 +31,32 @@ export const A2A_JSON_MEDIA_TYPE = 'application/json'; /** Card identity when the operator names none. Matches the MCP server name. */ export const A2A_DEFAULT_AGENT_NAME = 'agent-commerce'; + +/** + * Version negotiation header. A2A v1 carries the protocol version out of band, + * so a request that omits it is a client speaking an older negotiation + * convention — treated as unsupported rather than optimistically accepted. + */ +export const A2A_VERSION_HEADER = 'a2a-version'; + +/** The one JSON-RPC method this adapter serves. Not the legacy `message/send`. */ +export const A2A_METHOD_SEND_MESSAGE = 'SendMessage'; + +/** + * A2A methods that exist and are deliberately not served here. Kept apart from + * unknown methods so a caller learns which of the two they hit: a real method + * this deployment refuses, or a typo. One list, read by both the descriptor + * and the transport — a second copy would drift the moment one gains a method. + */ +export const A2A_UNSUPPORTED_METHODS: readonly string[] = [ + 'SendStreamingMessage', + 'GetTask', + 'ListTasks', + 'CancelTask', + 'SubscribeToTask', + 'CreateTaskPushNotificationConfig', + 'GetTaskPushNotificationConfig', + 'ListTaskPushNotificationConfigs', + 'DeleteTaskPushNotificationConfig', + 'GetExtendedAgentCard', +]; diff --git a/src/protocols/a2a/descriptor.ts b/src/protocols/a2a/descriptor.ts index 7ae84da..bdd76ee 100644 --- a/src/protocols/a2a/descriptor.ts +++ b/src/protocols/a2a/descriptor.ts @@ -7,7 +7,7 @@ * on purpose rather than by omission. */ import type { AdapterDescriptor } from '../../core/index.js'; -import { A2A_SPEC_VERSION } from './constants.js'; +import { A2A_SPEC_VERSION, A2A_UNSUPPORTED_METHODS } from './constants.js'; /** What this adapter actually implements. */ export const A2A_CAPABILITIES: readonly string[] = ['agent-card', 'jsonrpc', 'SendMessage']; @@ -19,17 +19,9 @@ export const A2A_CAPABILITIES: readonly string[] = ['agent-card', 'jsonrpc', 'Se * `/.well-known/agent-commerce` surface this verbatim. */ export const A2A_UNSUPPORTED: readonly string[] = [ - // Methods, named as the protocol names them. - 'SendStreamingMessage', - 'GetTask', - 'ListTasks', - 'CancelTask', - 'SubscribeToTask', - 'CreateTaskPushNotificationConfig', - 'GetTaskPushNotificationConfig', - 'ListTaskPushNotificationConfigs', - 'DeleteTaskPushNotificationConfig', - 'GetExtendedAgentCard', + // Methods, named as the protocol names them. Same list the transport + // rejects by name, so the descriptor cannot promise less than it refuses. + ...A2A_UNSUPPORTED_METHODS, // Transports other than the one binding served. 'HTTP+JSON/REST binding', 'gRPC binding', diff --git a/src/protocols/a2a/index.ts b/src/protocols/a2a/index.ts index 3a4a609..767c2a5 100644 --- a/src/protocols/a2a/index.ts +++ b/src/protocols/a2a/index.ts @@ -11,9 +11,11 @@ export { A2aProtocolAdapter, createA2aAdapter } from './adapter.js'; export { A2A_AGENT_CARD_PATH, A2A_DEFAULT_MOUNT_PATH, + A2A_METHOD_SEND_MESSAGE, A2A_PROTOCOL_BINDING, A2A_PROTOCOL_VERSION, A2A_SPEC_VERSION, + A2A_VERSION_HEADER, } from './constants.js'; export { A2A_CAPABILITIES, A2A_UNSUPPORTED } from './descriptor.js'; export type { A2aInvocation } from './message-mapping.js'; diff --git a/src/protocols/a2a/jsonrpc.ts b/src/protocols/a2a/jsonrpc.ts new file mode 100644 index 0000000..dcf0cb3 --- /dev/null +++ b/src/protocols/a2a/jsonrpc.ts @@ -0,0 +1,126 @@ +/** + * JSON-RPC 2.0 framing for the A2A binding. + * + * Transport errors only: this file decides whether a request *is* a valid A2A + * JSON-RPC call, never what the call means. Commerce outcomes are mapped + * elsewhere, so a malformed frame and a refused purchase can never be + * confused for one another. + * + * Every JSON-RPC-level failure is returned as a 200 with an `error` member, + * per the JSON-RPC over HTTP convention A2A clients expect; HTTP status codes + * are reserved for things that are not JSON-RPC at all (wrong verb, adapter + * down). + */ + +/** JSON-RPC 2.0 reserved codes. */ +export const JSONRPC_PARSE_ERROR = -32700; +export const JSONRPC_INVALID_REQUEST = -32600; +export const JSONRPC_METHOD_NOT_FOUND = -32601; +export const JSONRPC_INVALID_PARAMS = -32602; +export const JSONRPC_INTERNAL_ERROR = -32603; + +/** + * A2A's own `UnsupportedOperationError`. Used for a real A2A operation this + * deployment declines to serve — including an unsupported protocol version — + * as distinct from `METHOD_NOT_FOUND`, which means the method does not exist. + */ +export const A2A_ERROR_UNSUPPORTED_OPERATION = -32004; + +/** An id may legally be a string, a number or null; anything else is not one. */ +export type JsonRpcId = string | number | null; + +export interface JsonRpcErrorBody { + readonly code: number; + readonly message: string; +} + +export interface JsonRpcRequest { + readonly id: JsonRpcId; + readonly method: string; + readonly params: unknown; +} + +export type JsonRpcParseResult = + | { readonly ok: true; readonly request: JsonRpcRequest } + | { readonly ok: false; readonly id: JsonRpcId; readonly error: JsonRpcErrorBody }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Echoed back only when the request carried a usable one. */ +function readId(value: unknown): JsonRpcId { + if (typeof value === 'string' || typeof value === 'number') return value; + return null; +} + +export function jsonRpcResult(id: JsonRpcId, result: unknown): Record { + return { jsonrpc: '2.0', id, result }; +} + +export function jsonRpcError( + id: JsonRpcId, + code: number, + message: string, +): Record { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +export function parseJsonRpcRequest(rawBody: string): JsonRpcParseResult { + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + // The parser's own message names offsets and input fragments; neither is + // the caller's business, and echoing input is how bodies get reflected. + return { ok: false, id: null, error: { code: JSONRPC_PARSE_ERROR, message: 'Invalid JSON.' } }; + } + + if (Array.isArray(payload)) { + return { + ok: false, + id: null, + error: { + code: JSONRPC_INVALID_REQUEST, + message: 'Batch requests are not supported: send a single JSON-RPC request object.', + }, + }; + } + if (!isPlainObject(payload)) { + return { + ok: false, + id: null, + error: { code: JSONRPC_INVALID_REQUEST, message: 'Request must be a JSON-RPC 2.0 object.' }, + }; + } + + const id = readId(payload['id']); + if (payload['jsonrpc'] !== '2.0') { + return { + ok: false, + id, + error: { code: JSONRPC_INVALID_REQUEST, message: 'Request must set "jsonrpc" to "2.0".' }, + }; + } + const method = payload['method']; + if (typeof method !== 'string' || method.length === 0) { + return { + ok: false, + id, + error: { code: JSONRPC_INVALID_REQUEST, message: 'Request must carry a "method" string.' }, + }; + } + const params = payload['params']; + if (params !== undefined && !isPlainObject(params)) { + return { + ok: false, + id, + error: { + code: JSONRPC_INVALID_PARAMS, + message: 'Request "params" must be an object.', + }, + }; + } + + return { ok: true, request: { id, method, params: params ?? {} } }; +} diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts index eb8e900..6b38fff 100644 --- a/tests/integration/a2a-over-gateway.test.ts +++ b/tests/integration/a2a-over-gateway.test.ts @@ -70,6 +70,42 @@ async function startGateway(): Promise { return gateway; } +interface JsonRpcResponse { + jsonrpc: string; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string }; +} + +async function rpc( + gw: GatewayInstance, + payload: unknown, + headers: Record = { 'a2a-version': '1.0' }, +): Promise<{ statusCode: number; body: JsonRpcResponse }> { + const res = await gw.server.inject({ + method: 'POST', + url: '/a2a', + headers: { 'content-type': 'application/json', ...headers }, + payload: typeof payload === 'string' ? payload : JSON.stringify(payload), + }); + return { statusCode: res.statusCode, body: res.json() }; +} + +function sendMessage(data: unknown, id: string | number = 'req-1'): unknown { + return { + jsonrpc: '2.0', + id, + method: 'SendMessage', + params: { + message: { + role: 'ROLE_USER', + messageId: 'msg-1', + parts: [{ data, mediaType: 'application/json' }], + }, + }, + }; +} + describe('A2A agent card over the real gateway', () => { it('serves the card at the spec-fixed path with the gateway public base URL', async () => { const gw = await startGateway(); @@ -103,3 +139,117 @@ describe('A2A agent card over the real gateway', () => { expect(mcp.statusCode).toBe(405); }); }); + +describe('A2A JSON-RPC transport over the real gateway', () => { + it('reaches the adapter with the request body intact and answers as JSON-RPC', async () => { + const gw = await startGateway(); + const { statusCode, body } = await rpc(gw, sendMessage({ resource: 'weather_basic' })); + + expect(statusCode).toBe(200); + expect(body.jsonrpc).toBe('2.0'); + expect(body.id).toBe('req-1'); + // Phase 5 stops at the transport boundary: a well-formed call parses, + // then reports that execution is not wired rather than inventing a result. + expect(body.error?.code).toBe(-32603); + }); + + it('rejects the legacy message/send method name as unknown', async () => { + const gw = await startGateway(); + const { body } = await rpc(gw, { + jsonrpc: '2.0', + id: 1, + method: 'message/send', + params: { message: { role: 'ROLE_USER', parts: [{ data: { resource: 'weather_basic' } }] } }, + }); + expect(body.error?.code).toBe(-32601); + }); + + it.each([ + ['a known but unsupported operation', 'SendStreamingMessage', -32004], + ['another unsupported operation', 'GetTask', -32004], + ['a completely unknown method', 'DoSomething', -32601], + ])('distinguishes %s', async (_label, method, code) => { + const gw = await startGateway(); + const { body } = await rpc(gw, { jsonrpc: '2.0', id: 1, method, params: {} }); + expect(body.error?.code).toBe(code); + }); + + it.each([ + ['malformed JSON', '{"jsonrpc":', -32700], + ['a non-object request', '"hello"', -32600], + ['a batch request', '[{"jsonrpc":"2.0","id":1,"method":"SendMessage"}]', -32600], + ])('rejects %s', async (_label, payload, code) => { + const gw = await startGateway(); + const { statusCode, body } = await rpc(gw, payload); + expect(statusCode).toBe(200); + expect(body.error?.code).toBe(code); + }); + + it.each([ + ['a wrong jsonrpc version', { jsonrpc: '1.0', id: 1, method: 'SendMessage' }, -32600], + ['a missing method', { jsonrpc: '2.0', id: 1 }, -32600], + ['non-object params', { jsonrpc: '2.0', id: 1, method: 'SendMessage', params: [] }, -32602], + ])('rejects %s', async (_label, payload, code) => { + const gw = await startGateway(); + const { body } = await rpc(gw, payload); + expect(body.error?.code).toBe(code); + expect(body.id).toBe(1); + }); + + it('maps an invalid invocation envelope to invalid params', async () => { + const gw = await startGateway(); + const { body } = await rpc(gw, sendMessage({ input: { city: 'Berlin' } })); + expect(body.error?.code).toBe(-32602); + }); + + it('maps a legal but unsupported A2A structure to unsupported operation', async () => { + const gw = await startGateway(); + const { body } = await rpc(gw, { + jsonrpc: '2.0', + id: 'req-1', + method: 'SendMessage', + params: { + message: { role: 'ROLE_USER', parts: [{ text: 'give me the weather' }] }, + }, + }); + expect(body.error?.code).toBe(-32004); + }); + + it.each([ + ['a missing version header', {}], + ['an older version', { 'a2a-version': '0.3' }], + ['an unknown version', { 'a2a-version': '2.0' }], + ])('refuses %s', async (_label, headers) => { + const gw = await startGateway(); + const { statusCode, body } = await rpc(gw, sendMessage({ resource: 'weather_basic' }), { + 'content-type': 'application/json', + ...headers, + }); + expect(statusCode).toBe(200); + expect(body.error?.code).toBe(-32004); + expect(body.error?.message).toContain('1.0'); + }); + + it('answers 405 to a GET on the JSON-RPC mount', async () => { + const gw = await startGateway(); + const res = await gw.server.inject({ method: 'GET', url: '/a2a' }); + expect(res.statusCode).toBe(405); + expect(res.json().error?.code).toBe(-32600); + }); + + it('leaks no internals in any error message', async () => { + const gw = await startGateway(); + const responses = await Promise.all([ + rpc(gw, '{"jsonrpc":'), + rpc(gw, sendMessage({ resource: 7 })), + rpc(gw, { jsonrpc: '2.0', id: 1, method: 'GetTask' }), + rpc(gw, sendMessage({ resource: 'weather_basic' }), { 'content-type': 'application/json' }), + ]); + for (const { body } of responses) { + const message = body.error?.message ?? ''; + expect(message).not.toMatch(/\bat .*:\d+:\d+/); // stack frame + expect(message).not.toMatch(/[/\\](src|node_modules)[/\\]/); // path + expect(message).not.toMatch(/Error:|SQLITE|ZodError|TypeError/); + } + }); +}); From 95e30cdd8a0853c96cfe4e73b61c01ae3238b466 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 30 Aug 2026 12:37:21 +0200 Subject: [PATCH 06/10] feat(a2a): map SendMessage onto canonical execution pipeline --- src/protocols/a2a/adapter.ts | 104 +++++- src/protocols/a2a/message-mapping.ts | 29 +- tests/integration/a2a-over-gateway.test.ts | 28 +- .../protocols-a2a/pipeline-mapping.test.ts | 323 ++++++++++++++++++ 4 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 tests/unit/protocols-a2a/pipeline-mapping.test.ts diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts index e268992..3a98c1e 100644 --- a/src/protocols/a2a/adapter.ts +++ b/src/protocols/a2a/adapter.ts @@ -14,11 +14,16 @@ import { type AdapterDescriptor, type AdapterHealth, type AdapterHttpRoute, + type CanonicalRequest, CommerceError, + type CommerceErrorCode, type CommerceResource, + type ExecutionOutcome, type HttpProtocolAdapter, type ProtocolAdapterContext, toCommerceError, + toDeliverySummary, + toPaymentRequiredEnvelope, } from '../../core/index.js'; import { PACKAGE_VERSION } from '../../version.js'; import { buildAgentCard } from './agent-card.js'; @@ -42,9 +47,14 @@ import { JSONRPC_PARSE_ERROR, type JsonRpcId, jsonRpcError, + jsonRpcResult, parseJsonRpcRequest, } from './jsonrpc.js'; -import { parseInvocation } from './message-mapping.js'; +import { + type A2aInvocation, + extractPaymentSubmission, + parseInvocation, +} from './message-mapping.js'; import type { A2aAgentCard } from './types.js'; /** @@ -79,6 +89,10 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { private context: ProtocolAdapterContext | undefined; private started = false; private skills: readonly CommerceResource[] = []; + // Same defence in depth MCP applies: the card only advertises a2a-exposed + // resources, but nothing stops a caller naming any id, and the adapter must + // not rely on the pipeline alone to refuse one scoped to another protocol. + private skillsById: ReadonlyMap = new Map(); // Built once at start: resources are fixed at config load, and a card // rebuilt per request would let a discovery GET do work a caller controls // the cost of. @@ -115,6 +129,7 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { } this.skills = resources; + this.skillsById = new Map(resources.map((resource) => [resource.id, resource])); this.card = buildAgentCard({ name: this.agentName, description: this.agentDescription, @@ -265,19 +280,68 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { } /** - * Replaced by pipeline execution; the transport above is complete and - * tested without it, which is the point of the split. + * One accepted invocation, one `pipeline.execute()`. Nothing here prices a + * resource, inspects a proof or talks to a merchant backend — the adapter + * builds a `CanonicalRequest` and reads back what the pipeline decided. */ private async execute( id: JsonRpcId, - invocation: ReturnType, + invocation: A2aInvocation, ): Promise> { - void invocation; - return jsonRpcError( - id, - JSONRPC_INTERNAL_ERROR, - 'Resource execution over A2A is not available yet.', - ); + const context = this.context; + if (context === undefined) { + return jsonRpcError(id, JSONRPC_INTERNAL_ERROR, 'A2A adapter is not running.'); + } + + const resource = this.skillsById.get(invocation.resourceId); + if (resource === undefined) { + // Identical message whether the resource does not exist or is scoped to + // another protocol: a caller must not be able to probe for resources + // this deployment does not expose over A2A. + return jsonRpcError( + id, + JSONRPC_INVALID_PARAMS, + `Unknown canonical resource "${invocation.resourceId}".`, + ); + } + + const { input, payment } = extractPaymentSubmission(invocation.input, resource); + const request: CanonicalRequest = { + requestId: context.ids.next('a2a'), + resourceId: invocation.resourceId, + input, + protocol: 'a2a', + receivedAt: context.clock.nowIso(), + ...(payment !== undefined ? { payment } : {}), + }; + + try { + return jsonRpcResult(id, this.toResult(await context.pipeline.execute(request))); + } catch (err) { + const error = toCommerceError(err); + context.logger.warn( + { resourceId: invocation.resourceId, requestId: request.requestId, err: error.toInfo() }, + 'a2a adapter: execution failed', + ); + return jsonRpcError(id, jsonRpcCodeFor(error.code), error.message); + } + } + + /** + * Interim shape: terminal A2A Task mapping arrives with outcome mapping. + * Both branches use the canonical envelopes rather than an A2A-specific + * invention, so nothing here has to be unlearned then. + */ + private toResult(outcome: ExecutionOutcome): Record { + if (outcome.kind === 'payment-required') { + return { ...toPaymentRequiredEnvelope(outcome) }; + } + return { + kind: 'delivered', + resourceId: outcome.resourceId, + body: outcome.body, + delivery: toDeliverySummary(outcome), + }; } async health(): Promise { @@ -292,6 +356,7 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { this.started = false; this.card = undefined; this.skills = []; + this.skillsById = new Map(); this.context = undefined; } @@ -302,6 +367,25 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { } } +/** + * Commerce failures are not transport failures: a rejected payment or a + * missing resource is the caller's request being answered, not the frame + * being wrong. Only the two that genuinely describe the request map to a + * JSON-RPC param error; everything else stays internal until outcome mapping + * gives it a task state. + */ +function jsonRpcCodeFor(code: CommerceErrorCode): number { + switch (code) { + case 'RESOURCE_NOT_FOUND': + case 'INPUT_INVALID': + return JSONRPC_INVALID_PARAMS; + case 'PROTOCOL_UNSUPPORTED': + return A2A_ERROR_UNSUPPORTED_OPERATION; + default: + return JSONRPC_INTERNAL_ERROR; + } +} + /** * Reads the unconsumed request stream the gateway hands over. Stops at the cap * rather than buffering whatever arrives. diff --git a/src/protocols/a2a/message-mapping.ts b/src/protocols/a2a/message-mapping.ts index 06b099b..8b2b0fb 100644 --- a/src/protocols/a2a/message-mapping.ts +++ b/src/protocols/a2a/message-mapping.ts @@ -25,7 +25,12 @@ * successful, possibly *paid*, call for something they did not ask for. */ import { z } from 'zod'; -import { CommerceError } from '../../core/index.js'; +import { + CommerceError, + type CommerceResource, + PAYMENT_INPUT_FIELD, + type PaymentSubmission, +} from '../../core/index.js'; import { A2A_JSON_MEDIA_TYPE } from './constants.js'; /** The only role a request message may carry. A2A v1 spells roles this way. */ @@ -172,3 +177,25 @@ export function parseInvocation(rawParams: unknown): A2aInvocation { ...(message.messageId !== undefined ? { messageId: message.messageId } : {}), }; } + +/** + * Lifts a payment proof out of the reserved input field into the canonical + * `PaymentSubmission` the pipeline reads, leaving the rest of the input alone. + * + * The *convention* is shared with MCP — one reserved field named once in + * `core` — but the code is not: a cross-adapter import would make an A2A + * deployment's payment retry depend on the MCP SDK being installed. The + * adapter decides nothing about the payment here; it only moves it to where + * the pipeline looks, and the rail comes from the resource's own declaration. + */ +export function extractPaymentSubmission( + rawInput: Record, + resource: CommerceResource | undefined, +): { input: Record; payment?: PaymentSubmission } { + const { [PAYMENT_INPUT_FIELD]: proof, ...input } = rawInput; + const method = resource?.paymentMethods[0]; + if (typeof proof === 'string' && proof.length > 0 && method !== undefined) { + return { input, payment: { method, payload: proof } }; + } + return { input }; +} diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts index 6b38fff..1951cf0 100644 --- a/tests/integration/a2a-over-gateway.test.ts +++ b/tests/integration/a2a-over-gateway.test.ts @@ -9,6 +9,7 @@ */ import { afterEach, describe, expect, it } from 'vitest'; import type { GatewayConfig } from '../../src/config/index.js'; +import type { BackendExecutor } from '../../src/core/index.js'; import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; import { createA2aAdapter } from '../../src/protocols/a2a/index.js'; import type { A2aAgentCard } from '../../src/protocols/a2a/types.js'; @@ -60,12 +61,20 @@ function config(): GatewayConfig { }; } +/** No merchant is reachable from a test; the adapter must never call one anyway. */ +const backend: BackendExecutor = { + async call() { + return { status: 200, body: { forecast: 'sunny' }, headers: {}, durationMs: 1 }; + }, +}; + async function startGateway(): Promise { gateway = await createGateway({ config: config(), store: createFakeStore(), paymentProviders: [], protocolAdapters: [createMcpAdapter(), createA2aAdapter()], + backend, }); return gateway; } @@ -141,16 +150,25 @@ describe('A2A agent card over the real gateway', () => { }); describe('A2A JSON-RPC transport over the real gateway', () => { - it('reaches the adapter with the request body intact and answers as JSON-RPC', async () => { + it('carries a call through the gateway, the adapter and the pipeline to a delivery', async () => { const gw = await startGateway(); - const { statusCode, body } = await rpc(gw, sendMessage({ resource: 'weather_basic' })); + const { statusCode, body } = await rpc( + gw, + sendMessage({ resource: 'weather_basic', input: { city: 'Berlin' } }), + ); expect(statusCode).toBe(200); expect(body.jsonrpc).toBe('2.0'); expect(body.id).toBe('req-1'); - // Phase 5 stops at the transport boundary: a well-formed call parses, - // then reports that execution is not wired rather than inventing a result. - expect(body.error?.code).toBe(-32603); + expect(body.error).toBeUndefined(); + expect(body.result).toMatchObject({ kind: 'delivered', body: { forecast: 'sunny' } }); + }); + + it('refuses a resource the config does not expose over a2a', async () => { + const gw = await startGateway(); + const { body } = await rpc(gw, sendMessage({ resource: 'mcp_only', input: {} })); + expect(body.error?.code).toBe(-32602); + expect(body.error?.message).toContain('Unknown canonical resource'); }); it('rejects the legacy message/send method name as unknown', async () => { diff --git a/tests/unit/protocols-a2a/pipeline-mapping.test.ts b/tests/unit/protocols-a2a/pipeline-mapping.test.ts new file mode 100644 index 0000000..2100589 --- /dev/null +++ b/tests/unit/protocols-a2a/pipeline-mapping.test.ts @@ -0,0 +1,323 @@ +/** + * The A2A adapter against a spied `ExecutionPipeline`: one accepted invocation + * must produce exactly one canonical execution, carrying the resource id and + * input the caller sent and nothing the adapter invented. + */ +import { describe, expect, it, vi } from 'vitest'; +import { createResourceRegistry } from '../../../src/core/execution/index.js'; +import type { + CanonicalRequest, + Clock, + CommerceReceipt, + CommerceResource, + EventSink, + ExecutionOutcome, + ExecutionPipeline, + IdGenerator, + Logger, + ProtocolAdapterContext, + ResourceRegistry, +} from '../../../src/core/index.js'; +import { CommerceError, PAYMENT_INPUT_FIELD } from '../../../src/core/index.js'; +import { createA2aAdapter } from '../../../src/protocols/a2a/index.js'; + +const NOOP_LOGGER: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => NOOP_LOGGER, +}; + +const clock: Clock = { + now: () => new Date('2026-01-01T00:00:00.000Z'), + nowIso: () => '2026-01-01T00:00:00.000Z', + monotonicMs: () => 0, +}; + +const receipt: CommerceReceipt = { + id: 'rcpt-1', + requestId: 'a2a-1', + resourceId: 'market_report', + protocol: 'a2a', + deliveredAt: '2026-01-01T00:00:00.000Z', + backendStatus: 200, + durationMs: 3, +}; + +const free: CommerceResource = { + id: 'weather_basic', + name: 'Basic Weather', + inputSchema: { type: 'object', properties: { city: { type: 'string' } } }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/weather/{city}' }, + pricing: { type: 'free' }, + exposedVia: ['a2a'], + paymentMethods: [], +}; + +const paid: CommerceResource = { + id: 'market_report', + name: 'Premium Market Report', + inputSchema: { type: 'object', properties: { symbol: { type: 'string' } } }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['a2a'], + paymentMethods: ['x402'], +}; + +const mcpOnly: CommerceResource = { ...free, id: 'mcp_only', exposedVia: ['mcp'] }; + +const delivered: ExecutionOutcome = { + kind: 'delivered', + requestId: 'a2a-1', + resourceId: 'market_report', + backendStatus: 200, + body: { price: 42 }, + receipt, + durationMs: 3, +}; + +function setup(outcome: ExecutionOutcome | CommerceError = delivered) { + const execute = vi.fn(async (_request: CanonicalRequest): Promise => { + if (outcome instanceof CommerceError) throw outcome; + return outcome; + }); + const context: ProtocolAdapterContext = { + pipeline: { execute } as ExecutionPipeline, + resources: createResourceRegistry([free, paid, mcpOnly]) as ResourceRegistry, + events: { emit: async () => {} } as EventSink, + logger: NOOP_LOGGER, + clock, + ids: (() => { + let n = 0; + return { next: (prefix?: string) => `${prefix ?? 'id'}-${++n}` }; + })() as IdGenerator, + publicBaseUrl: 'https://gateway.example.com', + }; + return { execute, context }; +} + +/** The one canonical request the pipeline was handed. */ +function firstRequest(execute: { mock: { calls: unknown[][] } }): CanonicalRequest { + const request = execute.mock.calls[0]?.[0]; + if (request === undefined) throw new Error('pipeline was never called'); + return request as CanonicalRequest; +} + +interface JsonRpcResponse { + jsonrpc: string; + id: string | number | null; + result?: Record; + error?: { code: number; message: string }; +} + +/** Drives the mount handler with a minimal fake req/res pair. */ +async function send( + adapter: ReturnType, + data: unknown, +): Promise { + const payload = JSON.stringify({ + jsonrpc: '2.0', + id: 'req-1', + method: 'SendMessage', + params: { + message: { + role: 'ROLE_USER', + messageId: 'msg-1', + parts: [{ data, mediaType: 'application/json' }], + }, + }, + }); + let body = ''; + const res = { + headersSent: false, + writeHead() { + return res; + }, + end(chunk?: string) { + body = chunk ?? ''; + return res; + }, + }; + const req = Object.assign( + (async function* () { + yield Buffer.from(payload, 'utf8'); + })(), + { method: 'POST', headers: { 'a2a-version': '1.0' } }, + ); + await adapter.handleHttp(req as never, res as never); + return JSON.parse(body) as JsonRpcResponse; +} + +describe('A2A SendMessage onto the execution pipeline', () => { + it('runs exactly one execution per accepted invocation, with the canonical protocol', async () => { + const { execute, context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + + expect(execute).toHaveBeenCalledTimes(1); + const request = firstRequest(execute); + expect(request.protocol).toBe('a2a'); + expect(request.resourceId).toBe('market_report'); + expect(request.input).toEqual({ symbol: 'ETH' }); + expect(request.requestId).toMatch(/^a2a-/); + expect(response.result?.['kind']).toBe('delivered'); + }); + + it('preserves the input verbatim, including nested values', async () => { + const { execute, context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const input = { symbol: 'ETH', filters: { since: '2026-01-01', tags: ['a', 'b'] }, depth: 3 }; + await send(adapter, { resource: 'market_report', input }); + + expect(firstRequest(execute).input).toEqual(input); + }); + + it('lifts the reserved payment field into the canonical payment submission', async () => { + const { execute, context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + await send(adapter, { + resource: 'market_report', + input: { symbol: 'ETH', [PAYMENT_INPUT_FIELD]: 'base64-proof' }, + }); + + const request = firstRequest(execute); + expect(request.payment).toEqual({ method: 'x402', payload: 'base64-proof' }); + // The proof never reaches the merchant backend as resource input. + expect(request.input).toEqual({ symbol: 'ETH' }); + }); + + it('sends no payment for a free resource, whatever the caller put in the field', async () => { + const { execute, context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + await send(adapter, { + resource: 'weather_basic', + input: { city: 'Berlin', [PAYMENT_INPUT_FIELD]: 'base64-proof' }, + }); + + expect(firstRequest(execute).payment).toBeUndefined(); + }); + + it('never executes a resource that is not exposed over a2a', async () => { + const { execute, context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'mcp_only', input: {} }); + + expect(execute).not.toHaveBeenCalled(); + expect(response.error?.code).toBe(-32602); + expect(response.error?.message).toContain('Unknown canonical resource'); + }); + + it('answers an unknown resource exactly as it answers a hidden one', async () => { + const { context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const hidden = await send(adapter, { resource: 'mcp_only', input: {} }); + const missing = await send(adapter, { resource: 'does_not_exist', input: {} }); + + expect(hidden.error?.code).toBe(missing.error?.code); + expect(hidden.error?.message.replace('mcp_only', 'X')).toBe( + missing.error?.message.replace('does_not_exist', 'X'), + ); + }); + + it('returns the payment challenge the pipeline built, not one of its own', async () => { + const { context } = setup({ + kind: 'payment-required', + requestId: 'a2a-1', + resourceId: 'market_report', + requirement: { + id: 'req-1', + requestId: 'a2a-1', + resourceId: 'market_report', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + destination: '0x1111111111111111111111111111111111111111', + network: 'eip155:84532', + asset: '0x2222222222222222222222222222222222222222', + expiresAt: '2026-01-01T00:05:00.000Z', + challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, + }, + }); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + + expect(response.error).toBeUndefined(); + expect(response.result?.['payment']).toMatchObject({ amount: '0.01', currency: 'USDC' }); + }); + + it.each([ + [ + 'a rejected payment', + new CommerceError('PAYMENT_INVALID', 'Payment proof is invalid.'), + -32603, + ], + ['a backend failure', new CommerceError('BACKEND_ERROR', 'Backend returned 502.'), -32603], + ['invalid input', new CommerceError('INPUT_INVALID', 'Input does not match schema.'), -32602], + ])('maps %s thrown by the pipeline onto a JSON-RPC error', async (_label, error, code) => { + const { execute, context } = setup(error); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(response.result).toBeUndefined(); + expect(response.error?.code).toBe(code); + }); + + it('executes nothing when the envelope is rejected', async () => { + const { execute, context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + await send(adapter, { input: { symbol: 'ETH' } }); + await send(adapter, { resource: '' }); + + expect(execute).not.toHaveBeenCalled(); + }); +}); + +/** + * A negative that no runtime test can observe: the adapter reaching a merchant + * backend on its own would simply look like a working call here, because the + * pipeline fake never notices it was bypassed. Assert it at the source. + */ +describe('A2A adapter is not a client of anything', () => { + it('contains no HTTP client call, no payment verification and no price arithmetic', async () => { + const { readdir, readFile } = await import('node:fs/promises'); + const dir = new URL('../../../src/protocols/a2a/', import.meta.url); + const files = (await readdir(dir)).filter((name) => name.endsWith('.ts')); + expect(files.length).toBeGreaterThan(5); + + for (const file of files) { + const source = await readFile(new URL(file, dir), 'utf8'); + expect(source, `${file} must not call fetch`).not.toMatch(/\bfetch\s*\(/); + // `import type { IncomingMessage }` is fine; a value import of a client + // is not. + expect(source, `${file} must not import an HTTP client`).not.toMatch( + /^import\s+(?!type)[^;]*from\s+'node:(http|https|net|tls)'/m, + ); + expect(source, `${file} must not import undici or axios`).not.toMatch( + /from\s+'(undici|axios|got|node-fetch)'/, + ); + expect(source, `${file} must not verify or settle payments`).not.toMatch( + /\b(verifyPayment|settlePayment|createPaymentProvider)\b/, + ); + } + }); +}); From 4781ac9396fd1eaa068284fec25a71466847f14d Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 30 Aug 2026 13:24:13 +0200 Subject: [PATCH 07/10] feat(a2a): map execution outcomes to completed tasks --- src/protocols/a2a/adapter.ts | 85 ++++---- src/protocols/a2a/constants.ts | 11 + src/protocols/a2a/index.ts | 5 +- src/protocols/a2a/task-mapping.ts | 103 +++++++++ src/protocols/a2a/types.ts | 30 +++ tests/integration/a2a-over-gateway.test.ts | 26 ++- .../protocols-a2a/pipeline-mapping.test.ts | 197 +++++++++++++----- 7 files changed, 359 insertions(+), 98 deletions(-) create mode 100644 src/protocols/a2a/task-mapping.ts diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts index 3a98c1e..33b85a1 100644 --- a/src/protocols/a2a/adapter.ts +++ b/src/protocols/a2a/adapter.ts @@ -16,14 +16,11 @@ import { type AdapterHttpRoute, type CanonicalRequest, CommerceError, - type CommerceErrorCode, type CommerceResource, type ExecutionOutcome, type HttpProtocolAdapter, type ProtocolAdapterContext, toCommerceError, - toDeliverySummary, - toPaymentRequiredEnvelope, } from '../../core/index.js'; import { PACKAGE_VERSION } from '../../version.js'; import { buildAgentCard } from './agent-card.js'; @@ -55,7 +52,13 @@ import { extractPaymentSubmission, parseInvocation, } from './message-mapping.js'; -import type { A2aAgentCard } from './types.js'; +import { + completedTask, + failedTask, + paymentRequiredTask, + type TaskIdentity, +} from './task-mapping.js'; +import type { A2aAgentCard, A2aTask } from './types.js'; /** * The gateway mount already destroys a connection whose body passes its cap, @@ -295,13 +298,19 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { const resource = this.skillsById.get(invocation.resourceId); if (resource === undefined) { - // Identical message whether the resource does not exist or is scoped to - // another protocol: a caller must not be able to probe for resources - // this deployment does not expose over A2A. - return jsonRpcError( + // A commerce outcome, so a failed task rather than a JSON-RPC error — + // and identical whether the resource does not exist or is scoped to + // another protocol, so a caller cannot probe for what this deployment + // does not expose over A2A. + return this.taskResult( id, - JSONRPC_INVALID_PARAMS, - `Unknown canonical resource "${invocation.resourceId}".`, + failedTask( + new CommerceError( + 'RESOURCE_NOT_FOUND', + `Unknown canonical resource "${invocation.resourceId}".`, + ), + this.taskIdentity(context, context.ids.next('a2a')), + ), ); } @@ -314,36 +323,43 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { receivedAt: context.clock.nowIso(), ...(payment !== undefined ? { payment } : {}), }; + const identity = this.taskIdentity(context, request.requestId); try { - return jsonRpcResult(id, this.toResult(await context.pipeline.execute(request))); + const outcome: ExecutionOutcome = await context.pipeline.execute(request); + return this.taskResult( + id, + outcome.kind === 'payment-required' + ? paymentRequiredTask(outcome, identity) + : completedTask(outcome, identity), + ); } catch (err) { + // Whatever went wrong downstream is the caller's *answer*, not a broken + // frame — `toCommerceError` also strips an arbitrary Error's message, so + // nothing internal reaches the artifact. const error = toCommerceError(err); context.logger.warn( { resourceId: invocation.resourceId, requestId: request.requestId, err: error.toInfo() }, 'a2a adapter: execution failed', ); - return jsonRpcError(id, jsonRpcCodeFor(error.code), error.message); + return this.taskResult(id, failedTask(error, identity)); } } - /** - * Interim shape: terminal A2A Task mapping arrives with outcome mapping. - * Both branches use the canonical envelopes rather than an A2A-specific - * invention, so nothing here has to be unlearned then. - */ - private toResult(outcome: ExecutionOutcome): Record { - if (outcome.kind === 'payment-required') { - return { ...toPaymentRequiredEnvelope(outcome) }; - } + private taskIdentity(context: ProtocolAdapterContext, requestId: string): TaskIdentity { return { - kind: 'delivered', - resourceId: outcome.resourceId, - body: outcome.body, - delivery: toDeliverySummary(outcome), + taskId: requestId, + contextId: context.ids.next('a2a-ctx'), + artifactId: context.ids.next('a2a-artifact'), + timestamp: context.clock.nowIso(), }; } + /** A2A's JSON-RPC result wraps the terminal task. */ + private taskResult(id: JsonRpcId, task: A2aTask): Record { + return jsonRpcResult(id, { task }); + } + async health(): Promise { const checkedAt = this.context?.clock.nowIso() ?? new Date().toISOString(); if (!this.started || this.card === undefined) { @@ -367,25 +383,6 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter { } } -/** - * Commerce failures are not transport failures: a rejected payment or a - * missing resource is the caller's request being answered, not the frame - * being wrong. Only the two that genuinely describe the request map to a - * JSON-RPC param error; everything else stays internal until outcome mapping - * gives it a task state. - */ -function jsonRpcCodeFor(code: CommerceErrorCode): number { - switch (code) { - case 'RESOURCE_NOT_FOUND': - case 'INPUT_INVALID': - return JSONRPC_INVALID_PARAMS; - case 'PROTOCOL_UNSUPPORTED': - return A2A_ERROR_UNSUPPORTED_OPERATION; - default: - return JSONRPC_INTERNAL_ERROR; - } -} - /** * Reads the unconsumed request stream the gateway hands over. Stops at the cap * rather than buffering whatever arrives. diff --git a/src/protocols/a2a/constants.ts b/src/protocols/a2a/constants.ts index c236957..b60f368 100644 --- a/src/protocols/a2a/constants.ts +++ b/src/protocols/a2a/constants.ts @@ -60,3 +60,14 @@ export const A2A_UNSUPPORTED_METHODS: readonly string[] = [ 'DeleteTaskPushNotificationConfig', 'GetExtendedAgentCard', ]; + +/** + * Terminal task states. Only these two are ever returned: a synchronous + * execution is finished by the time the response is written, and no task + * store exists for a caller to poll a non-terminal one against. + */ +export const A2A_TASK_STATE_COMPLETED = 'TASK_STATE_COMPLETED'; +export const A2A_TASK_STATE_FAILED = 'TASK_STATE_FAILED'; + +/** Role an agent-authored message carries, as A2A v1 spells it. */ +export const A2A_AGENT_ROLE = 'ROLE_AGENT'; diff --git a/src/protocols/a2a/index.ts b/src/protocols/a2a/index.ts index 767c2a5..6c7b668 100644 --- a/src/protocols/a2a/index.ts +++ b/src/protocols/a2a/index.ts @@ -15,9 +15,12 @@ export { A2A_PROTOCOL_BINDING, A2A_PROTOCOL_VERSION, A2A_SPEC_VERSION, + A2A_TASK_STATE_COMPLETED, + A2A_TASK_STATE_FAILED, A2A_VERSION_HEADER, } from './constants.js'; export { A2A_CAPABILITIES, A2A_UNSUPPORTED } from './descriptor.js'; export type { A2aInvocation } from './message-mapping.js'; export { A2A_USER_ROLE, parseInvocation } from './message-mapping.js'; -export type { A2aAgentCard, A2aAgentSkill } from './types.js'; +export { completedTask, failedTask, paymentRequiredTask } from './task-mapping.js'; +export type { A2aAgentCard, A2aAgentSkill, A2aArtifact, A2aTask } from './types.js'; diff --git a/src/protocols/a2a/task-mapping.ts b/src/protocols/a2a/task-mapping.ts new file mode 100644 index 0000000..85891d2 --- /dev/null +++ b/src/protocols/a2a/task-mapping.ts @@ -0,0 +1,103 @@ +/** + * Execution outcomes as terminal A2A Tasks. + * + * A2A models the output of an execution as a Task carrying Artifacts, so that + * is what a completed purchase comes back as — not a plain Message, which + * models conversation rather than result. + * + * The critical split this file enforces: a commerce outcome is never a + * JSON-RPC error. Payment required, an unknown resource, input that fails the + * resource schema, a backend that broke — all of those are *answers*, and they + * come back as a terminal Task in the JSON-RPC `result`. Only a malformed or + * unsupported A2A request gets a JSON-RPC error. A client that treats a + * transport failure and a refused purchase the same way is a client that + * retries a 402 as if the gateway were broken. + * + * Every payload inside an artifact is an existing canonical envelope, + * verbatim. There is no A2A-specific delivery, payment-required or error + * schema to keep in step with the HTTP and MCP ones. + */ +import { + type CommerceError, + DELIVERY_SUMMARY_META_KEY, + type DeliveredOutcome, + type PaymentRequiredOutcome, + toDeliverySummary, + toErrorEnvelope, + toPaymentRequiredEnvelope, +} from '../../core/index.js'; +import { + A2A_JSON_MEDIA_TYPE, + A2A_TASK_STATE_COMPLETED, + A2A_TASK_STATE_FAILED, +} from './constants.js'; +import type { A2aArtifact, A2aTask } from './types.js'; + +export interface TaskIdentity { + /** Gateway request id, reused so a task correlates with receipts and events. */ + readonly taskId: string; + /** Fresh every time: nothing here can be continued, so nothing shares a context. */ + readonly contextId: string; + readonly artifactId: string; + readonly timestamp: string; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * A data part's payload must be a JSON object, but a merchant backend may + * legitimately return a string, a number or an array. Those are wrapped under + * `value` rather than dropped or stringified — one predictable rule a caller + * can code against, instead of a shape that depends on what the backend felt + * like returning. + */ +function dataPayload(body: unknown): Record { + return isPlainObject(body) ? body : { value: body ?? null }; +} + +function task( + identity: TaskIdentity, + state: string, + artifact: Omit, +): A2aTask { + return { + id: identity.taskId, + contextId: identity.contextId, + status: { state, timestamp: identity.timestamp }, + artifacts: [{ artifactId: identity.artifactId, ...artifact }], + }; +} + +export function completedTask(outcome: DeliveredOutcome, identity: TaskIdentity): A2aTask { + return task(identity, A2A_TASK_STATE_COMPLETED, { + name: outcome.resourceId, + parts: [{ data: dataPayload(outcome.body), mediaType: A2A_JSON_MEDIA_TYPE }], + // Same meta key MCP attaches its summary under, so a buyer reads the + // record of their own purchase the same way on either protocol. + metadata: { [DELIVERY_SUMMARY_META_KEY]: { ...toDeliverySummary(outcome) } }, + }); +} + +/** + * Terminal, not `input-required`: without a task store there is nothing to + * continue, and advertising a resumable task the adapter cannot resume would + * be worse than saying plainly that this attempt is over. The caller retries + * by sending a new message carrying the proof. + */ +export function paymentRequiredTask( + outcome: PaymentRequiredOutcome, + identity: TaskIdentity, +): A2aTask { + return task(identity, A2A_TASK_STATE_FAILED, { + name: outcome.resourceId, + parts: [{ data: { ...toPaymentRequiredEnvelope(outcome) }, mediaType: A2A_JSON_MEDIA_TYPE }], + }); +} + +export function failedTask(error: CommerceError, identity: TaskIdentity): A2aTask { + return task(identity, A2A_TASK_STATE_FAILED, { + parts: [{ data: { ...toErrorEnvelope(error) }, mediaType: A2A_JSON_MEDIA_TYPE }], + }); +} diff --git a/src/protocols/a2a/types.ts b/src/protocols/a2a/types.ts index f8ddbb9..607cabc 100644 --- a/src/protocols/a2a/types.ts +++ b/src/protocols/a2a/types.ts @@ -54,3 +54,33 @@ export interface A2aAgentCard { readonly defaultOutputModes: readonly string[]; readonly skills: readonly A2aAgentSkill[]; } + +/** A structured data part — the only part kind this adapter emits. */ +export interface A2aDataPart { + readonly data: Record; + readonly mediaType: string; +} + +export interface A2aArtifact { + readonly artifactId: string; + readonly name?: string; + readonly parts: readonly A2aDataPart[]; + readonly metadata?: Record; +} + +export interface A2aTaskStatus { + readonly state: string; + readonly timestamp: string; +} + +/** + * A terminal task. No `history`, and no id a caller can fetch later: tasks are + * ephemeral representations of a synchronous result, which is why `GetTask` is + * unsupported rather than missing. + */ +export interface A2aTask { + readonly id: string; + readonly contextId: string; + readonly status: A2aTaskStatus; + readonly artifacts: readonly A2aArtifact[]; +} diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts index 1951cf0..8cddabf 100644 --- a/tests/integration/a2a-over-gateway.test.ts +++ b/tests/integration/a2a-over-gateway.test.ts @@ -12,7 +12,7 @@ import type { GatewayConfig } from '../../src/config/index.js'; import type { BackendExecutor } from '../../src/core/index.js'; import { createGateway, type GatewayInstance } from '../../src/gateway/index.js'; import { createA2aAdapter } from '../../src/protocols/a2a/index.js'; -import type { A2aAgentCard } from '../../src/protocols/a2a/types.js'; +import type { A2aAgentCard, A2aTask } from '../../src/protocols/a2a/types.js'; import { createMcpAdapter } from '../../src/protocols/mcp/index.js'; import { createFakeStore } from '../unit/gateway/helpers.js'; @@ -82,7 +82,7 @@ async function startGateway(): Promise { interface JsonRpcResponse { jsonrpc: string; id: string | number | null; - result?: unknown; + result?: { task?: A2aTask }; error?: { code: number; message: string }; } @@ -161,14 +161,28 @@ describe('A2A JSON-RPC transport over the real gateway', () => { expect(body.jsonrpc).toBe('2.0'); expect(body.id).toBe('req-1'); expect(body.error).toBeUndefined(); - expect(body.result).toMatchObject({ kind: 'delivered', body: { forecast: 'sunny' } }); + const task = body.result?.task; + expect(task?.status.state).toBe('TASK_STATE_COMPLETED'); + expect(task?.artifacts[0]?.parts[0]?.data).toEqual({ forecast: 'sunny' }); }); - it('refuses a resource the config does not expose over a2a', async () => { + it('refuses a resource the config does not expose over a2a, as a failed task', async () => { const gw = await startGateway(); const { body } = await rpc(gw, sendMessage({ resource: 'mcp_only', input: {} })); - expect(body.error?.code).toBe(-32602); - expect(body.error?.message).toContain('Unknown canonical resource'); + + // A commerce answer, not a broken frame: the JSON-RPC layer stays clean. + expect(body.error).toBeUndefined(); + expect(body.result?.task?.status.state).toBe('TASK_STATE_FAILED'); + expect(body.result?.task?.artifacts[0]?.parts[0]?.data['code']).toBe('RESOURCE_NOT_FOUND'); + }); + + it('answers input that fails the resource schema with a failed task', async () => { + const gw = await startGateway(); + const { body } = await rpc(gw, sendMessage({ resource: 'weather_basic', input: { city: 42 } })); + + expect(body.error).toBeUndefined(); + expect(body.result?.task?.status.state).toBe('TASK_STATE_FAILED'); + expect(body.result?.task?.artifacts[0]?.parts[0]?.data['code']).toBe('INPUT_INVALID'); }); it('rejects the legacy message/send method name as unknown', async () => { diff --git a/tests/unit/protocols-a2a/pipeline-mapping.test.ts b/tests/unit/protocols-a2a/pipeline-mapping.test.ts index 2100589..db5464e 100644 --- a/tests/unit/protocols-a2a/pipeline-mapping.test.ts +++ b/tests/unit/protocols-a2a/pipeline-mapping.test.ts @@ -10,6 +10,7 @@ import type { Clock, CommerceReceipt, CommerceResource, + DeliveredOutcome, EventSink, ExecutionOutcome, ExecutionPipeline, @@ -18,8 +19,13 @@ import type { ProtocolAdapterContext, ResourceRegistry, } from '../../../src/core/index.js'; -import { CommerceError, PAYMENT_INPUT_FIELD } from '../../../src/core/index.js'; +import { + CommerceError, + DELIVERY_SUMMARY_META_KEY, + PAYMENT_INPUT_FIELD, +} from '../../../src/core/index.js'; import { createA2aAdapter } from '../../../src/protocols/a2a/index.js'; +import type { A2aTask } from '../../../src/protocols/a2a/types.js'; const NOOP_LOGGER: Logger = { debug: () => {}, @@ -67,6 +73,25 @@ const paid: CommerceResource = { const mcpOnly: CommerceResource = { ...free, id: 'mcp_only', exposedVia: ['mcp'] }; +const paymentRequired: ExecutionOutcome = { + kind: 'payment-required', + requestId: 'a2a-1', + resourceId: 'market_report', + requirement: { + id: 'req-1', + requestId: 'a2a-1', + resourceId: 'market_report', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + destination: '0x1111111111111111111111111111111111111111', + network: 'eip155:84532', + asset: '0x2222222222222222222222222222222222222222', + expiresAt: '2026-01-01T00:05:00.000Z', + challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, + }, +}; + const delivered: ExecutionOutcome = { kind: 'delivered', requestId: 'a2a-1', @@ -107,10 +132,27 @@ function firstRequest(execute: { mock: { calls: unknown[][] } }): CanonicalReque interface JsonRpcResponse { jsonrpc: string; id: string | number | null; - result?: Record; + result?: { task?: A2aTask }; error?: { code: number; message: string }; } +/** The terminal task a commerce outcome comes back as. */ +function task(response: JsonRpcResponse): A2aTask { + const value = response.result?.task; + if (value === undefined) { + throw new Error(`expected a task, got ${JSON.stringify(response)}`); + } + return value; +} + +/** The single data payload inside the task's single artifact. */ +function artifactData(response: JsonRpcResponse): Record { + const part = task(response).artifacts[0]?.parts[0]; + if (part === undefined) throw new Error('task carried no artifact part'); + expect(part.mediaType).toBe('application/json'); + return part.data; +} + /** Drives the mount handler with a minimal fake req/res pair. */ async function send( adapter: ReturnType, @@ -163,7 +205,7 @@ describe('A2A SendMessage onto the execution pipeline', () => { expect(request.resourceId).toBe('market_report'); expect(request.input).toEqual({ symbol: 'ETH' }); expect(request.requestId).toMatch(/^a2a-/); - expect(response.result?.['kind']).toBe('delivered'); + expect(task(response).status.state).toBe('TASK_STATE_COMPLETED'); }); it('preserves the input verbatim, including nested values', async () => { @@ -214,8 +256,8 @@ describe('A2A SendMessage onto the execution pipeline', () => { const response = await send(adapter, { resource: 'mcp_only', input: {} }); expect(execute).not.toHaveBeenCalled(); - expect(response.error?.code).toBe(-32602); - expect(response.error?.message).toContain('Unknown canonical resource'); + expect(task(response).status.state).toBe('TASK_STATE_FAILED'); + expect(artifactData(response)['code']).toBe('RESOURCE_NOT_FOUND'); }); it('answers an unknown resource exactly as it answers a hidden one', async () => { @@ -223,62 +265,48 @@ describe('A2A SendMessage onto the execution pipeline', () => { const adapter = createA2aAdapter(); await adapter.start(context); - const hidden = await send(adapter, { resource: 'mcp_only', input: {} }); - const missing = await send(adapter, { resource: 'does_not_exist', input: {} }); + const hidden = artifactData(await send(adapter, { resource: 'mcp_only', input: {} })); + const missing = artifactData(await send(adapter, { resource: 'does_not_exist', input: {} })); - expect(hidden.error?.code).toBe(missing.error?.code); - expect(hidden.error?.message.replace('mcp_only', 'X')).toBe( - missing.error?.message.replace('does_not_exist', 'X'), + expect(hidden['code']).toBe(missing['code']); + expect(String(hidden['message']).replace('mcp_only', 'X')).toBe( + String(missing['message']).replace('does_not_exist', 'X'), ); }); it('returns the payment challenge the pipeline built, not one of its own', async () => { - const { context } = setup({ - kind: 'payment-required', - requestId: 'a2a-1', - resourceId: 'market_report', - requirement: { - id: 'req-1', - requestId: 'a2a-1', - resourceId: 'market_report', - provider: 'x402', - amount: '0.01', - currency: 'USDC', - destination: '0x1111111111111111111111111111111111111111', - network: 'eip155:84532', - asset: '0x2222222222222222222222222222222222222222', - expiresAt: '2026-01-01T00:05:00.000Z', - challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, - }, - }); + const { context } = setup(paymentRequired); const adapter = createA2aAdapter(); await adapter.start(context); const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); expect(response.error).toBeUndefined(); - expect(response.result?.['payment']).toMatchObject({ amount: '0.01', currency: 'USDC' }); + expect(task(response).status.state).toBe('TASK_STATE_FAILED'); + const data = artifactData(response); + expect(data['status']).toBe('payment-required'); + expect(data['payment']).toMatchObject({ amount: '0.01', currency: 'USDC' }); }); it.each([ - [ - 'a rejected payment', - new CommerceError('PAYMENT_INVALID', 'Payment proof is invalid.'), - -32603, - ], - ['a backend failure', new CommerceError('BACKEND_ERROR', 'Backend returned 502.'), -32603], - ['invalid input', new CommerceError('INPUT_INVALID', 'Input does not match schema.'), -32602], - ])('maps %s thrown by the pipeline onto a JSON-RPC error', async (_label, error, code) => { - const { execute, context } = setup(error); - const adapter = createA2aAdapter(); - await adapter.start(context); - - const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); - - expect(execute).toHaveBeenCalledTimes(1); - expect(response.result).toBeUndefined(); - expect(response.error?.code).toBe(code); - }); + ['a rejected payment', new CommerceError('PAYMENT_INVALID', 'Payment proof is invalid.')], + ['a backend failure', new CommerceError('BACKEND_ERROR', 'Backend returned 502.')], + ['invalid input', new CommerceError('INPUT_INVALID', 'Input does not match schema.')], + ])( + 'answers %s thrown by the pipeline with a failed task, not a JSON-RPC error', + async (_label, error) => { + const { execute, context } = setup(error); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(response.error).toBeUndefined(); + expect(task(response).status.state).toBe('TASK_STATE_FAILED'); + expect(artifactData(response)['code']).toBe(error.code); + }, + ); it('executes nothing when the envelope is rejected', async () => { const { execute, context } = setup(); @@ -321,3 +349,78 @@ describe('A2A adapter is not a client of anything', () => { } }); }); + +describe('A2A terminal task representation', () => { + it('returns a completed task whose artifact carries the canonical body', async () => { + const { context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + const result = task(response); + + expect(result.id).toMatch(/^a2a-/); + expect(result.contextId).toMatch(/^a2a-ctx-/); + expect(result.status).toEqual({ + state: 'TASK_STATE_COMPLETED', + timestamp: '2026-01-01T00:00:00.000Z', + }); + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0]?.artifactId).toMatch(/^a2a-artifact-/); + expect(result.artifacts[0]?.name).toBe('market_report'); + expect(artifactData(response)).toEqual({ price: 42 }); + }); + + it('attaches the delivery summary under the same meta key MCP uses', async () => { + const { context } = setup(); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + const summary = task(response).artifacts[0]?.metadata?.[DELIVERY_SUMMARY_META_KEY]; + + expect(summary).toMatchObject({ resourceId: 'market_report' }); + }); + + it.each([ + ['a string body', 'plain text', { value: 'plain text' }], + ['an array body', [1, 2], { value: [1, 2] }], + ['a null body', null, { value: null }], + ])('wraps %s so the data part is always an object', async (_label, body, expected) => { + const { context } = setup({ ...(delivered as DeliveredOutcome), body }); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: {} }); + expect(artifactData(response)).toEqual(expected); + }); + + it('sanitises an unexpected exception: nothing internal reaches the artifact', async () => { + const boom = new Error('connect ECONNREFUSED 10.0.0.5:5432 while reading /etc/secret.key'); + const { context } = setup(boom as unknown as CommerceError); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const response = await send(adapter, { resource: 'market_report', input: { symbol: 'ETH' } }); + const serialised = JSON.stringify(response); + + expect(task(response).status.state).toBe('TASK_STATE_FAILED'); + expect(serialised).not.toContain('ECONNREFUSED'); + expect(serialised).not.toContain('10.0.0.5'); + expect(serialised).not.toContain('/etc/secret.key'); + expect(artifactData(response)['code']).toBe('INTERNAL_ERROR'); + }); + + it.each([ + ['a delivery', delivered], + ['a payment challenge', paymentRequired], + ['a domain failure', new CommerceError('BACKEND_ERROR', 'Backend returned 502.')], + ])('always reaches a terminal state for %s', async (_label, outcome) => { + const { context } = setup(outcome); + const adapter = createA2aAdapter(); + await adapter.start(context); + + const state = task(await send(adapter, { resource: 'market_report', input: {} })).status.state; + expect(['TASK_STATE_COMPLETED', 'TASK_STATE_FAILED']).toContain(state); + }); +}); From e4349c051e91749c15451069c041c6b615a7d3f8 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 30 Aug 2026 14:00:10 +0200 Subject: [PATCH 08/10] feat(runtime): wire a2a into gateway discovery and diagnostics --- src/cli/commands/doctor.ts | 36 ++++++++- src/cli/commands/validate.ts | 2 +- src/gateway/main.ts | 11 +++ tests/integration/a2a-over-gateway.test.ts | 85 ++++++++++++++++++++++ tests/unit/cli/doctor.test.ts | 69 +++++++++++++++++- 5 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 6b926f2..0da8505 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -7,6 +7,15 @@ import { findNetworkProfile, resolveDeploymentMode, } from '../../payments/x402/networks.js'; +// Narrow modules, not the package barrel: the CLI must pull in no protocol +// SDK, and these two are plain constants and strings. +import { + A2A_AGENT_CARD_PATH, + A2A_PROTOCOL_BINDING, + A2A_PROTOCOL_VERSION, + A2A_SPEC_VERSION, +} from '../../protocols/a2a/constants.js'; +import { A2A_UNSUPPORTED } from '../../protocols/a2a/descriptor.js'; import { createSqliteReceiptStore } from '../../storage/receipts/index.js'; import { type ConfigLoader, type GatewayConfig, loadConfigDynamic } from '../lib/config-client.js'; import { type FetchLike, fetchJson } from '../lib/http.js'; @@ -265,10 +274,35 @@ export async function runDoctor( checks.push({ name: 'Protocols', status: 'FAIL', detail: 'well-known document unreachable' }); } else { const mcpMountPath = config.protocols.mcp.enabled ? config.protocols.mcp.mountPath : undefined; + const a2aMountPath = config.protocols.a2a.enabled ? config.protocols.a2a.mountPath : undefined; checks.push({ name: 'Protocols', status: 'PASS', - detail: `http=${config.protocols.http.enabled ? 'on' : 'off'} mcp=${config.protocols.mcp.enabled ? `on (${mcpMountPath})` : 'off'}`, + detail: `http=${config.protocols.http.enabled ? 'on' : 'off'} mcp=${config.protocols.mcp.enabled ? `on (${mcpMountPath})` : 'off'} a2a=${config.protocols.a2a.enabled ? `on (${a2aMountPath})` : 'off'}`, + }); + } + + // 5b. A2A specifics. Reported from the pins rather than from the live + // gateway so the spec revision, the negotiation version and the binding are + // three separate, named values an operator can check against a client — the + // first two look alike and are routinely conflated. + if (config === undefined) { + checks.push({ name: 'A2A', status: 'WARN', detail: 'skipped — config invalid' }); + } else if (!config.protocols.a2a.enabled) { + checks.push({ name: 'A2A', status: 'INFO', detail: 'disabled' }); + } else { + checks.push({ + name: 'A2A', + status: 'PASS', + detail: `experimental · spec ${A2A_SPEC_VERSION} · protocol ${A2A_PROTOCOL_VERSION} · binding ${A2A_PROTOCOL_BINDING} · mount ${config.protocols.a2a.mountPath} · card ${A2A_AGENT_CARD_PATH}`, + }); + // Listed in full, never summarised as a count: "18 unsupported" tells an + // operator nothing about whether the one operation their client needs is + // among them. + checks.push({ + name: 'A2A unsupported', + status: 'INFO', + detail: A2A_UNSUPPORTED.join(', '), }); } diff --git a/src/cli/commands/validate.ts b/src/cli/commands/validate.ts index a910026..069487a 100644 --- a/src/cli/commands/validate.ts +++ b/src/cli/commands/validate.ts @@ -85,7 +85,7 @@ export async function runValidate( io.stdout(` merchant: ${config.merchant.name} (${config.merchant.id})`); io.stdout(` resources: ${config.resources.length}`); io.stdout( - ` protocols: http=${config.protocols.http.enabled ? 'on' : 'off'} mcp=${config.protocols.mcp.enabled ? 'on' : 'off'}`, + ` protocols: http=${config.protocols.http.enabled ? 'on' : 'off'} mcp=${config.protocols.mcp.enabled ? 'on' : 'off'} a2a=${config.protocols.a2a.enabled ? 'on' : 'off'}`, ); io.stdout(` payments: x402=${config.payments.x402?.enabled === true ? 'on' : 'off'}`); return 0; diff --git a/src/gateway/main.ts b/src/gateway/main.ts index d4043c6..dc1e753 100644 --- a/src/gateway/main.ts +++ b/src/gateway/main.ts @@ -21,6 +21,7 @@ import { loadConfig } from '../config/index.js'; import type { PaymentProvider, ProtocolAdapter, ReceiptStore } from '../core/index.js'; import { CommerceError, isCommerceError } from '../core/index.js'; import { createX402PaymentProvider } from '../payments/x402/index.js'; +import { createA2aAdapter } from '../protocols/a2a/index.js'; import { createMcpAdapter } from '../protocols/mcp/index.js'; import { createSqliteReceiptStore } from '../storage/receipts/index.js'; @@ -93,6 +94,16 @@ async function main(): Promise { if (config.protocols.mcp.enabled) { protocolAdapters.push(createMcpAdapter({ mountPath: config.protocols.mcp.mountPath })); } + if (config.protocols.a2a.enabled) { + protocolAdapters.push( + createA2aAdapter({ + mountPath: config.protocols.a2a.mountPath, + // The Agent Card names the merchant, not the software: a client + // picking between agents is choosing whose resources to buy. + agentName: config.merchant.name, + }), + ); + } const gateway = await createGateway({ config, diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts index 8cddabf..45cd9e2 100644 --- a/tests/integration/a2a-over-gateway.test.ts +++ b/tests/integration/a2a-over-gateway.test.ts @@ -285,3 +285,88 @@ describe('A2A JSON-RPC transport over the real gateway', () => { } }); }); + +describe('A2A in gateway discovery', () => { + it('publishes the A2A descriptor through the existing adapter mechanism', async () => { + const gw = await startGateway(); + const doc = ( + await gw.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json<{ + protocols: Record; + adapters: { + name: string; + status: string; + supportedSpec: string; + unsupported?: string[]; + health: { status: string }; + }[]; + }>(); + + const a2a = doc.adapters.find((adapter) => adapter.name === 'a2a'); + expect(a2a?.status).toBe('experimental'); + expect(a2a?.supportedSpec).toBe('1.0.0'); + expect(a2a?.unsupported).toContain('SendStreamingMessage'); + expect(a2a?.health.status).toBe('pass'); + expect(doc.protocols['a2a']).toEqual({ enabled: true, mountPath: '/a2a' }); + + // The neighbouring adapter's own entry is untouched. + expect(doc.adapters.find((adapter) => adapter.name === 'mcp')?.status).toBe('stable'); + }); + + it('omits the adapter entirely when a2a is not mounted', async () => { + gateway = await createGateway({ + config: { + ...config(), + protocols: { ...config().protocols, a2a: { enabled: false, mountPath: '/a2a' } }, + }, + store: createFakeStore(), + paymentProviders: [], + protocolAdapters: [createMcpAdapter()], + backend, + }); + + const doc = ( + await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json<{ adapters: { name: string }[] }>(); + expect(doc.adapters.map((adapter) => adapter.name)).toEqual(['mcp']); + + // Nothing serves the card path when no adapter claims it. + const card = await gateway.server.inject({ + method: 'GET', + url: '/.well-known/agent-card.json', + }); + expect(card.statusCode).toBe(404); + }); + + it('keeps MCP serving when the A2A adapter fails to start', async () => { + const broken = createA2aAdapter(); + broken.start = async () => { + throw new Error('a2a could not start'); + }; + + gateway = await createGateway({ + config: config(), + store: createFakeStore(), + paymentProviders: [], + protocolAdapters: [createMcpAdapter(), broken], + backend, + }); + + const doc = ( + await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' }) + ).json<{ adapters: { name: string; health: { status: string; detail?: string } }[] }>(); + + const a2a = doc.adapters.find((adapter) => adapter.name === 'a2a'); + expect(a2a?.health.status).toBe('fail'); + // The failure reason is internal; the anonymous route must not carry it. + expect(a2a?.health.detail).toBeUndefined(); + expect(doc.adapters.find((adapter) => adapter.name === 'mcp')?.health.status).toBe('pass'); + + // MCP still answers, and no A2A route was mounted. + expect((await gateway.server.inject({ method: 'GET', url: '/mcp' })).statusCode).toBe(405); + expect( + (await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-card.json' })) + .statusCode, + ).toBe(404); + }); +}); diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts index 3e7f738..ec060c5 100644 --- a/tests/unit/cli/doctor.test.ts +++ b/tests/unit/cli/doctor.test.ts @@ -710,7 +710,7 @@ describe('runDoctor — additional derivation and error-recovery branches', () = }, ); const protocols = report.checks.find((c) => c.name === 'Protocols'); - expect(protocols?.detail).toBe('http=on mcp=off'); + expect(protocols?.detail).toBe('http=on mcp=off a2a=off'); }); it('reports Storage as WARN when the receipt store health check itself warns', async () => { @@ -970,3 +970,70 @@ describe('runDoctor — Storage check does not create the store it is checking', expect(storage?.status).toBe('PASS'); }); }); + +describe('runDoctor — A2A', () => { + it('reports A2A as disabled by default', async () => { + const report = await runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch(), + loadConfig: async () => makeGatewayConfig(), + createStore: () => makeFakeReceiptStore(), + }, + ); + const a2a = report.checks.find((c) => c.name === 'A2A'); + expect(a2a?.status).toBe('INFO'); + expect(a2a?.detail).toBe('disabled'); + expect(report.checks.find((c) => c.name === 'A2A unsupported')).toBeUndefined(); + }); + + it('reports the spec revision, negotiation version, binding, mount and card path', async () => { + const base = makeGatewayConfig(); + const report = await runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch(), + loadConfig: async () => ({ + ...base, + protocols: { ...base.protocols, a2a: { enabled: true, mountPath: '/agents/a2a' } }, + }), + createStore: () => makeFakeReceiptStore(), + }, + ); + + const a2a = report.checks.find((c) => c.name === 'A2A'); + expect(a2a?.status).toBe('PASS'); + // Spec revision and negotiation version are different values that look + // alike; both must appear, named. + expect(a2a?.detail).toContain('spec 1.0.0'); + expect(a2a?.detail).toContain('protocol 1.0'); + expect(a2a?.detail).toContain('binding JSONRPC'); + expect(a2a?.detail).toContain('mount /agents/a2a'); + expect(a2a?.detail).toContain('card /.well-known/agent-card.json'); + expect(a2a?.detail).toContain('experimental'); + + const protocols = report.checks.find((c) => c.name === 'Protocols'); + expect(protocols?.detail).toContain('a2a=on (/agents/a2a)'); + }); + + it('lists every unsupported A2A operation in full', async () => { + const base = makeGatewayConfig(); + const report = await runDoctor( + { gatewayUrl: GATEWAY }, + { + fetchImpl: healthyFetch(), + loadConfig: async () => ({ + ...base, + protocols: { ...base.protocols, a2a: { enabled: true, mountPath: '/a2a' } }, + }), + createStore: () => makeFakeReceiptStore(), + }, + ); + + const unsupported = report.checks.find((c) => c.name === 'A2A unsupported'); + expect(unsupported?.status).toBe('INFO'); + for (const operation of ['SendStreamingMessage', 'GetTask', 'CancelTask', 'gRPC binding']) { + expect(unsupported?.detail).toContain(operation); + } + }); +}); From 03f5a2100befa3098f46222e444fccc0c2ebb9e4 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 30 Aug 2026 16:20:47 +0200 Subject: [PATCH 09/10] test(a2a): add sdk-driven v1 conformance coverage --- package-lock.json | 34 +++- package.json | 1 + src/protocols/a2a/agent-card.ts | 1 - src/protocols/a2a/constants.ts | 3 - src/protocols/a2a/message-mapping.ts | 12 +- src/protocols/a2a/types.ts | 1 - tests/conformance/a2a/agent-card.test.ts | 86 ++++++++ tests/conformance/a2a/errors.test.ts | 158 +++++++++++++++ tests/conformance/a2a/free-resource.test.ts | 104 ++++++++++ .../conformance/a2a/protocol-version.test.ts | 77 +++++++ tests/conformance/a2a/support/gateway.ts | 191 ++++++++++++++++++ tests/integration/a2a-over-gateway.test.ts | 1 - .../protocols-a2a/message-mapping.test.ts | 15 ++ 13 files changed, 674 insertions(+), 10 deletions(-) create mode 100644 tests/conformance/a2a/agent-card.test.ts create mode 100644 tests/conformance/a2a/errors.test.ts create mode 100644 tests/conformance/a2a/free-resource.test.ts create mode 100644 tests/conformance/a2a/protocol-version.test.ts create mode 100644 tests/conformance/a2a/support/gateway.ts diff --git a/package-lock.json b/package-lock.json index 6973d13..9da1375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devlab.group/agent-commerce", - "version": "0.2.0-beta.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devlab.group/agent-commerce", - "version": "0.2.0-beta.0", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "1.7.0", @@ -22,6 +22,7 @@ "agent-commerce": "dist/cli/index.js" }, "devDependencies": { + "@a2a-js/sdk": "1.1.0", "@biomejs/biome": "2.5.9", "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", @@ -72,6 +73,35 @@ } } }, + "node_modules/@a2a-js/sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-/Mhzw9C6VW7pFbY2Rq0pnrjT0Fy9PV0c46A7Gx1ppRlYq2u/6OV/bNRIoVBKChb8UZ8bE0WzzCc0pIr2CdmG+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jose": "^6.2.3" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.11.0", + "express": "^4.21.2 || ^5.1.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + }, + "@grpc/grpc-js": { + "optional": true + }, + "express": { + "optional": true + } + } + }, "node_modules/@adraffy/ens-normalize": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", diff --git a/package.json b/package.json index 6f0556c..98e1a19 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ } }, "devDependencies": { + "@a2a-js/sdk": "1.1.0", "@biomejs/biome": "2.5.9", "@coinbase/x402": "2.1.0", "@modelcontextprotocol/sdk": "1.30.0", diff --git a/src/protocols/a2a/agent-card.ts b/src/protocols/a2a/agent-card.ts index d45a1cc..c10247c 100644 --- a/src/protocols/a2a/agent-card.ts +++ b/src/protocols/a2a/agent-card.ts @@ -66,7 +66,6 @@ export function buildAgentSkill(resource: CommerceResource): A2aAgentSkill { export function buildAgentCard(options: AgentCardOptions): A2aAgentCard { return { - protocolVersion: A2A_PROTOCOL_VERSION, name: options.name, description: options.description, version: options.version, diff --git a/src/protocols/a2a/constants.ts b/src/protocols/a2a/constants.ts index b60f368..504d1a3 100644 --- a/src/protocols/a2a/constants.ts +++ b/src/protocols/a2a/constants.ts @@ -68,6 +68,3 @@ export const A2A_UNSUPPORTED_METHODS: readonly string[] = [ */ export const A2A_TASK_STATE_COMPLETED = 'TASK_STATE_COMPLETED'; export const A2A_TASK_STATE_FAILED = 'TASK_STATE_FAILED'; - -/** Role an agent-authored message carries, as A2A v1 spells it. */ -export const A2A_AGENT_ROLE = 'ROLE_AGENT'; diff --git a/src/protocols/a2a/message-mapping.ts b/src/protocols/a2a/message-mapping.ts index 8b2b0fb..9cb619f 100644 --- a/src/protocols/a2a/message-mapping.ts +++ b/src/protocols/a2a/message-mapping.ts @@ -99,9 +99,17 @@ function assertNoContinuation(params: z.infer): void { } } -/** Names the part kind so a caller learns which of theirs is the problem. */ +/** + * Names the part kind so a caller learns which of theirs is the problem. + * + * A2A v1 gives `Part` a content oneof — `text`, `data`, `raw` (inline bytes) + * or `url` — and carries `filename`/`mediaType` beside it, rather than the + * nested `file` object v0.3 used. Both spellings are refused: a v0.3-shaped + * client reaching this endpoint should be told its part kind is unsupported, + * not that its envelope is malformed. + */ function assertSupportedPart(part: Record): void { - if ('file' in part) { + if ('file' in part || 'raw' in part || 'url' in part) { throw unsupported('File and URL parts are not supported: send a structured data part.'); } if ('text' in part) { diff --git a/src/protocols/a2a/types.ts b/src/protocols/a2a/types.ts index 607cabc..1b80c1c 100644 --- a/src/protocols/a2a/types.ts +++ b/src/protocols/a2a/types.ts @@ -43,7 +43,6 @@ export interface A2aAgentSkill { } export interface A2aAgentCard { - readonly protocolVersion: string; readonly name: string; readonly description: string; /** Version of the agent implementation, not of the protocol. */ diff --git a/tests/conformance/a2a/agent-card.test.ts b/tests/conformance/a2a/agent-card.test.ts new file mode 100644 index 0000000..ce0d1b6 --- /dev/null +++ b/tests/conformance/a2a/agent-card.test.ts @@ -0,0 +1,86 @@ +/** + * Agent Card discovery through the official SDK's own resolver. + * + * The SDK acts purely as an external client here. No SDK server helper is + * used, and none is used to derive expected behaviour either — otherwise the + * suite would be checking the SDK against itself rather than checking this + * gateway against the protocol. + */ +import { DefaultAgentCardResolver } from '@a2a-js/sdk/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type RunningGateway, startConformanceGateway } from './support/gateway.js'; + +let running: RunningGateway; + +beforeAll(async () => { + running = await startConformanceGateway(); +}); + +afterAll(async () => { + await running?.close(); +}); + +describe('A2A agent card, resolved by the official SDK', () => { + it('is found at the well-known path the SDK looks in by default', async () => { + const card = await new DefaultAgentCardResolver().resolve(running.url); + + expect(card.name).toBe('Demo Weather Store'); + expect(card.version).toBeTruthy(); + expect(card.description).toBeTruthy(); + }); + + it('declares a JSONRPC interface at protocol version 1.0', async () => { + const card = await new DefaultAgentCardResolver().resolve(running.url); + + expect(card.supportedInterfaces).toHaveLength(1); + const [iface] = card.supportedInterfaces; + expect(iface?.protocolBinding).toBe('JSONRPC'); + expect(iface?.protocolVersion).toBe('1.0'); + expect(iface?.url).toBe(`${running.url}/a2a`); + }); + + it('publishes a2a-exposed resources as skills, and nothing else', async () => { + const card = await new DefaultAgentCardResolver().resolve(running.url); + + expect(card.skills.map((skill) => skill.id)).toEqual(['weather_basic']); + const [skill] = card.skills; + expect(skill?.name).toBe('Basic Weather'); + expect(skill?.inputModes).toEqual(['application/json']); + expect(skill?.outputModes).toEqual(['application/json']); + // Core AgentSkill defines no input schema field; none is invented. + expect(skill).not.toHaveProperty('inputSchema'); + }); + + it('declares the capabilities it actually has', async () => { + const card = await new DefaultAgentCardResolver().resolve(running.url); + + expect(card.capabilities?.streaming).toBe(false); + expect(card.capabilities?.pushNotifications).toBe(false); + expect(card.capabilities?.extendedAgentCard).toBe(false); + expect(card.defaultInputModes).toEqual(['application/json']); + expect(card.defaultOutputModes).toEqual(['application/json']); + }); + + it('carries no obsolete top-level endpoint field', async () => { + const raw = await (await fetch(`${running.url}/.well-known/agent-card.json`)).json(); + + expect(raw).not.toHaveProperty('url'); + expect(raw).not.toHaveProperty('preferredTransport'); + }); + + /** + * A2A v1 carries the protocol version per interface. `@a2a-js/sdk@1.1.0`'s + * `AgentCard` has no top-level `protocolVersion` field, so emitting one would + * be a claim no conformant client reads — this pins that it stays absent. + */ + it('states the protocol version per interface, not on the card itself', async () => { + const raw = (await ( + await fetch(`${running.url}/.well-known/agent-card.json`) + ).json()) as Record; + + expect(raw).not.toHaveProperty('protocolVersion'); + expect((raw['supportedInterfaces'] as { protocolVersion: string }[])[0]?.protocolVersion).toBe( + '1.0', + ); + }); +}); diff --git a/tests/conformance/a2a/errors.test.ts b/tests/conformance/a2a/errors.test.ts new file mode 100644 index 0000000..e283445 --- /dev/null +++ b/tests/conformance/a2a/errors.test.ts @@ -0,0 +1,158 @@ +/** + * Protocol-level refusals, driven over raw HTTP: a conformant SDK client + * cannot be made to send most of these, and using an SDK server helper to + * generate the expected answers would test the SDK against itself. + * + * The rule under test is the split — a malformed or unsupported A2A request is + * a JSON-RPC error; a commerce outcome never is. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type RunningGateway, startConformanceGateway } from './support/gateway.js'; + +let running: RunningGateway; + +beforeAll(async () => { + running = await startConformanceGateway(); +}); + +afterAll(async () => { + await running?.close(); +}); + +interface JsonRpcResponse { + readonly jsonrpc?: string; + readonly id?: string | number | null; + readonly result?: unknown; + readonly error?: { code: number; message: string }; +} + +async function post(body: unknown): Promise<{ status: number; body: JsonRpcResponse }> { + const response = await fetch(`${running.url}/a2a`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'A2A-Version': '1.0' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + return { status: response.status, body: (await response.json()) as JsonRpcResponse }; +} + +function sendMessage(params: unknown, id: string | number = 'e-1'): unknown { + return { jsonrpc: '2.0', id, method: 'SendMessage', params }; +} + +function message(parts: unknown[], overrides: Record = {}): unknown { + return { message: { role: 'ROLE_USER', messageId: 'msg-1', parts, ...overrides } }; +} + +describe('JSON-RPC framing errors', () => { + it.each([ + ['malformed JSON', '{"jsonrpc": "2.0", "id"', -32700], + ['a jsonrpc version other than 2.0', { jsonrpc: '1.0', id: 1, method: 'SendMessage' }, -32600], + ['a missing method', { jsonrpc: '2.0', id: 1 }, -32600], + ['a non-object request', '"SendMessage"', -32600], + ['invalid params', { jsonrpc: '2.0', id: 1, method: 'SendMessage', params: 'nope' }, -32602], + ])('answers %s with %i', async (_label, payload, code) => { + const { status, body } = await post(payload); + + expect(status).toBe(200); + expect(body.jsonrpc).toBe('2.0'); + expect(body.error?.code).toBe(code); + expect(body.result).toBeUndefined(); + }); +}); + +describe('method routing', () => { + it('does not implement the legacy message/send name', async () => { + const { body } = await post({ + jsonrpc: '2.0', + id: 1, + method: 'message/send', + params: message([{ data: { resource: 'weather_basic', input: { city: 'Berlin' } } }]), + }); + expect(body.error?.code).toBe(-32601); + }); + + it.each(['GetTask', 'ListTasks', 'CancelTask', 'SendStreamingMessage', 'SubscribeToTask'])( + 'refuses the known operation %s as unsupported, not unknown', + async (method) => { + const { body } = await post({ jsonrpc: '2.0', id: 1, method, params: {} }); + expect(body.error?.code).toBe(-32004); + expect(body.error?.message).toContain(method); + }, + ); + + it('reports a method that does not exist as method-not-found', async () => { + const { body } = await post({ jsonrpc: '2.0', id: 1, method: 'Frobnicate', params: {} }); + expect(body.error?.code).toBe(-32601); + }); +}); + +describe('invocation envelope refusals', () => { + it('refuses an unsupported role', async () => { + const { body } = await post( + sendMessage(message([{ data: { resource: 'weather_basic' } }], { role: 'ROLE_AGENT' })), + ); + expect(body.error?.code).toBe(-32602); + }); + + it.each([ + ['a text part', { text: 'what is the weather' }], + ['an inline-bytes part', { raw: 'QUFBQQ==', filename: 'a.bin' }], + ['a url part', { url: 'https://example.com/a.pdf' }], + ['a v0.3 file part', { file: { uri: 'https://example.com/a.pdf' } }], + ])('refuses %s as an unsupported part representation', async (_label, part) => { + const { body } = await post(sendMessage(message([part]))); + expect(body.error?.code).toBe(-32004); + }); + + it('refuses multiple parts rather than choosing one', async () => { + const { body } = await post( + sendMessage( + message([ + { data: { resource: 'weather_basic', input: { city: 'Berlin' } } }, + { data: { resource: 'http_only', input: {} } }, + ]), + ), + ); + expect(body.error?.code).toBe(-32004); + }); + + it('refuses task continuation, which it cannot honour', async () => { + const { body } = await post( + sendMessage(message([{ data: { resource: 'weather_basic' } }], { taskId: 'task-1' })), + ); + expect(body.error?.code).toBe(-32004); + }); +}); + +describe('commerce outcomes are never JSON-RPC errors', () => { + it('answers an unknown canonical resource with a failed task', async () => { + const { body } = await post( + sendMessage(message([{ data: { resource: 'no_such_resource', input: {} } }])), + ); + + expect(body.error).toBeUndefined(); + const task = (body.result as { task: { status: { state: string }; artifacts: unknown[] } }) + .task; + expect(task.status.state).toBe('TASK_STATE_FAILED'); + }); +}); + +describe('redaction', () => { + it('never returns a stack, a path, an internal hostname or an exception name', async () => { + const responses = await Promise.all([ + post('{"jsonrpc":'), + post({ jsonrpc: '2.0', id: 1, method: 'GetTask' }), + post(sendMessage(message([{ data: { resource: 42 } }]))), + post(sendMessage(message([{ data: { resource: 'weather_basic', input: { city: 42 } } }]))), + post(sendMessage({ message: { role: 'ROLE_USER', parts: [] } })), + ]); + + for (const { body } of responses) { + const text = JSON.stringify(body); + expect(text).not.toMatch(/\bat .*:\d+:\d+/); + expect(text).not.toMatch(/[/\\](src|node_modules)[/\\]/); + expect(text).not.toMatch(/ZodError|TypeError|ECONNREFUSED|SQLITE/); + expect(text).not.toContain('backend.local'); + } + }); +}); diff --git a/tests/conformance/a2a/free-resource.test.ts b/tests/conformance/a2a/free-resource.test.ts new file mode 100644 index 0000000..5b60dad --- /dev/null +++ b/tests/conformance/a2a/free-resource.test.ts @@ -0,0 +1,104 @@ +/** + * The definition of done for SDK conformance: the official client discovers + * the gateway from its card and invokes a free resource end to end — + * ClientFactory → card → JSONRPC transport → SendMessage → gateway → adapter → + * pipeline → merchant fixture → terminal Task with an Artifact. + */ +import { Role, type SendMessageRequest, TaskState } from '@a2a-js/sdk'; +import { ClientFactory } from '@a2a-js/sdk/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { MERCHANT_BODY, type RunningGateway, startConformanceGateway } from './support/gateway.js'; + +let running: RunningGateway; + +beforeAll(async () => { + running = await startConformanceGateway(); +}); + +afterAll(async () => { + await running?.close(); +}); + +/** The SDK's own factory: card discovery and transport selection are its job, not ours. */ +async function client() { + return new ClientFactory().createFromUrl(running.url); +} + +/** + * A `SendMessageRequest` in the SDK's own internal representation — the client + * serialises it, so the wire bytes are the SDK's, not this test's. + */ +function invocation(resource: string, input: Record): SendMessageRequest { + return { + tenant: '', + message: { + messageId: 'msg-conformance-1', + contextId: '', + taskId: '', + role: Role.ROLE_USER, + parts: [ + { + content: { $case: 'data', value: { resource, input } }, + mediaType: 'application/json', + filename: '', + metadata: undefined, + }, + ], + metadata: undefined, + extensions: [], + referenceTaskIds: [], + }, + configuration: undefined, + metadata: undefined, + }; +} + +describe('free resource, invoked by the official SDK client', () => { + it('returns a terminal completed task carrying the merchant response', async () => { + const result = await (await client()).sendMessage( + invocation('weather_basic', { city: 'Berlin' }), + ); + + // SendMessageResult is Task | Message; a resource execution is a Task. + expect('status' in result).toBe(true); + if (!('status' in result)) return; + + expect(result.status?.state).toBe(TaskState.TASK_STATE_COMPLETED); + expect(result.id).toBeTruthy(); + expect(result.contextId).toBeTruthy(); + expect(result.artifacts).toHaveLength(1); + + const part = result.artifacts[0]?.parts[0]; + expect(part?.mediaType).toBe('application/json'); + expect(part?.content?.$case).toBe('data'); + expect(part?.content?.value).toEqual(MERCHANT_BODY); + }); + + it('carries the delivery summary as artifact metadata', async () => { + const result = await (await client()).sendMessage( + invocation('weather_basic', { city: 'Berlin' }), + ); + if (!('status' in result)) throw new Error('expected a task'); + + const metadata = result.artifacts[0]?.metadata as Record | undefined; + expect(metadata?.['agent-commerce/delivery']).toMatchObject({ resourceId: 'weather_basic' }); + }); + + it('answers a resource that is not exposed over a2a with a failed task, not a transport error', async () => { + const result = await (await client()).sendMessage(invocation('http_only', {})); + if (!('status' in result)) throw new Error('expected a task'); + + expect(result.status?.state).toBe(TaskState.TASK_STATE_FAILED); + const data = result.artifacts[0]?.parts[0]?.content?.value as Record; + expect(data['code']).toBe('RESOURCE_NOT_FOUND'); + }); + + it('answers input that fails the resource schema with a failed task', async () => { + const result = await (await client()).sendMessage(invocation('weather_basic', { city: 42 })); + if (!('status' in result)) throw new Error('expected a task'); + + expect(result.status?.state).toBe(TaskState.TASK_STATE_FAILED); + const data = result.artifacts[0]?.parts[0]?.content?.value as Record; + expect(data['code']).toBe('INPUT_INVALID'); + }); +}); diff --git a/tests/conformance/a2a/protocol-version.test.ts b/tests/conformance/a2a/protocol-version.test.ts new file mode 100644 index 0000000..caedc21 --- /dev/null +++ b/tests/conformance/a2a/protocol-version.test.ts @@ -0,0 +1,77 @@ +/** + * Version negotiation. The SDK's own client always stamps + * `A2A-Version: ` on every call, so the positive case is + * covered by the SDK itself; the refusals are driven over raw HTTP, because a + * conformant client cannot be made to send a wrong one. + */ +import { ClientFactory } from '@a2a-js/sdk/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { type RunningGateway, startConformanceGateway } from './support/gateway.js'; + +let running: RunningGateway; + +beforeAll(async () => { + running = await startConformanceGateway(); +}); + +afterAll(async () => { + await running?.close(); +}); + +interface JsonRpcResponse { + readonly jsonrpc?: string; + readonly id?: string | number | null; + readonly result?: unknown; + readonly error?: { code: number; message: string }; +} + +async function post(body: string, headers: Record): Promise { + const response = await fetch(`${running.url}/a2a`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }); + return (await response.json()) as JsonRpcResponse; +} + +const SEND_MESSAGE = JSON.stringify({ + jsonrpc: '2.0', + id: 'v-1', + method: 'SendMessage', + params: { + message: { + role: 'ROLE_USER', + messageId: 'msg-1', + parts: [{ data: { resource: 'weather_basic', input: { city: 'Berlin' } } }], + }, + }, +}); + +describe('A2A protocol version negotiation', () => { + it('accepts 1.0, the version the official client sends', async () => { + // Proven by the SDK actually completing a call against this gateway. + const client = await new ClientFactory().createFromUrl(running.url); + expect(client.protocolVersion).toBe('1.0'); + + const body = await post(SEND_MESSAGE, { 'A2A-Version': '1.0' }); + expect(body.error).toBeUndefined(); + }); + + it.each([ + ['a missing header', {}], + ['the previous revision', { 'A2A-Version': '0.3' }], + ['an unreleased revision', { 'A2A-Version': '2.0' }], + ['a non-version string', { 'A2A-Version': 'latest' }], + ])('refuses %s as an unsupported operation', async (_label, headers) => { + const body = await post(SEND_MESSAGE, headers); + + expect(body.error?.code).toBe(-32004); + expect(body.error?.message).toContain('1.0'); + expect(body.result).toBeUndefined(); + }); + + it('matches the header case-insensitively, as HTTP requires', async () => { + const body = await post(SEND_MESSAGE, { 'a2a-VERSION': '1.0' }); + expect(body.error).toBeUndefined(); + }); +}); diff --git a/tests/conformance/a2a/support/gateway.ts b/tests/conformance/a2a/support/gateway.ts new file mode 100644 index 0000000..bf8cbcc --- /dev/null +++ b/tests/conformance/a2a/support/gateway.ts @@ -0,0 +1,191 @@ +/** + * A real, listening gateway for the SDK to talk to. + * + * The official client uses global `fetch` against a URL, so unlike the + * `inject()`-based integration tests this suite needs a socket. Everything + * below the adapter is the real thing — gateway, pipeline, A2A adapter — with + * the merchant backend and the receipt store faked, since neither is what the + * protocol conformance of this endpoint depends on. + */ +import { createServer } from 'node:net'; +import type { GatewayConfig } from '../../../../src/config/index.js'; +import type { + AdapterDescriptor, + BackendExecutor, + CommerceEvent, + CommerceReceipt, + PaymentAttempt, + ReceiptStore, +} from '../../../../src/core/index.js'; +import { createGateway, type GatewayInstance } from '../../../../src/gateway/index.js'; +import { createA2aAdapter } from '../../../../src/protocols/a2a/index.js'; + +const descriptor: AdapterDescriptor = { + name: 'fake-store', + kind: 'storage', + implementationVersion: '0.0.0-test', + supportedSpec: 'n/a', + capabilities: [], + status: 'experimental', +}; + +function createFakeStore(): ReceiptStore { + const events: CommerceEvent[] = []; + const receipts: CommerceReceipt[] = []; + const attempts = new Map(); + return { + async init() {}, + async appendEvent(event) { + events.push(event); + }, + async reservePaymentAttempt(reservation) { + const attempt: PaymentAttempt = { + id: `attempt-${attempts.size + 1}`, + requestId: reservation.requestId, + resourceId: reservation.resourceId, + provider: reservation.provider, + replayKey: reservation.replayKey, + status: 'reserved', + amount: reservation.amount, + currency: reservation.currency, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + attempts.set(reservation.replayKey, attempt); + return attempt; + }, + async updatePaymentAttempt() {}, + async saveReceipt(receipt) { + receipts.push(receipt); + }, + async getReceipt(id) { + return receipts.find((r) => r.id === id); + }, + async listReceipts() { + return receipts; + }, + async countReceipts() { + return receipts.length; + }, + async countUndeliveredReceipts() { + return 0; + }, + async listEvents() { + return events; + }, + async listPaymentAttempts() { + return [...attempts.values()]; + }, + async health() { + return { status: 'pass', checkedAt: '2026-01-01T00:00:00.000Z' }; + }, + async close() {}, + descriptor, + }; +} + +/** Fixed merchant response, so an assertion about the artifact is about the artifact. */ +export const MERCHANT_BODY = { city: 'Berlin', forecast: 'sunny', celsius: 21 }; + +const backend: BackendExecutor = { + async call() { + return { status: 200, body: MERCHANT_BODY, headers: {}, durationMs: 1 }; + }, +}; + +function config(publicBaseUrl: string): GatewayConfig { + return { + version: 1, + merchant: { id: 'demo-store', name: 'Demo Weather Store', publicBaseUrl }, + server: { port: 0, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: ':memory:' } }, + protocols: { + http: { enabled: true }, + mcp: { enabled: false, mountPath: '/mcp' }, + a2a: { enabled: true, mountPath: '/a2a' }, + }, + resources: [ + { + id: 'weather_basic', + name: 'Basic Weather', + description: 'Current weather for a city.', + inputSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + additionalProperties: false, + }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/weather/{city}' }, + pricing: { type: 'free' }, + exposedVia: ['a2a'], + paymentMethods: [], + }, + { + id: 'http_only', + name: 'HTTP Only', + inputSchema: { type: 'object', properties: {} }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/http-only' }, + pricing: { type: 'free' }, + exposedVia: ['http'], + paymentMethods: [], + }, + ], + payments: {}, + }; +} + +export interface RunningGateway { + readonly gateway: GatewayInstance; + /** Origin the SDK discovers the card from. */ + readonly url: string; + close(): Promise; +} + +/** + * A port nothing else holds. The card's `supportedInterfaces[].url` is built + * from `publicBaseUrl` at adapter start — before `listen()` returns — and the + * SDK POSTs to whatever that URL says, so the address has to be known up + * front. Binding to 0 and reading it back afterwards would be too late. + */ +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +export async function startConformanceGateway(): Promise { + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + + const gatewayConfig: GatewayConfig = { + ...config(url), + server: { port, host: '127.0.0.1', allowedOrigins: [] }, + }; + const gateway = await createGateway({ + config: gatewayConfig, + store: createFakeStore(), + paymentProviders: [], + // Mirrors src/gateway/main.ts's composition exactly: a conformance suite + // that wired the adapter differently from production would certify a + // deployment nobody runs. + protocolAdapters: [ + createA2aAdapter({ + mountPath: gatewayConfig.protocols.a2a.mountPath, + agentName: gatewayConfig.merchant.name, + }), + ], + backend, + }); + await gateway.listen(); + + return { + gateway, + url, + async close() { + await gateway.close(); + }, + }; +} diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts index 45cd9e2..4ac8524 100644 --- a/tests/integration/a2a-over-gateway.test.ts +++ b/tests/integration/a2a-over-gateway.test.ts @@ -124,7 +124,6 @@ describe('A2A agent card over the real gateway', () => { expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('application/json'); const card = res.json(); - expect(card.protocolVersion).toBe('1.0'); expect(card.supportedInterfaces).toEqual([ { url: 'http://localhost:8080/a2a', diff --git a/tests/unit/protocols-a2a/message-mapping.test.ts b/tests/unit/protocols-a2a/message-mapping.test.ts index a38c92d..3c24e32 100644 --- a/tests/unit/protocols-a2a/message-mapping.test.ts +++ b/tests/unit/protocols-a2a/message-mapping.test.ts @@ -191,3 +191,18 @@ describe('parseInvocation — legal A2A this adapter does not serve', () => { ).toBe('ping'); }); }); + +/** + * Part shapes the official SDK actually produces. A2A v1 flattened the v0.3 + * `file` object into a content oneof, so these are what a conformant client + * sends — checked here rather than only in the SDK conformance suite, where a + * failure would be one layer removed from the rule it breaks. + */ +describe('parseInvocation — A2A v1 part spellings', () => { + it.each([ + ['inline bytes', { raw: 'QUFBQQ==', filename: 'a.bin', mediaType: 'application/octet-stream' }], + ['a url part', { url: 'https://example.com/a.pdf', mediaType: 'application/pdf' }], + ])('rejects %s', (_label, part) => { + expectRejected({ message: { role: 'ROLE_USER', parts: [part] } }, 'PROTOCOL_UNSUPPORTED'); + }); +}); From 2b9e4f23ca464498c463aff472bcb1791a101313 Mon Sep 17 00:00:00 2001 From: Revinand Date: Sun, 30 Aug 2026 18:12:57 +0200 Subject: [PATCH 10/10] test(a2a): complete paid flow isolation and documentation --- README.md | 17 ++- config.example.yaml | 7 +- docs/configuration.md | 6 +- docs/protocols.md | 103 +++++++++++++- tests/conformance/a2a/agent-card.test.ts | 3 +- tests/conformance/a2a/paid-resource.test.ts | 148 ++++++++++++++++++++ tests/conformance/a2a/support/gateway.ts | 120 +++++++++++++++- tests/integration/a2a-over-gateway.test.ts | 127 ++++++++++++++++- 8 files changed, 514 insertions(+), 17 deletions(-) create mode 100644 tests/conformance/a2a/paid-resource.test.ts diff --git a/README.md b/README.md index f7763b6..2a9df57 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ TypeScript MCP x402 + A2A

## What it is, in ten seconds @@ -213,10 +214,14 @@ See [docs/configuration.md](docs/configuration.md). | **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | | **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM | | **HTTP** | Supported | native routes | +| **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` | | UCP | Planned | — | -| ACP · MPP · A2A · AP2 | Planned | — | +| ACP · MPP · AP2 | Planned | — | -"Planned" means **no code ships for it**. Each adapter reports its own +"Planned" means **no code ships for it**. "Experimental" means the code ships, +is tested against the official SDK, and serves a narrow named subset — A2A is +off by default and documented in full at +[docs/protocols.md](docs/protocols.md#a2a). Each adapter reports its own `supportedSpec`, `capabilities` and `unsupported` list at runtime via `GET /.well-known/agent-commerce` and `agent-commerce doctor` — so the claim is checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md). @@ -349,7 +354,8 @@ $ npm run agent-commerce -- doctor --config config-demo.yaml PASS Config valid — 2 resource(s), merchant "Demo Data Store" (using local chain manifest .deploy/local.json for X402_ASSET, X402_ASSET_NAME, X402_ASSET_VERSION, X402_ASSET_DECIMALS, MERCHANT_WALLET, X402_FACILITATOR_PRIVATE_KEY) PASS Gateway healthy and ready at http://127.0.0.1:8080 PASS Backend 2/2 backend host(s) reachable -PASS Protocols http=on mcp=on (/mcp) +PASS Protocols http=on mcp=on (/mcp) a2a=off +INFO A2A disabled PASS Payments x402 v2 (scheme=exact) enabled — LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local INFO Payments (MPP) planned — not implemented in this release PASS Storage sqlite schema v1 writable; receipts=2 @@ -396,10 +402,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Roadmap **Now (v1.0.0)** — MCP, x402 v2, settlement on the local chain, Base Sepolia -and Base mainnet, receipts, doctor, deterministic demo. +and Base mainnet, receipts, doctor, deterministic demo, and an experimental +A2A v1.0.0 adapter. **Next** — OpenAPI import · a stronger conformance suite · a `doctor` GitHub -Action · UCP · MPP · ACP · A2A · AP2 · Shopify and WooCommerce examples · +Action · UCP · MPP · ACP · AP2 · Shopify and WooCommerce examples · PostgreSQL · richer observability. New protocols land only after the adapter model survives real use. Scope diff --git a/config.example.yaml b/config.example.yaml index 0c25c19..b8f1b32 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -44,8 +44,11 @@ protocols: mcp: enabled: true mountPath: /mcp - # A2A (Agent2Agent) is experimental and off by default. When enabled the - # adapter also serves the spec-fixed /.well-known/agent-card.json. + # A2A (Agent2Agent) v1.0.0 — experimental, off by default. + # Enabling it serves JSON-RPC `SendMessage` at mountPath and the + # specification-fixed Agent Card at /.well-known/agent-card.json. Clients must + # send `A2A-Version: 1.0`. Streaming, task persistence and push notifications + # are not implemented; see docs/protocols.md#a2a. a2a: enabled: false mountPath: /a2a diff --git a/docs/configuration.md b/docs/configuration.md index 0c10370..8dbdee9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -201,8 +201,10 @@ told you is ignored. unsupported `version` · unresolved `${VAR}` · duplicate resource ids · `pricing.type: dynamic` · a paid resource with no `payments` · a resource naming an unconfigured or disabled payment method · `expose` values outside -`[http, mcp]` (UCP is planned, not supported) · `expose: [mcp]` while -`protocols.mcp.enabled` is false · an invalid or zero `payTo`/`asset`. +`[http, mcp, a2a]` (UCP is planned, not supported) · `expose: [mcp]` while +`protocols.mcp.enabled` is false (likewise `a2a`) · two enabled protocol mounts +that overlap · a mount that claims a route the gateway already serves, +including the A2A Agent Card path · an invalid or zero `payTo`/`asset`. It exits non-zero on any of them. diff --git a/docs/protocols.md b/docs/protocols.md index d1c3f3a..a84ea5d 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -10,15 +10,19 @@ implemented, exactly what is not, and pins the revisions. | **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` | tool discovery, tool invocation, payment-required and error mapping | | **x402** | Supported | x402 **v2** (`@x402/core@2.23.0`, `@x402/evm@2.23.0`), scheme `exact`, EVM, EIP-3009 | challenge, verification, settlement, replay binding | | **HTTP** | Supported | — | native resource routes with `PAYMENT-SIGNATURE` | +| **A2A** | Experimental | A2A **v1.0.0**, negotiation version `1.0`, binding `JSONRPC` | Agent Card discovery, `SendMessage`, terminal tasks, paid flow | | UCP | Planned | — | planned, no code ships | | ACP | Planned | — | planned, no code ships | | MPP | Planned | — | planned, no code ships | -| A2A | Planned | — | planned, no code ships | | AP2 | Planned | — | planned, no code ships | "Planned" means **no code ships for it**. There is no partial adapter, no endpoint and no diagnostic pretending otherwise. +"Experimental" means the opposite of planned and short of supported: the code +ships, it is tested against the official SDK, and the supported subset is +narrow and named below. It is off by default. + Every adapter reports itself at runtime through `GET /.well-known/agent-commerce` and in `agent-commerce doctor`, with `supportedSpec`, `capabilities`, `unsupported` and `status`. If this page and @@ -85,6 +89,101 @@ server-initiated requests. They are absent, not stubbed. The adapter's The adapter contains **no payment logic** and never calls a merchant backend — it normalises into `CanonicalRequest` and lets the pipeline decide. +## A2A + +**Experimental — A2A v1.0.0.** Off unless `protocols.a2a.enabled` is `true`. + +| | | +| --- | --- | +| Binding | JSON-RPC 2.0 over HTTPS | +| JSON-RPC method | `SendMessage` (not the legacy `message/send`) | +| Protocol negotiation version | `1.0`, required in the `A2A-Version` request header | +| Agent Card | `GET /.well-known/agent-card.json` (fixed by the specification) | +| Default mount | `/a2a` | +| Streaming | unsupported | +| Task persistence | unsupported | +| Push notifications | unsupported | + +Canonical resources exposed with `expose: [a2a]` become **A2A skills** on the +Agent Card. Skill id = resource id; a paid skill is tagged `paid` and names its +price in the description. + +### Invoking a resource + +> A2A skills are discovery descriptors. A2A v1.0 does not define a standard +> `skillId` field on `SendMessageRequest`, so Agent Commerce uses the +> structured-data invocation envelope below to select a canonical resource. + +One message, one part, whose `data` names the resource and carries its input: + +```json +{ + "data": { + "resource": "market_report", + "input": { + "symbol": "ETH" + } + }, + "mediaType": "application/json" +} +``` + +Anything richer is refused rather than guessed at: text, file, inline-bytes and +URL parts, multi-part messages, a role other than `ROLE_USER`, and any task or +context continuation. + +> Core A2A v1.0 `AgentSkill` does not provide an input schema field. Canonical +> Agent Commerce `inputSchema` is therefore not embedded in the Agent Card in +> this implementation. + +### Payment over A2A + +The reserved `_payment` input field, exactly as over MCP — there is no +A2A-specific payment representation: + +```json +{ + "data": { + "resource": "market_report", + "input": { "symbol": "ETH", "_payment": "" } + }, + "mediaType": "application/json" +} +``` + +### Results + +Every outcome is a **terminal task** in the JSON-RPC `result`, carrying one +artifact whose single data part is an existing canonical envelope: + +| Outcome | Task state | Artifact data | +| --- | --- | --- | +| delivered | `TASK_STATE_COMPLETED` | the merchant response (a non-object body is wrapped as `{ "value": … }`), with the delivery summary under the artifact's `agent-commerce/delivery` metadata | +| payment required | `TASK_STATE_FAILED` | `toPaymentRequiredEnvelope` output | +| domain failure | `TASK_STATE_FAILED` | `toErrorEnvelope` output | + +Payment required is terminal, not `input-required`: there is no task store, so +nothing can be continued. The buyer retries by sending a **new** message +carrying the proof. + +A commerce outcome is never a JSON-RPC error. JSON-RPC errors are reserved for +requests that are malformed or unsupported as A2A: `-32700` bad JSON, `-32600` +bad request object, `-32601` unknown method, `-32602` bad params or envelope, +and `-32004` (`UnsupportedOperationError`) for a real A2A operation this +deployment declines — including an unsupported `A2A-Version`. + +### Not implemented in the A2A adapter + +`SendStreamingMessage`, `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, +the four push-notification-config methods, `GetExtendedAgentCard`; the +HTTP+JSON/REST and gRPC bindings; SSE, task persistence and resumption, push +notifications, multi-turn continuation, authenticated extended agent cards, and +A2A authentication schemes. The adapter's `descriptor.unsupported` lists them at +runtime, and `agent-commerce doctor` prints the list in full. + +The adapter contains **no payment logic** and never calls a merchant backend. +`@a2a-js/sdk` is a **test-only** dependency: serving A2A installs no SDK. + ## x402 - Scheme `exact`, EVM family, via EIP-3009 `transferWithAuthorization`. @@ -121,6 +220,8 @@ one. What guards mainnet is in [configuration.md](configuration.md). | `GET /api/receipts`, `GET /api/events` | audit | | `GET /api/events/stream` | SSE event feed | | `/mcp` | MCP Streamable HTTP | +| `/.well-known/agent-card.json` | A2A Agent Card (only when A2A is enabled) | +| `/a2a` | A2A JSON-RPC `SendMessage` (only when A2A is enabled) | ## Adding a protocol diff --git a/tests/conformance/a2a/agent-card.test.ts b/tests/conformance/a2a/agent-card.test.ts index ce0d1b6..a696abf 100644 --- a/tests/conformance/a2a/agent-card.test.ts +++ b/tests/conformance/a2a/agent-card.test.ts @@ -42,7 +42,8 @@ describe('A2A agent card, resolved by the official SDK', () => { it('publishes a2a-exposed resources as skills, and nothing else', async () => { const card = await new DefaultAgentCardResolver().resolve(running.url); - expect(card.skills.map((skill) => skill.id)).toEqual(['weather_basic']); + // `http_only` is configured but exposed elsewhere, so it must not appear. + expect(card.skills.map((skill) => skill.id)).toEqual(['weather_basic', 'market_report']); const [skill] = card.skills; expect(skill?.name).toBe('Basic Weather'); expect(skill?.inputModes).toEqual(['application/json']); diff --git a/tests/conformance/a2a/paid-resource.test.ts b/tests/conformance/a2a/paid-resource.test.ts new file mode 100644 index 0000000..04e0c84 --- /dev/null +++ b/tests/conformance/a2a/paid-resource.test.ts @@ -0,0 +1,148 @@ +/** + * The paid flow, driven by the official SDK: challenge, pay, retry, delivery. + * + * The assertion that matters throughout is the merchant backend call count. + * "Payment succeeded" in a response body proves nothing — a paywall works if + * and only if the backend is not called before a valid proof and is called + * exactly once after one. + * + * Verification and settlement belong to the payment provider; every case below + * is arranged so that the A2A adapter deciding anything about a proof would + * make the test fail. + */ +import { Role, type SendMessageRequest, TaskState } from '@a2a-js/sdk'; +import { type Client, ClientFactory } from '@a2a-js/sdk/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { PAYMENT_INPUT_FIELD } from '../../../src/core/index.js'; +import { + MERCHANT_BODY, + type RunningGateway, + startConformanceGateway, + VALID_PROOF, +} from './support/gateway.js'; + +let running: RunningGateway; +let client: Client; + +beforeEach(async () => { + running = await startConformanceGateway(); + client = await new ClientFactory().createFromUrl(running.url); +}); + +afterEach(async () => { + await running?.close(); +}); + +function buy(input: Record): SendMessageRequest { + return { + tenant: '', + message: { + messageId: 'msg-paid-1', + contextId: '', + taskId: '', + role: Role.ROLE_USER, + parts: [ + { + content: { $case: 'data', value: { resource: 'market_report', input } }, + mediaType: 'application/json', + filename: '', + metadata: undefined, + }, + ], + metadata: undefined, + extensions: [], + referenceTaskIds: [], + }, + configuration: undefined, + metadata: undefined, + }; +} + +/** Narrows `Task | Message` and returns the single artifact payload. */ +async function send(input: Record) { + const result = await client.sendMessage(buy(input)); + if (!('status' in result)) throw new Error('expected a task, got a message'); + const data = result.artifacts[0]?.parts[0]?.content; + return { + state: result.status?.state, + data: (data?.$case === 'data' ? data.value : undefined) as Record | undefined, + }; +} + +describe('paid resource over A2A', () => { + it('challenges an unpaid call with the canonical payment-required envelope', async () => { + const { state, data } = await send({ symbol: 'ETH' }); + + expect(state).toBe(TaskState.TASK_STATE_FAILED); + // The existing Agent Commerce envelope, not an A2A-specific schema. + expect(data?.['status']).toBe('payment-required'); + expect(data?.['code']).toBe('PAYMENT_REQUIRED'); + expect(data?.['payment']).toMatchObject({ + provider: 'x402', + amount: '0.01', + currency: 'USDC', + destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + }); + expect(running.backendCalls()).toBe(0); + }); + + it('delivers exactly once when the retry carries a valid proof', async () => { + const challenge = await send({ symbol: 'ETH' }); + expect(challenge.state).toBe(TaskState.TASK_STATE_FAILED); + expect(running.backendCalls()).toBe(0); + + const delivery = await send({ symbol: 'ETH', [PAYMENT_INPUT_FIELD]: VALID_PROOF }); + + expect(delivery.state).toBe(TaskState.TASK_STATE_COMPLETED); + expect(delivery.data).toEqual(MERCHANT_BODY); + expect(running.backendCalls()).toBe(1); + expect(running.settleCalls()).toBe(1); + }); + + it('reports the settled payment in the delivery summary', async () => { + const result = await client.sendMessage( + buy({ symbol: 'ETH', [PAYMENT_INPUT_FIELD]: VALID_PROOF }), + ); + if (!('status' in result)) throw new Error('expected a task'); + + const metadata = result.artifacts[0]?.metadata as Record | undefined; + const summary = metadata?.['agent-commerce/delivery'] as Record | undefined; + expect(summary).toMatchObject({ resourceId: 'market_report' }); + expect(JSON.stringify(summary)).toContain('0xTXHASH'); + }); + + it.each([ + ['no proof at all', {}], + ['an empty proof', { [PAYMENT_INPUT_FIELD]: '' }], + ['a malformed proof', { [PAYMENT_INPUT_FIELD]: { not: 'a string' } }], + ['an invalid proof', { [PAYMENT_INPUT_FIELD]: 'forged-proof' }], + ['an unverifiable proof', { [PAYMENT_INPUT_FIELD]: 'unverifiable-proof' }], + ])('delivers nothing for %s', async (_label, payment) => { + const { state } = await send({ symbol: 'ETH', ...payment }); + + expect(state).toBe(TaskState.TASK_STATE_FAILED); + expect(running.backendCalls()).toBe(0); + expect(running.settleCalls()).toBe(0); + }); + + it('never settles a proof the provider rejected', async () => { + await send({ symbol: 'ETH', [PAYMENT_INPUT_FIELD]: 'forged-proof' }); + expect(running.settleCalls()).toBe(0); + + // …and a good proof afterwards still works: one bad attempt does not + // poison the resource. + const delivery = await send({ symbol: 'ETH', [PAYMENT_INPUT_FIELD]: VALID_PROOF }); + expect(delivery.state).toBe(TaskState.TASK_STATE_COMPLETED); + expect(running.backendCalls()).toBe(1); + }); + + it('advertises the paid resource as a skill tagged paid, with its price', async () => { + const card = await (await fetch(`${running.url}/.well-known/agent-card.json`)).json(); + const skill = ( + card as { skills: { id: string; tags: string[]; description: string }[] } + ).skills.find((s) => s.id === 'market_report'); + + expect(skill?.tags).toContain('paid'); + expect(skill?.description).toContain('0.01 USDC'); + }); +}); diff --git a/tests/conformance/a2a/support/gateway.ts b/tests/conformance/a2a/support/gateway.ts index bf8cbcc..760ac27 100644 --- a/tests/conformance/a2a/support/gateway.ts +++ b/tests/conformance/a2a/support/gateway.ts @@ -15,6 +15,12 @@ import type { CommerceEvent, CommerceReceipt, PaymentAttempt, + PaymentContext, + PaymentProvider, + PaymentRequirement, + PaymentResult, + PaymentSettlementContext, + PaymentVerificationContext, ReceiptStore, } from '../../../../src/core/index.js'; import { createGateway, type GatewayInstance } from '../../../../src/gateway/index.js'; @@ -87,12 +93,93 @@ function createFakeStore(): ReceiptStore { /** Fixed merchant response, so an assertion about the artifact is about the artifact. */ export const MERCHANT_BODY = { city: 'Berlin', forecast: 'sunny', celsius: 21 }; -const backend: BackendExecutor = { - async call() { - return { status: 200, body: MERCHANT_BODY, headers: {}, durationMs: 1 }; - }, +/** The only proof the fake provider accepts. */ +export const VALID_PROOF = 'valid-proof'; + +/** + * Counts merchant calls, because "was this delivered?" is the only question + * that matters for a paywall. A console line saying payment succeeded proves + * nothing; a backend call count of 0 before payment and 1 after does. + */ +function countingBackend(): BackendExecutor & { calls: () => number } { + let calls = 0; + return { + calls: () => calls, + async call() { + calls += 1; + return { status: 200, body: MERCHANT_BODY, headers: {}, durationMs: 1 }; + }, + }; +} + +const paymentDescriptor: AdapterDescriptor = { + name: 'fake-x402', + kind: 'payment', + implementationVersion: '0.0.0-test', + supportedSpec: 'x402/v2', + capabilities: [], + status: 'experimental', }; +/** + * Verification and settlement live here, not in the adapter — the whole point + * of the assertions in the paid suite is that the A2A code never decides + * whether a proof is good. `unverifiable` models a provider that cannot reach + * its facilitator: a throw, never a rejection, so the payer is not blamed for + * our outage. + */ +function fakeProvider(): PaymentProvider & { settleCalls: () => number } { + let settleCalls = 0; + return { + name: 'x402', + descriptor: paymentDescriptor, + settleCalls: () => settleCalls, + createRequirement: async (ctx: PaymentContext): Promise => ({ + id: 'requirement-1', + requestId: ctx.requestId, + resourceId: ctx.resource.id, + provider: 'x402', + amount: ctx.amount, + currency: ctx.currency, + destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + network: 'eip155:84532', + challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] }, + }), + verify: async (ctx: PaymentVerificationContext): Promise => { + if (ctx.submission.payload === 'unverifiable-proof') { + throw new Error('facilitator unreachable'); + } + return ctx.submission.payload === VALID_PROOF + ? { + status: 'verified', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + replayKey: `0xreplay-${settleCalls}`, + } + : { + status: 'rejected', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + rejectionReason: 'invalid_payment', + }; + }, + settle: async (_ctx: PaymentSettlementContext): Promise => { + settleCalls += 1; + return { + status: 'settled', + provider: 'x402', + amount: '0.01', + currency: 'USDC', + externalReference: '0xTXHASH', + replayKey: `0xreplay-${settleCalls}`, + }; + }, + health: async () => ({ status: 'pass', checkedAt: '2026-01-01T00:00:00.000Z' }), + }; +} + function config(publicBaseUrl: string): GatewayConfig { return { version: 1, @@ -120,6 +207,21 @@ function config(publicBaseUrl: string): GatewayConfig { exposedVia: ['a2a'], paymentMethods: [], }, + { + id: 'market_report', + name: 'Premium Market Report', + description: 'Latest market analysis.', + inputSchema: { + type: 'object', + properties: { symbol: { type: 'string' } }, + required: ['symbol'], + additionalProperties: false, + }, + handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' }, + pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' }, + exposedVia: ['a2a'], + paymentMethods: ['x402'], + }, { id: 'http_only', name: 'HTTP Only', @@ -138,6 +240,10 @@ export interface RunningGateway { readonly gateway: GatewayInstance; /** Origin the SDK discovers the card from. */ readonly url: string; + /** Merchant backend calls so far. */ + backendCalls(): number; + /** Successful settlements so far. */ + settleCalls(): number; close(): Promise; } @@ -164,10 +270,12 @@ export async function startConformanceGateway(): Promise { ...config(url), server: { port, host: '127.0.0.1', allowedOrigins: [] }, }; + const backend = countingBackend(); + const provider = fakeProvider(); const gateway = await createGateway({ config: gatewayConfig, store: createFakeStore(), - paymentProviders: [], + paymentProviders: [provider], // Mirrors src/gateway/main.ts's composition exactly: a conformance suite // that wired the adapter differently from production would certify a // deployment nobody runs. @@ -184,6 +292,8 @@ export async function startConformanceGateway(): Promise { return { gateway, url, + backendCalls: backend.calls, + settleCalls: provider.settleCalls, async close() { await gateway.close(); }, diff --git a/tests/integration/a2a-over-gateway.test.ts b/tests/integration/a2a-over-gateway.test.ts index 4ac8524..91cf7c1 100644 --- a/tests/integration/a2a-over-gateway.test.ts +++ b/tests/integration/a2a-over-gateway.test.ts @@ -62,8 +62,10 @@ function config(): GatewayConfig { } /** No merchant is reachable from a test; the adapter must never call one anyway. */ +const backendCalls: unknown[] = []; const backend: BackendExecutor = { - async call() { + async call(_handler, request) { + backendCalls.push(request.input); return { status: 200, body: { forecast: 'sunny' }, headers: {}, durationMs: 1 }; }, }; @@ -369,3 +371,126 @@ describe('A2A in gateway discovery', () => { ).toBe(404); }); }); + +/** + * 10.2 — the request body reaches the adapter unconsumed. + * + * Fastify pre-registers exact-match parsers for `application/json`, so a mount + * that does not suppress them hands the adapter an already-drained stream and + * every request fails to parse. That regression has shipped before. Driven + * through the gateway router, never by calling the adapter directly. + */ +describe('A2A request body handoff through the real gateway', () => { + it('parses a body the gateway would otherwise have consumed', async () => { + const gw = await startGateway(); + const { body } = await rpc( + gw, + sendMessage({ resource: 'weather_basic', input: { city: 'Berlin' } }), + ); + + expect(body.result?.task?.status.state).toBe('TASK_STATE_COMPLETED'); + }); + + it('carries a large body and non-ASCII input through intact', async () => { + const gw = await startGateway(); + // Big enough that the body arrives in several socket chunks, so a handler + // that reads only the first one fails here. + const city = `Köln-${'ß'.repeat(40_000)}`; + const { body } = await rpc(gw, sendMessage({ resource: 'weather_basic', input: { city } })); + + expect(body.error).toBeUndefined(); + expect(body.result?.task?.status.state).toBe('TASK_STATE_COMPLETED'); + expect(backendCalls.at(-1)).toEqual({ city }); + }); + + it('reads the body when the client sends no content-type at all', async () => { + const gw = await startGateway(); + const res = await gw.server.inject({ + method: 'POST', + url: '/a2a', + headers: { 'a2a-version': '1.0' }, + payload: JSON.stringify( + sendMessage({ resource: 'weather_basic', input: { city: 'Berlin' } }), + ), + }); + + expect(res.json().result?.task?.status.state).toBe('TASK_STATE_COMPLETED'); + }); +}); + +/** + * 10.3 — one adapter's failure is never another's, and never the process's. + */ +describe('A2A adapter isolation', () => { + it('keeps A2A serving when the MCP adapter fails to start', async () => { + const brokenMcp = createMcpAdapter(); + brokenMcp.start = async () => { + throw new Error('mcp could not start'); + }; + + gateway = await createGateway({ + config: config(), + store: createFakeStore(), + paymentProviders: [], + protocolAdapters: [brokenMcp, createA2aAdapter()], + backend, + }); + + const card = await gateway.server.inject({ + method: 'GET', + url: '/.well-known/agent-card.json', + }); + expect(card.statusCode).toBe(200); + + const { body } = await rpc( + gateway, + sendMessage({ resource: 'weather_basic', input: { city: 'Berlin' } }), + ); + expect(body.result?.task?.status.state).toBe('TASK_STATE_COMPLETED'); + + expect((await gateway.server.inject({ method: 'GET', url: '/mcp' })).statusCode).toBe(404); + }); + + it('fails one request safely when the A2A handler throws, leaving the gateway alive', async () => { + const adapter = createA2aAdapter(); + const realHandleHttp = adapter.handleHttp.bind(adapter); + let explode = true; + adapter.handleHttp = async (req, res) => { + if (explode) throw new Error('handler exploded: /var/secret/path'); + return realHandleHttp(req, res); + }; + + gateway = await createGateway({ + config: config(), + store: createFakeStore(), + paymentProviders: [], + protocolAdapters: [createMcpAdapter(), adapter], + backend, + }); + + const failed = await gateway.server.inject({ + method: 'POST', + url: '/a2a', + headers: { 'content-type': 'application/json', 'a2a-version': '1.0' }, + payload: JSON.stringify( + sendMessage({ resource: 'weather_basic', input: { city: 'Berlin' } }), + ), + }); + expect(failed.statusCode).toBe(500); + expect(failed.payload).not.toContain('/var/secret/path'); + + // The process is fine and every other surface still answers — including + // this adapter's own card route and, once it stops throwing, its mount. + explode = false; + expect( + (await gateway.server.inject({ method: 'GET', url: '/.well-known/agent-card.json' })) + .statusCode, + ).toBe(200); + expect((await gateway.server.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200); + const { body } = await rpc( + gateway, + sendMessage({ resource: 'weather_basic', input: { city: 'Berlin' } }), + ); + expect(body.result?.task?.status.state).toBe('TASK_STATE_COMPLETED'); + }); +});