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 @@
+
## 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');
+ });
+});