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
26 changes: 26 additions & 0 deletions .changeset/a2a-adapter-v0-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@adcp/client': minor
---

**A2A serve adapter (preview).** `createA2AAdapter({ server, agentCard, authenticate, taskStore })` exposes the same `AdcpServer` that `serve()` mounts over MCP as a peer A2A JSON-RPC transport. Both transports share the dispatch pipeline — idempotency store, state store, `resolveAccount`, request/response validation, governance — so a handler change is picked up by both at once.

**Scope (v0):** `message/send`, `tasks/get`, `tasks/cancel`, `GET /.well-known/agent-card.json`. Streaming (`message/stream`), push notifications, and mid-flight `input-required` interrupts are explicit "not yet" — tracked for v1. The adapter is marked preview; pin a minor version while the AdCP-over-A2A conventions stabilise across the ecosystem.

**Handler return → A2A `Task.state` mapping (aligned with A2A 0.3.0 lifecycle):**

- Success arm → `completed` + DataPart artifact carrying the typed payload
- Submitted arm (`status:'submitted'`) → `completed` (the transport call itself completed; `submitted` is initial-only per A2A 0.3.0, not terminal) + DataPart artifact preserving the AdCP response; **`adcp_task_id` rides on `artifact.metadata`** so the AdCP payload still validates cleanly against the tool's response schema
- Error arm (`errors:[]`) → `failed` + DataPart artifact preserving the spec-defined error shape
- `adcpError('CODE', ...)` → `failed` + DataPart artifact with `adcp_error`

**Two lifecycles, one response.** A2A `Task.state` tracks the transport call (did the HTTP request complete?); AdCP `status` inside the artifact tracks the work (submitted / completed / failed). A `completed` A2A task can carry a `submitted` AdCP response — they're orthogonal state machines. Buyers resume async AdCP work via `artifact.metadata.adcp_task_id`.

**`mount(app)` convenience helper.** `adapter.mount(app)` wires all four routes from one call: JSON-RPC at the agent-card URL's pathname, the agent card at both `{basePath}/.well-known/agent-card.json` (A2A SDK discovery convention) and `/.well-known/agent-card.json` (origin-root probes). Eliminates the common 404 on first discovery when sellers mount the card at only one path. `A2AMountOptions` supports `basePath` override and `wellKnownAtRoot: false` for deployments where an upstream proxy owns origin-root routes.

**Skill addressing.** Clients send a `Message` with a single `DataPart` carrying `{ skill: '<tool_name>', input: { ... } }`. The legacy key `parameters` (emitted by `src/lib/protocols/a2a.ts` before the adapter landed) is accepted as an alias for `input` so same-SDK client/server pairs talk cleanly. Non-conforming messages surface as `Task.state='failed'` with `reason: 'INVALID_INVOCATION'`.

**New public surface.** `AdcpServer.invoke({ toolName, args, authInfo, signal })` — production-safe alias of the tool-call path both transports run through. Docstring makes auth the caller's responsibility; `dispatchTestRequest` stays the test-only sibling.

**New exports** (from `@adcp/client` and `@adcp/client/server`): `createA2AAdapter`, `A2AInvocationError`, `A2AAdapter`, `A2AAdapterOptions`, `A2AAgentCardOverrides`, `A2AMountOptions`, `ExpressAppLike`, plus `AdcpAuthInfo` and `AdcpInvokeOptions` for transport authors building custom adapters.

