Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-strict-3178c6">
<img alt="MCP" src="https://img.shields.io/badge/MCP-supported-6b4fbb">
<img alt="x402" src="https://img.shields.io/badge/x402-supported-0052ff">
<img alt="A2A" src="https://img.shields.io/badge/A2A-experimental-f0a30a">
</p>

## What it is, in ten seconds
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ protocols:
mcp:
enabled: true
mountPath: /mcp
# 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

resources:
# --- free resource: proves the gateway fronts an existing API -------------
Expand Down
3 changes: 3 additions & 0 deletions demo/dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion docs/contract-surface.txt
Original file line number Diff line number Diff line change
@@ -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<string>;
Expand All @@ -19,6 +19,12 @@ interface AdapterHealth {
readonly status: "pass" | "warn" | "fail";
}

interface AdapterHttpRoute {
handleHttp: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
readonly method: "GET" | "POST";
readonly path: string;
}

interface BackendExecutor {
call: (handler: BackendHandler, request: BackendRequest) => Promise<BackendResponse>;
}
Expand Down Expand Up @@ -156,6 +162,7 @@ interface ExecutionPipeline {
interface HttpProtocolAdapter {
handleHttp: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
health: () => Promise<AdapterHealth>;
readonly additionalHttpRoutes?: ReadonlyArray<AdapterHttpRoute>;
readonly descriptor: AdapterDescriptor;
readonly mountPath: string;
readonly name: ProtocolName;
Expand Down
2 changes: 2 additions & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
---

Expand Down
103 changes: 102 additions & 1 deletion docs/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<base64 x402 proof>" }
},
"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`.
Expand Down Expand Up @@ -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

Expand Down
34 changes: 32 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(', '),
});
}

Expand Down
Loading
Loading