**Dependencies.** Uses `@a2a-js/sdk` (already a peer dep for the client-side caller) via its `/server` subpath export; no new peer deps required. `@types/express` added as a devDep so our types resolve when the SDK's express middleware returns `RequestHandler` from `express`.
62 changes: 62 additions & 0 deletions docs/guides/BUILD-AN-AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,68 @@ serve(() => createAdcpServer({
- **Warns on incoherent tool sets** — e.g., `create_media_buy` without `get_products`
- **Narrows response unions** — a handler may return the Success arm *or* the full response union (`Success | Error | Submitted`). Adapter-style handlers that already produce `Result<CreateMediaBuyResponse, ...>` don't need to pre-narrow: the dispatcher detects the arm by shape and routes accordingly. Error arms surface as `{ isError: true, structuredContent: { errors: [...] } }`; Submitted arms surface as `{ structuredContent: { status: 'submitted', task_id, ... } }` without the Success-only `revision` / `confirmed_at` defaults. You can still call `adcpError('CODE', ...)` directly for framework-style error envelopes.

### Exposing your agent over A2A (preview)

MCP is the default transport (`serve({ server: adcp })`). To additionally expose the same `AdcpServer` over A2A JSON-RPC — so A2A-native buyers can discover and call your agent — mount `createA2AAdapter`:

```typescript
import express from 'express';
import { createAdcpServer, serve, createA2AAdapter } from '@adcp/client';

const adcp = createAdcpServer({
mediaBuy: { getProducts: async () => ({ products: [] }) },
});

// MCP (as today)
serve(() => adcp);

// A2A (new, preview)
const a2a = createA2AAdapter({
server: adcp,
agentCard: {
name: 'Acme SSP',
description: 'Guaranteed + non-guaranteed display inventory',
url: 'https://ssp.acme.com/a2a',
version: '1.0.0',
provider: { organization: 'Acme', url: 'https://acme.com' },
securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
},
async authenticate(req) {
const token = req.headers.authorization?.replace(/^Bearer\s+/, '');
return token ? { token, clientId: 'buyer_123', scopes: [] } : null;
},
});

const app = express();
app.use(express.json());
// mount() wires: JSON-RPC at the `agentCard.url` pathname (`/a2a` here),
// the agent card at both `{basePath}/.well-known/agent-card.json` (A2A
// SDK discovery derives this) AND `/.well-known/agent-card.json` (origin-
// root probes). The `jsonRpcHandler` and `agentCardHandler` properties
// stay exposed for deployments that need custom mounting.
a2a.mount(app);
app.listen(3000);
```

Both transports share the same `AdcpServer` — handlers, idempotency store, state store, and `resolveAccount` all run the same pipeline regardless of which transport received the request. Changes to handlers are picked up by both at once.

**Skill addressing.** A2A clients send a `Message` with a single `DataPart`: `{ kind: 'data', data: { skill: 'get_products', input: { brief: '...' } } }`. `skill` matches an AdCP tool name; `input` is the tool arguments. The legacy key `parameters` (shipped by `src/lib/protocols/a2a.ts` before the adapter landed) is accepted as an alias for `input` so same-SDK clients and servers talk cleanly.

**Two lifecycles, one response.** A2A's `Task.state` tracks the *transport call* (did the HTTP request complete?). AdCP's `status` inside the artifact tracks the *work* (submitted / completed / failed). Don't conflate them — a `completed` A2A task can carry a `submitted` AdCP response, meaning the call returned but the ad-tech operation is still queued.

**Handler return → A2A `Task.state` + artifact:**

| Handler returned… | A2A `Task.state` | Artifact payload |
|---|---|---|
| Success arm | `completed` | DataPart with the typed AdCP response |
| Submitted arm (`status:'submitted'`) | `completed` | DataPart with the AdCP response; `adcp_task_id` on `artifact.metadata` |
| Error arm (`errors: [...]`) | `failed` | DataPart with the Error arm payload |
| `adcpError('CODE', ...)` | `failed` | DataPart with `adcp_error` |

**A2A `Task.id` vs AdCP `task_id`.** A2A owns its `Task.id` (SDK-generated per `message/send`). The AdCP-level `task_id` — present when the handler returned a Submitted arm — rides on `artifact.metadata.adcp_task_id`, off the DataPart's payload so the `data` still validates cleanly against the AdCP response schema. Buyers resuming the A2A side poll via `tasks/get` using the A2A `Task.id`; buyers reaching for AdCP-side async state use `adcp_task_id`.

**v0 scope.** `message/send`, `tasks/get`, `tasks/cancel`, `GET /.well-known/agent-card.json`. Streaming (`message/stream`), push notifications, and `input-required` mid-flight interrupts are explicit "not yet" — tracked for v1. Pin a minor version while the surface stabilises.

### Reading tool results (client side)

The framework emits responses with typed data in `structuredContent` (MCP L3) and a human-readable summary in `content[0].text` (L2). When calling an AdCP agent from client code, prefer `structuredContent`; only fall back to parsing the text block for pre-`structuredContent` servers. The SDK ships two helpers with different failure modes:
Expand Down
113 changes: 111 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 @@ -245,6 +245,7 @@
"@commitlint/config-conventional": "^19.6.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",
"@types/express": "4.17.25",
"@types/node": "^20.19.13",
"@types/pg": "^8.20.0",
"@types/tar": "^6.1.13",
Expand Down
7 changes: 7 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,8 @@ export {
IDEMPOTENCY_MIGRATION,
cleanupExpiredIdempotency,
hashPayload,
createA2AAdapter,
A2AInvocationError,
} from './server';
export type {
AdcpErrorOptions,
Expand Down Expand Up @@ -656,6 +658,11 @@ export type {
IdempotencyCheckResult,
MemoryBackendOptions,
PgBackendOptions,
A2AAdapter,
A2AAdapterOptions,
A2AAgentCardOverrides,
A2AMountOptions,
ExpressAppLike,
} from './server';

// ====== ERROR HANDLING & RETRY ======
Expand Down
Loading
Loading