From f66887408377df56ef19982bafbf51afee53914f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 12:37:55 -0400 Subject: [PATCH 1/4] feat(server): A2A transport adapter (preview) peer to MCP serve() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, and governance — so a handler change is picked up by both at once. v0 supports message/send, tasks/get, tasks/cancel, and GET of /.well-known/agent-card.json. Streaming, push notifications, and mid-flight input-required interrupts are explicit "not yet" for v1. Adapter is marked preview; pin a minor version while conventions settle. Handler return → A2A Task.state mapping: - Success arm → completed + DataPart artifact - Submitted arm (status:'submitted') → submitted + adcp_task_id on artifact - Error arm (errors:[]) → failed + DataPart artifact - adcpError() envelope → failed + adcp_error on artifact A2A owns Task.id (SDK-generated). The AdCP-level task_id (when a handler returns Submitted) rides on the DataPart artifact as adcp_task_id. A2A-native buyers poll the A2A Task.id; the adcp_task_id is the handle for MCP-direct polling. New surfaces: - AdcpServer.invoke({ toolName, args, authInfo, signal }) — production-safe alias of the tool-call path both transports call. Docstring requires caller to have authenticated the principal first. dispatchTestRequest stays test-only. - Types: AdcpAuthInfo, AdcpInvokeOptions for transport authors. - Exports: createA2AAdapter, A2AInvocationError, A2AAdapter, A2AAdapterOptions, A2AAgentCardOverrides. Agent card hybrid: seller supplies identity (name, description, url, version, provider, securitySchemes); SDK seeds skills[] from registered tools, defaults capabilities.streaming=false / pushNotifications=false, validates merged card at boot — unserviceable cards fail createA2AAdapter() rather than shipping to the wire. Skill addressing: clients send a Message with one DataPart carrying { skill: '', input: { ... } }. Non-conforming messages surface as Task.state='failed' with reason: 'INVALID_INVOCATION'. Uses @a2a-js/sdk (already a peer dep for the client-side caller) via its /server subpath. @types/express added as devDep so our types resolve when the SDK's express middleware returns RequestHandler. Addresses #877. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/a2a-adapter-v0-preview.md | 24 + docs/guides/BUILD-AN-AGENT.md | 56 +++ package-lock.json | 113 ++++- package.json | 1 + src/lib/index.ts | 5 + src/lib/server/a2a-adapter.ts | 669 +++++++++++++++++++++++++++ src/lib/server/adcp-server.ts | 97 +++- src/lib/server/index.ts | 3 + test/server-a2a-adapter.test.js | 430 +++++++++++++++++ 9 files changed, 1387 insertions(+), 11 deletions(-) create mode 100644 .changeset/a2a-adapter-v0-preview.md create mode 100644 src/lib/server/a2a-adapter.ts create mode 100644 test/server-a2a-adapter.test.js diff --git a/.changeset/a2a-adapter-v0-preview.md b/.changeset/a2a-adapter-v0-preview.md new file mode 100644 index 000000000..f6167663c --- /dev/null +++ b/.changeset/a2a-adapter-v0-preview.md @@ -0,0 +1,24 @@ +--- +'@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`:** + +- Success arm → `completed` + DataPart artifact carrying the typed payload +- Submitted arm (`status:'submitted'`) → `submitted` + DataPart artifact with `adcp_task_id` surfaced alongside the AdCP payload. A2A's `Task.id` is SDK-generated; the AdCP-level handle rides on the artifact. +- Error arm (`errors:[]`) → `failed` + DataPart artifact preserving the spec-defined error shape +- `adcpError('CODE', ...)` → `failed` + DataPart artifact with `adcp_error` + +**Agent card.** Seller supplies identity (`name`, `description`, `url`, `version`, `provider`, `securitySchemes`); the SDK seeds `skills[]` from registered AdCP tools, defaults `capabilities.streaming=false` / `pushNotifications=false` (v0 ships neither), and validates the merged card against A2A's required-field set at boot — unserviceable cards fail `createA2AAdapter()` rather than shipping to the wire. + +**Skill addressing.** Clients send a `Message` with a single `DataPart` carrying `{ skill: '', input: { ... } }`. Non-conforming messages surface as `Task.state='failed'` with `reason: 'INVALID_INVOCATION'` rather than silently misrouting. + +**New public surface.** `AdcpServer.invoke({ toolName, args, authInfo, signal })` — production-safe alias of the tool-call path both transports run through. Documented as requiring the caller to have authenticated the principal. `dispatchTestRequest` stays as the test-only sibling with its "never mount behind HTTP" docstring intact. + +**New exports** (from `@adcp/client` and `@adcp/client/server`): `createA2AAdapter`, `A2AInvocationError`, `A2AAdapter`, `A2AAdapterOptions`, `A2AAgentCardOverrides`, 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`. diff --git a/docs/guides/BUILD-AN-AGENT.md b/docs/guides/BUILD-AN-AGENT.md index 7cac1648e..97a6ab31c 100644 --- a/docs/guides/BUILD-AN-AGENT.md +++ b/docs/guides/BUILD-AN-AGENT.md @@ -98,6 +98,62 @@ 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` 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()); +app.use('/.well-known/agent-card.json', a2a.agentCardHandler); +app.use('/a2a', a2a.jsonRpcHandler); +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. + +**Handler return → A2A `Task.state`:** + +| Handler returned… | A2A result | +|---|---| +| Success arm | `state: 'completed'` + DataPart artifact | +| Submitted arm (`status:'submitted'`) | `state: 'submitted'` + `adcp_task_id` on the artifact | +| Error arm (`errors: [...]`) | `state: 'failed'` + DataPart artifact | +| `adcpError('CODE', ...)` | `state: 'failed'` + `adcp_error` artifact | + +**A2A `Task.id` vs AdCP `task_id`.** A2A owns its Task.id (SDK-generated per `message/send`). The AdCP-level `task_id` — if your handler returned a Submitted arm — rides on the DataPart artifact as `adcp_task_id`. A2A-native clients poll via `tasks/get` using the A2A 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: diff --git a/package-lock.json b/package-lock.json index 0f8e8fbf8..5d9521454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adcp/client", - "version": "5.15.0", + "version": "5.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adcp/client", - "version": "5.15.0", + "version": "5.16.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", @@ -27,6 +27,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", @@ -1099,6 +1100,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.2", "dev": true, @@ -1117,6 +1139,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "dev": true, @@ -1125,6 +1173,13 @@ "@types/unist": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -1135,6 +1190,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "dev": true, @@ -1153,6 +1215,53 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/tar": { "version": "6.1.13", "dev": true, diff --git a/package.json b/package.json index 16a6a32ea..b546dfc92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/index.ts b/src/lib/index.ts index b20670a8f..7ceb5739a 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -586,6 +586,8 @@ export { IDEMPOTENCY_MIGRATION, cleanupExpiredIdempotency, hashPayload, + createA2AAdapter, + A2AInvocationError, } from './server'; export type { AdcpErrorOptions, @@ -656,6 +658,9 @@ export type { IdempotencyCheckResult, MemoryBackendOptions, PgBackendOptions, + A2AAdapter, + A2AAdapterOptions, + A2AAgentCardOverrides, } from './server'; // ====== ERROR HANDLING & RETRY ====== diff --git a/src/lib/server/a2a-adapter.ts b/src/lib/server/a2a-adapter.ts new file mode 100644 index 000000000..a9b9542a1 --- /dev/null +++ b/src/lib/server/a2a-adapter.ts @@ -0,0 +1,669 @@ +/** + * A2A transport adapter for `AdcpServer`. + * + * Peer of `serve()` / `createExpressAdapter()`: same `AdcpServer` handle, + * different wire transport. MCP and A2A share the dispatcher, idempotency + * store, state store, resolveAccount, and governance — everything the + * framework pipeline owns is transport-agnostic. + * + * **Scope (v0)**: `message/send`, `tasks/get`, `tasks/cancel`, and + * `GET /.well-known/agent-card.json`. Streaming (`message/stream`), + * push notifications, and mid-flight `input-required` interrupts are + * explicit "not yet" — see `docs/guides/BUILD-AN-AGENT.md`. + * + * **Handler-return → A2A `Task.state` mapping:** + * + * | Handler returned… | A2A result | + * |-------------------------------------|----------------------------------------------------| + * | Success arm | `Task.state = 'completed'` + DataPart artifact | + * | Submitted arm (`status:'submitted'`)| `Task.state = 'submitted'` + DataPart artifact[^1] | + * | Error arm (`errors:[]`) | `Task.state = 'failed'` + DataPart artifact | + * | `adcpError()` envelope | `Task.state = 'failed'` + DataPart artifact | + * + * [^1]: A2A owns `Task.id`. The AdCP-level `task_id` rides on the DataPart + * artifact's `data.adcp_task_id` — buyers poll the A2A `Task.id` via + * `tasks/get`; the `adcp_task_id` is the handle they'd use against AdCP + * tool-task APIs if they were calling the agent over MCP directly. + * + * **Message shape.** A client addresses a tool by sending a `Message` with + * a single `DataPart`: `{ kind: 'data', data: { skill, input } }`. The + * `skill` must match a registered AdCP tool name (e.g. `get_products`); + * `input` becomes the tool arguments before AdCP schema validation runs. + * + * @preview v0 surface — field semantics may shift while the ecosystem + * converges on AdCP-over-A2A conventions. Pinning a minor version is + * recommended. + */ + +import { + type AgentExecutor, + type ExecutionEventBus, + type RequestContext, + type TaskStore, + type User, + type UnauthenticatedUser, + DefaultRequestHandler, + InMemoryTaskStore as SdkInMemoryTaskStore, + DefaultExecutionEventBusManager, +} from '@a2a-js/sdk/server'; +import { jsonRpcHandler, agentCardHandler } from '@a2a-js/sdk/server/express'; +import type { + AgentCard, + AgentCapabilities, + AgentProvider, + AgentSkill, + Artifact, + DataPart, + Message, + SecurityScheme, + Task, + TaskArtifactUpdateEvent, + TaskStatusUpdateEvent, +} from '@a2a-js/sdk'; +import type { Request, RequestHandler } from 'express'; +import { randomUUID } from 'node:crypto'; +import { getSdkServer, listRegisteredToolNames, type AdcpAuthInfo, type AdcpServer } from './adcp-server'; +import type { McpToolResponse } from './responses'; +import type { AdcpLogger } from './create-adcp-server'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Agent-card identity fields the adapter can't derive automatically. + * Auto-seeded fields (`capabilities`, `skills`, `defaultInputModes`, + * `defaultOutputModes`, `protocolVersion`, `additionalInterfaces`) may + * be overridden by passing them here; the merged card is validated + * against A2A's required-field set at boot. + */ +export interface A2AAgentCardOverrides { + /** Human-readable agent name (required). */ + name: string; + /** Human-readable description (required). */ + description: string; + /** Agent URL — the endpoint A2A clients connect to (required). */ + url: string; + /** Agent version (required). */ + version: string; + + provider?: AgentProvider; + documentationUrl?: string; + iconUrl?: string; + securitySchemes?: { [k: string]: SecurityScheme }; + security?: { [k: string]: string[] }[]; + preferredTransport?: string; + + /** + * Override the auto-generated capabilities. The adapter sets + * `streaming: false` and `pushNotifications: false` by default (v0 + * ships neither). Set `streaming: true` if you wire a downstream + * extension; the adapter still won't emit `TaskStatusUpdateEvent`s + * on the stream path in v0. + */ + capabilities?: AgentCapabilities; + + /** + * Override the auto-generated skills list. When omitted the adapter + * derives one `AgentSkill` per registered AdCP tool from the server's + * capability object. Supply this to add descriptions, examples, tags, + * or per-skill input/output modes the SDK can't infer. + */ + skills?: AgentSkill[]; + + defaultInputModes?: string[]; + defaultOutputModes?: string[]; + protocolVersion?: string; +} + +/** + * Options for {@link createA2AAdapter}. + * + * **Auth posture.** `authenticate(req)` runs BEFORE the tool handler + * sees the request. Return an `AdcpAuthInfo` to let the pipeline + * proceed with that principal; return `null` (or throw) to reject. + * A rejection currently surfaces as a generic JSON-RPC `-32000` + * server error — the `@a2a-js/sdk` doesn't yet expose a typed + * authentication-failed code for the `UserBuilder` path. Production + * deployments SHOULD wire upstream middleware (e.g. `express-jwt`) to + * reject with a proper HTTP 401 / WWW-Authenticate challenge before + * the request reaches `jsonRpcHandler`. The `authenticate` option + * here is a last-line-of-defense guard, not the primary auth surface. + * + * **Agent-card `securitySchemes`.** The `agentCard.securitySchemes` + * you provide is served verbatim at `/.well-known/agent-card.json` — + * only put non-secret discovery data there (token endpoint, scopes, + * OIDC issuer URL). Never paste client secrets, private JWKS, or + * internal URLs into the card. The SDK doesn't schema-validate + * `securitySchemes` at boot (v0 check is required-field presence + * only), so a hand-crafted malformed entry will ship as-written. + * + * Omitting `authenticate` makes the adapter anonymous — handlers see + * `ctx.authInfo === undefined`, matching `serve({ authenticate: undefined })`. + */ +export interface A2AAdapterOptions { + /** AdCP server whose registered tools this adapter exposes over A2A. */ + server: AdcpServer; + + /** + * Authenticate an inbound A2A request. Transport-level auth runs + * before `AdcpServer.invoke()` so the framework pipeline sees a + * verified `authInfo`. Return `null` (or throw) to reject. + */ + authenticate?: (req: Request) => Promise; + + /** Seller-supplied agent-card identity fields. Required. */ + agentCard: A2AAgentCardOverrides; + + /** + * A2A task store. Defaults to the SDK's `InMemoryTaskStore`. + * Persistent deployments should supply a durable implementation + * (e.g. a Postgres-backed `TaskStore`). + */ + taskStore?: TaskStore; + + /** Optional logger. Falls back to `console`. */ + logger?: AdcpLogger; +} + +/** + * Value returned by {@link createA2AAdapter}. `jsonRpcHandler` accepts + * A2A JSON-RPC posts (`message/send`, `tasks/get`, `tasks/cancel`); + * mount it on the path your agent card advertises. `agentCardHandler` + * serves the discovery GET — mount it at + * `/.well-known/agent-card.json`. + */ +export interface A2AAdapter { + jsonRpcHandler: RequestHandler; + agentCardHandler: RequestHandler; + /** Returns the merged, validated agent card. */ + getAgentCard(): Promise; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/** + * Our `User` carries the full AdCP auth payload, not just the two + * getters A2A's minimal `User` requires. The executor reads this back + * out of `RequestContext.context.user`. + */ +interface A2AAdcpUser extends User { + readonly adcpAuthInfo?: AdcpAuthInfo; +} + +function buildAuthenticatedUser(authInfo: AdcpAuthInfo): A2AAdcpUser { + const clientId = authInfo.clientId; + return { + get isAuthenticated() { + return true; + }, + get userName() { + return clientId; + }, + adcpAuthInfo: authInfo, + }; +} + +function buildAnonymousUser(): UnauthenticatedUser { + return { + get isAuthenticated() { + return false as const; + }, + get userName() { + return 'anonymous'; + }, + }; +} + +function getAdcpAuthInfo(context: RequestContext['context']): AdcpAuthInfo | undefined { + const user = context?.user as A2AAdcpUser | undefined; + return user?.adcpAuthInfo; +} + +/** + * Extract the `{ skill, input }` pair from the inbound Message's parts. + * + * Convention: the client sends a single DataPart with + * `{ skill: '', input: { ...args } }`. Reject anything else + * — text-only payloads, files, multiple data parts — so buyers get a + * deterministic error instead of silently-wrong routing. + */ +interface ExtractedInvocation { + skill: string; + input: Record; +} + +function extractInvocation(message: Message): ExtractedInvocation { + if (!Array.isArray(message.parts) || message.parts.length === 0) { + throw new A2AInvocationError('Message must carry at least one part with a `data` kind.'); + } + const dataParts = message.parts.filter((p): p is DataPart => p?.kind === 'data'); + if (dataParts.length === 0) { + throw new A2AInvocationError( + "Message must include a DataPart with { skill, input } — text-only messages aren't routable to AdCP tools." + ); + } + if (dataParts.length > 1) { + throw new A2AInvocationError( + 'Message must include exactly one DataPart — multi-part invocations are not supported in v0.' + ); + } + const firstDataPart = dataParts[0]!; + const rawData = firstDataPart.data; + // Guard before destructuring — a client sending `{ kind: 'data', data: null }` + // or `data: "string"` would otherwise TypeError on payload.skill and surface + // as a generic HANDLER_THREW instead of INVALID_INVOCATION. + if (rawData == null || typeof rawData !== 'object' || Array.isArray(rawData)) { + throw new A2AInvocationError('DataPart `data` must be an object containing { skill, input }.'); + } + const payload = rawData as Record; + const skill = payload.skill; + const input = payload.input; + if (typeof skill !== 'string' || skill.length === 0) { + throw new A2AInvocationError('DataPart must include a non-empty string `skill` field naming the AdCP tool.'); + } + if (input != null && (typeof input !== 'object' || Array.isArray(input))) { + throw new A2AInvocationError('DataPart `input` must be an object (or omitted).'); + } + return { skill, input: (input as Record) ?? {} }; +} + +/** Thrown when an incoming Message doesn't match the AdCP-over-A2A convention. */ +export class A2AInvocationError extends Error { + constructor(message: string) { + super(message); + this.name = 'A2AInvocationError'; + } +} + +// --------------------------------------------------------------------------- +// Executor — translates A2A execute() into adcpServer.invoke() + events +// --------------------------------------------------------------------------- + +/** + * Classify a framework-produced `McpToolResponse` so the executor knows + * what A2A `Task.state` to publish. + */ +type ClassifiedResult = + | { kind: 'success'; data: Record } + | { kind: 'submitted'; adcpTaskId: string; data: Record } + | { kind: 'error_arm'; data: Record } + | { kind: 'adcp_error'; data: Record }; + +function classifyResponse(res: McpToolResponse): ClassifiedResult { + const structured = (res.structuredContent ?? {}) as Record; + if (res.isError === true) { + if (structured.adcp_error && typeof structured.adcp_error === 'object') { + return { kind: 'adcp_error', data: structured }; + } + return { kind: 'error_arm', data: structured }; + } + if (structured.status === 'submitted' && typeof structured.task_id === 'string') { + return { kind: 'submitted', adcpTaskId: structured.task_id, data: structured }; + } + return { kind: 'success', data: structured }; +} + +class AdcpA2AAgentExecutor implements AgentExecutor { + // Cooperative-cancel flag. `DefaultRequestHandler.cancelTask` only + // calls our executor's `cancelTask` while an execute() is in-flight + // (eventBus still open); post-completion cancels are handled by the + // SDK directly against the task store. So this set only holds + // taskIds that currently have a pending `cancelTask`, and execute() + // always clears its entry at the end — no unbounded growth. + private readonly canceled = new Set(); + + // A2A `Task.id` → original `contextId`. Populated when execute() + // starts so `cancelTask` can emit a well-formed status-update event + // against the same contextId instead of guessing an empty string. + // Cleared alongside the canceled flag in execute()'s finally block. + private readonly taskContextIds = new Map(); + + constructor( + private readonly server: AdcpServer, + private readonly logger: AdcpLogger + ) {} + + async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise { + const { taskId, contextId, userMessage } = requestContext; + const authInfo = getAdcpAuthInfo(requestContext.context); + + this.taskContextIds.set(taskId, contextId); + try { + // Register the task with the ResultManager by publishing a Task + // event first — subsequent status-update / artifact-update events + // only resolve if the manager has seen the task. `working` is the + // initial state; we replace it with completed / submitted / failed + // once the handler returns. + this.publishInitialTask(eventBus, taskId, contextId, userMessage); + + let invocation: ExtractedInvocation; + try { + invocation = extractInvocation(userMessage); + } catch (err) { + const message = err instanceof Error ? err.message : 'Invalid A2A invocation'; + this.emitFailure(eventBus, taskId, contextId, { + reason: 'INVALID_INVOCATION', + message, + }); + return; + } + + let response: McpToolResponse; + try { + response = await this.server.invoke({ + toolName: invocation.skill, + args: invocation.input, + ...(authInfo && { authInfo }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error('A2A adapter: handler invocation threw', { toolName: invocation.skill, error: message }); + this.emitFailure(eventBus, taskId, contextId, { + reason: 'HANDLER_THREW', + message, + }); + return; + } + + if (this.canceled.has(taskId)) { + this.publishStatus(eventBus, taskId, contextId, 'canceled', true); + return; + } + + const classified = classifyResponse(response); + this.publishArtifact(eventBus, taskId, contextId, classified); + this.publishStatus( + eventBus, + taskId, + contextId, + classified.kind === 'success' ? 'completed' : classified.kind === 'submitted' ? 'submitted' : 'failed', + true + ); + } finally { + // Clean up per-task state regardless of path — the executor is + // long-lived (one per adapter instance), so any leak compounds. + this.canceled.delete(taskId); + this.taskContextIds.delete(taskId); + eventBus.finished(); + } + } + + async cancelTask(taskId: string, eventBus: ExecutionEventBus): Promise { + this.canceled.add(taskId); + // `DefaultRequestHandler.cancelTask` only reaches us when execute() + // is still in-flight (eventBus still open). Publish the canceled + // status so the SDK's secondary `_processEvents` loop terminates; + // execute() will ALSO see the flag and short-circuit before + // publishing a success/failure status. The A2A event bus is + // idempotent on duplicate status publishes — whichever lands first + // wins the taskStore write. + const contextId = this.taskContextIds.get(taskId) ?? ''; + this.publishStatus(eventBus, taskId, contextId, 'canceled', true); + // Do NOT call eventBus.finished() here — execute()'s finally block + // owns the finished() signal, and calling it twice risks closing + // the queue mid-event-flush on the primary processEvents loop. + } + + private publishInitialTask( + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + userMessage: Message + ): void { + const task: Task = { + kind: 'task', + id: taskId, + contextId, + status: { + state: 'working', + timestamp: new Date().toISOString(), + }, + history: [userMessage], + }; + eventBus.publish(task); + } + + private publishStatus( + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + state: TaskStatusUpdateEvent['status']['state'], + final: boolean + ): void { + const event: TaskStatusUpdateEvent = { + kind: 'status-update', + taskId, + contextId, + status: { + state, + timestamp: new Date().toISOString(), + }, + final, + }; + eventBus.publish(event); + } + + private publishArtifact( + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + classified: ClassifiedResult + ): void { + const artifactName = + classified.kind === 'success' ? 'result' : classified.kind === 'submitted' ? 'submitted' : 'error'; + const artifact: Artifact = { + artifactId: randomUUID(), + name: artifactName, + parts: [ + { + kind: 'data', + data: + classified.kind === 'submitted' + ? { ...classified.data, adcp_task_id: classified.adcpTaskId } + : classified.data, + }, + ], + }; + const event: TaskArtifactUpdateEvent = { + kind: 'artifact-update', + taskId, + contextId, + artifact, + append: false, + lastChunk: true, + }; + eventBus.publish(event); + } + + private emitFailure( + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + payload: { reason: string; message: string } + ): void { + const artifact: Artifact = { + artifactId: randomUUID(), + name: 'error', + parts: [{ kind: 'data', data: payload }], + }; + eventBus.publish({ + kind: 'artifact-update', + taskId, + contextId, + artifact, + append: false, + lastChunk: true, + } satisfies TaskArtifactUpdateEvent); + this.publishStatus(eventBus, taskId, contextId, 'failed', true); + } +} + +// --------------------------------------------------------------------------- +// Agent card +// --------------------------------------------------------------------------- + +const DEFAULT_MODES = ['application/json'] as const; +const DEFAULT_PROTOCOL_VERSION = '0.3.0'; + +/** + * Derive one `AgentSkill` per registered AdCP tool. Skills without + * seller-supplied descriptions get a generic one pointing at the + * AdCP tool name — enough to pass A2A registry validation; sellers + * are expected to enrich via `agentCard.skills` in production. + */ +function deriveSkills(toolNames: string[]): AgentSkill[] { + return toolNames.map(name => ({ + id: name, + name, + description: `AdCP tool: ${name}. Send { skill: "${name}", input: { ... } } as a DataPart.`, + tags: ['adcp'], + })); +} + +function listRegisteredTools(server: AdcpServer): string[] { + const sdk = getSdkServer(server); + if (!sdk) return []; + return listRegisteredToolNames(sdk).filter(name => name !== 'get_adcp_capabilities'); +} + +function buildAgentCard(server: AdcpServer, overrides: A2AAgentCardOverrides): AgentCard { + const tools = listRegisteredTools(server); + const skills = overrides.skills ?? deriveSkills(tools); + + const card: AgentCard = { + name: overrides.name, + description: overrides.description, + url: overrides.url, + version: overrides.version, + protocolVersion: overrides.protocolVersion ?? DEFAULT_PROTOCOL_VERSION, + defaultInputModes: overrides.defaultInputModes ?? [...DEFAULT_MODES], + defaultOutputModes: overrides.defaultOutputModes ?? [...DEFAULT_MODES], + capabilities: overrides.capabilities ?? { + streaming: false, + pushNotifications: false, + }, + skills, + ...(overrides.provider && { provider: overrides.provider }), + ...(overrides.documentationUrl && { documentationUrl: overrides.documentationUrl }), + ...(overrides.iconUrl && { iconUrl: overrides.iconUrl }), + ...(overrides.securitySchemes && { securitySchemes: overrides.securitySchemes }), + ...(overrides.security && { security: overrides.security }), + ...(overrides.preferredTransport && { preferredTransport: overrides.preferredTransport }), + }; + + validateAgentCard(card); + return card; +} + +/** + * Fail loud at adapter construction when the merged card misses + * A2A-required fields. The SDK would reject the discovery response + * at runtime anyway — better to catch it at boot so the agent never + * binds a port with an unserviceable card. + */ +function validateAgentCard(card: AgentCard): void { + const missing: string[] = []; + if (!card.name) missing.push('name'); + if (!card.description) missing.push('description'); + if (!card.url) missing.push('url'); + if (!card.version) missing.push('version'); + if (!card.protocolVersion) missing.push('protocolVersion'); + if (!card.capabilities) missing.push('capabilities'); + if (!Array.isArray(card.defaultInputModes) || card.defaultInputModes.length === 0) { + missing.push('defaultInputModes'); + } + if (!Array.isArray(card.defaultOutputModes) || card.defaultOutputModes.length === 0) { + missing.push('defaultOutputModes'); + } + if (!Array.isArray(card.skills)) missing.push('skills'); + if (missing.length > 0) { + throw new Error( + `createA2AAdapter: agent card is missing required fields — ${missing.join(', ')}. ` + + `Supply them via options.agentCard so A2A discovery doesn't fail at runtime.` + ); + } + if (Array.isArray(card.skills) && card.skills.length === 0) { + throw new Error( + 'createA2AAdapter: agent card has no skills — register AdCP handlers on the server (or supply options.agentCard.skills) before creating the adapter.' + ); + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +const DEFAULT_LOGGER: AdcpLogger = { + debug: (m, d) => console.debug(m, d ?? ''), + info: (m, d) => console.info(m, d ?? ''), + warn: (m, d) => console.warn(m, d ?? ''), + error: (m, d) => console.error(m, d ?? ''), +}; + +/** + * Create an A2A transport adapter around an `AdcpServer`. + * + * @example + * ```ts + * const adcp = createAdcpServer({ mediaBuy: { getProducts: async () => ({ products: [] }) } }); + * 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 = extractBearer(req); + * return token ? { token, clientId: 'buyer_123', scopes: [] } : null; + * }, + * }); + * + * app.use('/a2a', a2a.jsonRpcHandler); + * app.get('/.well-known/agent-card.json', a2a.agentCardHandler); + * ``` + * + * @preview — see the module docstring. + */ +export function createA2AAdapter(options: A2AAdapterOptions): A2AAdapter { + const logger = options.logger ?? DEFAULT_LOGGER; + const card = buildAgentCard(options.server, options.agentCard); + const taskStore = options.taskStore ?? new SdkInMemoryTaskStore(); + const executor = new AdcpA2AAgentExecutor(options.server, logger); + const eventBusManager = new DefaultExecutionEventBusManager(); + const requestHandler = new DefaultRequestHandler(card, taskStore, executor, eventBusManager); + + const userBuilder = async (req: Request): Promise => { + if (!options.authenticate) return buildAnonymousUser(); + const authInfo = await options.authenticate(req); + if (authInfo == null) { + // Throwing an A2AError with an authentication code would give the + // SDK's JSON-RPC envelope the right shape, but the SDK keeps + // `A2AError` internal — surfacing as a thrown Error yields a + // generic -32000 server error, which is still closer to the + // right signal than silently continuing anonymously. Most + // deployments should reject before the UserBuilder via upstream + // middleware (e.g. `express-jwt`); auth via the UserBuilder is + // the fallback path. + throw new Error('A2A authentication failed'); + } + return buildAuthenticatedUser(authInfo); + }; + + const jsonRpc = jsonRpcHandler({ requestHandler, userBuilder }); + const agentCard = agentCardHandler({ agentCardProvider: requestHandler }); + + return { + jsonRpcHandler: jsonRpc, + agentCardHandler: agentCard, + async getAgentCard() { + return card; + }, + }; +} diff --git a/src/lib/server/adcp-server.ts b/src/lib/server/adcp-server.ts index d5bc24714..239d75780 100644 --- a/src/lib/server/adcp-server.ts +++ b/src/lib/server/adcp-server.ts @@ -29,6 +29,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { McpToolResponse } from './responses'; /** * Structural shape of an MCP transport the server can connect to. @@ -88,6 +89,19 @@ export interface AdcpTestToolsCallRequest { */ export type AdcpTestResponse = unknown; +/** + * Auth principal visible to handlers and `resolveAccount`. Mirrors the + * shape `serve()` produces from its `authenticate` hook, and what the + * A2A adapter's `authenticate` callback should return. + */ +export interface AdcpAuthInfo { + token: string; + clientId: string; + scopes: string[]; + expiresAt?: number; + extra?: Record; +} + /** * Optional per-call overrides for `dispatchTestRequest()`. Lets tests * simulate transport-level state — most importantly the `authInfo` that @@ -95,17 +109,30 @@ export type AdcpTestResponse = unknown; * a real HTTP transport. */ export interface AdcpTestRequestExtras { + authInfo?: AdcpAuthInfo; +} + +/** + * Arguments accepted by `AdcpServer.invoke()`. A transport adapter (MCP + * `serve()`, `createA2AAdapter()`, etc.) authenticates the incoming + * request, maps it to one of the registered AdCP tool names, then calls + * this surface to run the framework pipeline (idempotency, account + * resolution, validation, governance, response-union narrowing). + */ +export interface AdcpInvokeOptions { + /** AdCP tool name (`get_products`, `create_media_buy`, ...). */ + toolName: string; + /** Tool arguments as received from the transport, pre-schema-validation. */ + args: Record; /** - * Auth principal visible to handlers and `resolveAccount`. Mirrors the - * shape `serve()` produces from its `authenticate` hook. + * Auth principal the transport produced from its `authenticate` hook. + * Handlers and `resolveAccount` see it as `ctx.authInfo`. Transports + * MUST verify the principal before calling `invoke()` — `invoke()` + * does NOT re-check the token. */ - authInfo?: { - token: string; - clientId: string; - scopes: string[]; - expiresAt?: number; - extra?: Record; - }; + authInfo?: AdcpAuthInfo; + /** Abort signal for cancellation; defaults to a fresh controller. */ + signal?: AbortSignal; } /** @@ -200,6 +227,33 @@ export interface AdcpServer { */ dispatchTestRequest(request: AdcpTestToolsCallRequest, extras?: AdcpTestRequestExtras): Promise; dispatchTestRequest(request: AdcpTestRequest, extras?: AdcpTestRequestExtras): Promise; + + /** + * Production-safe tool invocation surface for transport adapters. + * + * Runs the full framework pipeline against a registered tool: + * request schema validation → account resolution → idempotency → + * handler dispatch → response narrowing → response validation. + * Returns the same `McpToolResponse` envelope the MCP transport + * observes — a transport adapter layers its own protocol framing on + * top (JSON-RPC result for MCP, A2A `Task` artifact for A2A, etc.). + * + * **Auth is the caller's responsibility.** `invoke()` forwards the + * provided `authInfo` verbatim to handlers and `resolveAccount` + * without re-verifying. Mount it only behind a transport that has + * already authenticated the principal (e.g. `serve({ authenticate })` + * for MCP, `createA2AAdapter({ authenticate })` for A2A). Do not call + * it directly from an HTTP handler — that path skips auth. + * + * For in-process tests that want to synthesize an `authInfo` without + * running a transport, reach for {@link dispatchTestRequest} instead + * — it takes the same principal shape but is explicitly marked + * test-only in its docstring. + * + * Throws when `toolName` is not registered; schema errors round-trip + * as structured `VALIDATION_ERROR` envelopes inside the return value. + */ + invoke(options: AdcpInvokeOptions): Promise; } /** @@ -299,6 +353,19 @@ function getRegisteredTool(server: McpServer, name: string): RegisteredTool | un return (server as unknown as McpServerPrivates)._registeredTools?.[name]; } +/** + * Enumerate the tool names registered on the underlying SDK server. + * Used by transport adapters that need to derive discovery metadata + * (agent cards, capability listings) from the registered surface + * without reaching into private SDK fields at every call site. + * + * @internal + */ +export function listRegisteredToolNames(server: McpServer): string[] { + const registered = (server as unknown as McpServerPrivates)._registeredTools ?? {}; + return Object.keys(registered); +} + function getRequestHandler( server: McpServer, method: string @@ -357,6 +424,17 @@ export function wrapMcpServer( } return handler({ method: request.method, params: request.params ?? {} }, extra); }; + const invoke = async (options: AdcpInvokeOptions): Promise => { + const tool = getRegisteredTool(mcp, options.toolName); + if (!tool) { + throw new Error(`AdcpServer.invoke: tool "${options.toolName}" is not registered`); + } + const extra: { signal: AbortSignal; authInfo?: AdcpAuthInfo } = { + signal: options.signal ?? new AbortController().signal, + }; + if (options.authInfo) extra.authInfo = options.authInfo; + return (await tool.handler(options.args, extra)) as McpToolResponse; + }; const wrapper: AdcpServerInternal = { [ADCP_SDK_SERVER]: mcp, connect(transport) { @@ -371,6 +449,7 @@ export function wrapMcpServer( // Satisfies both overloads (typed tools/call + generic fallback) — the // runtime dispatcher is a single function that narrows by method. dispatchTestRequest: dispatch as AdcpServerInternal['dispatchTestRequest'], + invoke, }; return wrapper; } diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index ff3420c50..a4e88c0bf 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -241,6 +241,9 @@ export type { PgBackendOptions, } from './idempotency'; +export { createA2AAdapter, A2AInvocationError } from './a2a-adapter'; +export type { A2AAdapter, A2AAdapterOptions, A2AAgentCardOverrides } from './a2a-adapter'; + export { createWebhookEmitter, memoryWebhookKeyStore } from './webhook-emitter'; export type { WebhookEmitter, diff --git a/test/server-a2a-adapter.test.js b/test/server-a2a-adapter.test.js new file mode 100644 index 000000000..507dcea30 --- /dev/null +++ b/test/server-a2a-adapter.test.js @@ -0,0 +1,430 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const express = require('express'); +const { createAdcpServer: _createAdcpServer } = require('../dist/lib/server/create-adcp-server'); +const { createA2AAdapter } = require('../dist/lib/server/a2a-adapter'); +const { InMemoryStateStore } = require('../dist/lib/server/state-store'); +const { adcpError } = require('../dist/lib/server/errors'); +const { createIdempotencyStore, memoryBackend } = require('../dist/lib/server/idempotency'); + +// Opt out of strict response validation for sparse handler fixtures — +// same rationale as server-create-adcp-server.test.js. +function createAdcpServer(config) { + return _createAdcpServer({ + ...config, + stateStore: config?.stateStore ?? new InMemoryStateStore(), + validation: { responses: 'off', ...(config?.validation ?? {}) }, + }); +} + +function baseCard(overrides) { + return { + name: 'Test Agent', + description: 'Test agent', + url: 'https://example.com/a2a', + version: '1.0.0', + provider: { organization: 'Test Co', url: 'https://example.com' }, + securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } }, + ...overrides, + }; +} + +function randomUuid() { + return ( + '00000000-0000-0000-0000-' + + Math.floor(Math.random() * 1e12) + .toString() + .padStart(12, '0') + ); +} + +function dataPartMessage(skill, input) { + return { + kind: 'message', + messageId: randomUuid(), + role: 'user', + parts: [{ kind: 'data', data: { skill, input } }], + }; +} + +async function postJsonRpc(app, body, headers = {}) { + const server = app.listen(0); + try { + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}/`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + const json = await res.json(); + return { status: res.status, body: json }; + } finally { + server.close(); + } +} + +async function getAgentCard(app) { + const server = app.listen(0); + try { + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}/.well-known/agent-card.json`); + return { status: res.status, body: await res.json() }; + } finally { + server.close(); + } +} + +function messageSend(message) { + return { + jsonrpc: '2.0', + id: 1, + method: 'message/send', + params: { message }, + }; +} + +function taskGet(taskId) { + return { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { id: taskId } }; +} + +function taskCancel(taskId) { + return { jsonrpc: '2.0', id: 1, method: 'tasks/cancel', params: { id: taskId } }; +} + +function mountAdapter(a2a) { + const app = express(); + app.use(express.json()); + app.use('/.well-known/agent-card.json', a2a.agentCardHandler); + app.use('/', a2a.jsonRpcHandler); + return app; +} + +describe('createA2AAdapter', () => { + describe('agent card', () => { + it('exposes seller identity fields and auto-seeds skills from registered tools', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + signals: { getSignals: async () => ({ signals: [] }) }, + }); + const a2a = createA2AAdapter({ server: adcp, agentCard: baseCard() }); + const app = mountAdapter(a2a); + const res = await getAgentCard(app); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, 'Test Agent'); + assert.strictEqual(res.body.url, 'https://example.com/a2a'); + assert.ok(Array.isArray(res.body.skills)); + const skillIds = res.body.skills.map(s => s.id); + assert.ok(skillIds.includes('get_products'), 'get_products skill derived'); + assert.ok(skillIds.includes('get_signals'), 'get_signals skill derived'); + assert.ok(!skillIds.includes('get_adcp_capabilities'), 'capabilities tool excluded from skills'); + assert.ok(res.body.capabilities, 'capabilities block present'); + assert.strictEqual(res.body.capabilities.streaming, false); + assert.strictEqual(res.body.capabilities.pushNotifications, false); + assert.deepStrictEqual(res.body.provider, { organization: 'Test Co', url: 'https://example.com' }); + }); + + it('allows seller to override skills entirely', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard({ + skills: [{ id: 'custom', name: 'Custom', description: 'hand-written', tags: ['enriched'] }], + }), + }); + const card = await a2a.getAgentCard(); + assert.strictEqual(card.skills.length, 1); + assert.strictEqual(card.skills[0].id, 'custom'); + }); + + it('fails loud at boot when agent card is missing required fields', () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + assert.throws( + () => + createA2AAdapter({ + server: adcp, + agentCard: { name: 'Missing URL', description: 'nope', version: '1.0.0' }, + }), + /missing required fields/i + ); + }); + + it('fails loud when no tools are registered and no skills override supplied', () => { + const adcp = createAdcpServer({}); + assert.throws(() => createA2AAdapter({ server: adcp, agentCard: baseCard() }), /no skills/i); + }); + }); + + describe('message/send routing', () => { + it('Success arm → Task.state=completed + DataPart artifact', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [{ product_id: 'p1' }] }) }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'premium' }))); + assert.strictEqual(res.status, 200); + assert.ok(res.body.result, 'JSON-RPC success result'); + assert.strictEqual(res.body.result.kind, 'task'); + assert.strictEqual(res.body.result.status.state, 'completed'); + const artifacts = res.body.result.artifacts ?? []; + assert.strictEqual(artifacts.length, 1); + const dataPart = artifacts[0].parts.find(p => p.kind === 'data'); + assert.ok(dataPart, 'artifact carries DataPart'); + assert.deepStrictEqual(dataPart.data.products, [{ product_id: 'p1' }]); + }); + + it('Submitted arm → Task.state=submitted + adcp_task_id on the artifact', async () => { + const adcp = createAdcpServer({ + mediaBuy: { + createMediaBuy: async () => ({ + status: 'submitted', + task_id: 'tk_async_1', + message: 'Queued for IO signature', + }), + }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc( + app, + messageSend( + dataPartMessage('create_media_buy', { + account: { account_id: 'a1' }, + brand: { brand_id: 'b1' }, + start_time: '2026-01-01T00:00:00Z', + end_time: '2026-02-01T00:00:00Z', + }) + ) + ); + assert.strictEqual(res.body.result.status.state, 'submitted'); + const dataPart = res.body.result.artifacts[0].parts[0]; + assert.strictEqual(dataPart.data.adcp_task_id, 'tk_async_1', 'AdCP task_id surfaced on artifact'); + assert.strictEqual(dataPart.data.status, 'submitted'); + // A2A task id is the SDK-generated one, not the AdCP task_id. + assert.notStrictEqual(res.body.result.id, 'tk_async_1'); + }); + + it('Error arm → Task.state=failed with errors[] preserved on artifact', async () => { + const adcp = createAdcpServer({ + mediaBuy: { + createMediaBuy: async () => ({ + errors: [{ code: 'PRODUCT_NOT_FOUND', message: 'gone' }], + }), + }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc( + app, + messageSend( + dataPartMessage('create_media_buy', { + account: { account_id: 'a1' }, + brand: { brand_id: 'b1' }, + start_time: '2026-01-01T00:00:00Z', + end_time: '2026-02-01T00:00:00Z', + }) + ) + ); + assert.strictEqual(res.body.result.status.state, 'failed'); + const dataPart = res.body.result.artifacts[0].parts[0]; + assert.ok(Array.isArray(dataPart.data.errors)); + assert.strictEqual(dataPart.data.errors[0].code, 'PRODUCT_NOT_FOUND'); + }); + + it('adcpError envelope → Task.state=failed with adcp_error on artifact', async () => { + const adcp = createAdcpServer({ + mediaBuy: { + getProducts: async () => adcpError('RATE_LIMITED', { message: 'slow down', retry_after: 30 }), + }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'x' }))); + assert.strictEqual(res.body.result.status.state, 'failed'); + const dataPart = res.body.result.artifacts[0].parts[0]; + assert.ok(dataPart.data.adcp_error, 'adcp_error present on artifact'); + assert.strictEqual(dataPart.data.adcp_error.code, 'RATE_LIMITED'); + }); + + it('handler returning { isError: true } without adcp_error routes as error_arm', async () => { + // Classifier coverage: a hand-rolled error envelope (isError: true + + // structuredContent that doesn't carry adcp_error or a spec errors[]) + // must still surface as Task.state='failed' and preserve whatever + // structuredContent the handler shipped — no silent Success-path wrap. + const adcp = createAdcpServer({ + mediaBuy: { + getProducts: async () => ({ + content: [{ type: 'text', text: 'custom failure' }], + isError: true, + structuredContent: { reason: 'custom', detail: 'hand-rolled' }, + }), + }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'x' }))); + assert.strictEqual(res.body.result.status.state, 'failed'); + const dataPart = res.body.result.artifacts[0].parts[0]; + assert.strictEqual(dataPart.data.reason, 'custom', 'hand-rolled structuredContent preserved'); + assert.strictEqual(dataPart.data.detail, 'hand-rolled'); + }); + + it('DataPart with null data surfaces as failed with INVALID_INVOCATION (not uncaught TypeError)', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc( + app, + messageSend({ + kind: 'message', + messageId: randomUuid(), + role: 'user', + parts: [{ kind: 'data', data: null }], + }) + ); + assert.strictEqual(res.body.result.status.state, 'failed'); + const dataPart = res.body.result.artifacts[0].parts[0]; + assert.strictEqual(dataPart.data.reason, 'INVALID_INVOCATION', 'null data guard fires before destructure'); + }); + + it('invalid DataPart (missing skill) surfaces as failed with INVALID_INVOCATION', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc( + app, + messageSend({ + kind: 'message', + messageId: randomUuid(), + role: 'user', + parts: [{ kind: 'data', data: { not_a_skill: true } }], + }) + ); + assert.strictEqual(res.body.result.status.state, 'failed'); + const dataPart = res.body.result.artifacts[0].parts[0]; + assert.strictEqual(dataPart.data.reason, 'INVALID_INVOCATION'); + }); + }); + + describe('tasks/get polling', () => { + it('returns the completed Task for a prior message/send result', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [{ product_id: 'p1' }] }) }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const send = await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'x' }))); + const taskId = send.body.result.id; + const get = await postJsonRpc(app, taskGet(taskId)); + assert.strictEqual(get.body.result.id, taskId); + assert.strictEqual(get.body.result.status.state, 'completed'); + }); + }); + + describe('tasks/cancel', () => { + it('returns TaskNotCancelable when task already completed', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const send = await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'x' }))); + const taskId = send.body.result.id; + assert.strictEqual(send.body.result.status.state, 'completed', 'task completed synchronously'); + const cancel = await postJsonRpc(app, taskCancel(taskId)); + assert.ok(cancel.body.error, 'cancel on completed task returns a JSON-RPC error'); + const err = cancel.body.error; + // A2A's TaskNotCancelable spec code is -32002; accept the spec code + // OR a message mentioning cancellation, since the exact surface + // depends on the SDK version. + assert.ok( + err.code === -32002 || /cancel/i.test(err.message ?? ''), + `expected TaskNotCancelable-shaped error, got ${JSON.stringify(err)}` + ); + }); + + it('returns an error when canceling an unknown task id', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const cancel = await postJsonRpc(app, taskCancel('00000000-0000-0000-0000-000000000000')); + assert.ok(cancel.body.error, 'unknown task cancel returns a JSON-RPC error'); + }); + }); + + describe('authenticate', () => { + it('rejects when authenticate returns null', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard(), + async authenticate() { + return null; + }, + }); + const app = mountAdapter(a2a); + const res = await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'x' }))); + // Rejection surfaces as a JSON-RPC error (SDK wraps the thrown Error as -32000). + assert.ok(res.body.error, 'rejection yields JSON-RPC error envelope'); + }); + + it('propagates authInfo into ctx.authInfo', async () => { + let sawAuth; + const adcp = createAdcpServer({ + mediaBuy: { + getProducts: async (_params, ctx) => { + sawAuth = ctx.authInfo; + return { products: [] }; + }, + }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard(), + async authenticate() { + return { token: 'abc', clientId: 'buyer_1', scopes: ['read'] }; + }, + }); + const app = mountAdapter(a2a); + await postJsonRpc(app, messageSend(dataPartMessage('get_products', { brief: 'x' }))); + assert.ok(sawAuth, 'authInfo threaded into handler'); + assert.strictEqual(sawAuth.clientId, 'buyer_1'); + assert.deepStrictEqual(sawAuth.scopes, ['read']); + }); + }); + + describe('idempotency replay across A2A transport', () => { + it('replays a cached response on duplicate idempotency_key', async () => { + let calls = 0; + const adcp = createAdcpServer({ + idempotency: createIdempotencyStore({ backend: memoryBackend() }), + // Idempotency needs a principal-scoping resolver; tenant_a is an + // arbitrary test value (real deployments scope by authInfo). + resolveSessionKey: () => 'tenant_a', + mediaBuy: { + createMediaBuy: async () => { + calls += 1; + return { media_buy_id: `mb_${calls}`, packages: [] }; + }, + }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const key = '11111111-1111-1111-1111-111111111111'; + const payload = { + account: { account_id: 'a1' }, + brand: { brand_id: 'b1' }, + start_time: '2026-01-01T00:00:00Z', + end_time: '2026-02-01T00:00:00Z', + idempotency_key: key, + }; + const first = await postJsonRpc(app, messageSend(dataPartMessage('create_media_buy', payload))); + const second = await postJsonRpc(app, messageSend(dataPartMessage('create_media_buy', payload))); + assert.strictEqual(calls, 1, 'handler ran exactly once'); + const firstId = first.body.result.artifacts[0].parts[0].data.media_buy_id; + const secondId = second.body.result.artifacts[0].parts[0].data.media_buy_id; + assert.strictEqual(firstId, secondId, 'replay returns identical media_buy_id'); + }); + }); +}); From 99451599c2493b0eab070feb63869d8a32e80027 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 12:58:32 -0400 Subject: [PATCH 2/4] fix(a2a): align DataPart key + Submitted Task.state with A2A 0.3.0 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol-expert review found two wire-format issues in the initial A2A adapter commit: 1. DataPart key mismatch with our own in-tree A2A client. The client at src/lib/protocols/a2a.ts:289 sends { skill, parameters }; the adapter expected { skill, input }. Same SDK couldn't talk to itself. Fix: accept both — `data.input ?? data.parameters` — with `input` as canonical going forward (matches the AdCP tool's typed request shape; `parameters` is kept for ecosystem compatibility with downstream consumers already emitting it). 2. `Task.state: 'submitted'` with `final: true` is non-conformant per A2A 0.3.0. `submitted` is the INITIAL lifecycle state before `working`, not a terminal state for message/send. Fix: when the AdCP handler returns a Submitted arm, emit A2A `state: 'completed'` (the HTTP call itself completed) and move `adcp_task_id` from the DataPart's `data` to `artifact.metadata` (A2A's extension channel). The DataPart still carries the AdCP response verbatim, so buyers read `data.status: 'submitted'` from the payload and resume the AdCP work via `metadata.adcp_task_id`. 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. Verified end-to-end against a live server (20/20 HTTP smoke checks) plus `node bin/adcp.js --protocol a2a` hitting the adapter for get_adcp_capabilities + get_products. Storyboard harness (`--protocol a2a`) targets the adapter cleanly — schema validation even fires correctly on sparse A2A-transported responses. Also updated BUILD-AN-AGENT.md's "Two lifecycles, one response" callout and fixed a lingering `app.get(...)` → `app.use(...)` in a JSDoc example. New tests (now 18 total): - Submitted AdCP arm → A2A state=completed; adcp_task_id on artifact.metadata; transport metadata absent from DataPart data - { skill, parameters } alias routes the same as { skill, input } Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guides/BUILD-AN-AGENT.md | 20 ++++---- src/lib/server/a2a-adapter.ts | 86 +++++++++++++++++++++++---------- test/server-a2a-adapter.test.js | 52 ++++++++++++++++++-- 3 files changed, 118 insertions(+), 40 deletions(-) diff --git a/docs/guides/BUILD-AN-AGENT.md b/docs/guides/BUILD-AN-AGENT.md index 97a6ab31c..617ea251f 100644 --- a/docs/guides/BUILD-AN-AGENT.md +++ b/docs/guides/BUILD-AN-AGENT.md @@ -139,18 +139,20 @@ 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. +**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. -**Handler return → A2A `Task.state`:** +**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 returned… | A2A result | -|---|---| -| Success arm | `state: 'completed'` + DataPart artifact | -| Submitted arm (`status:'submitted'`) | `state: 'submitted'` + `adcp_task_id` on the artifact | -| Error arm (`errors: [...]`) | `state: 'failed'` + DataPart artifact | -| `adcpError('CODE', ...)` | `state: 'failed'` + `adcp_error` artifact | +**Handler return → A2A `Task.state` + artifact:** -**A2A `Task.id` vs AdCP `task_id`.** A2A owns its Task.id (SDK-generated per `message/send`). The AdCP-level `task_id` — if your handler returned a Submitted arm — rides on the DataPart artifact as `adcp_task_id`. A2A-native clients poll via `tasks/get` using the A2A Task.id. +| 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. diff --git a/src/lib/server/a2a-adapter.ts b/src/lib/server/a2a-adapter.ts index a9b9542a1..9579d43f1 100644 --- a/src/lib/server/a2a-adapter.ts +++ b/src/lib/server/a2a-adapter.ts @@ -13,22 +13,41 @@ * * **Handler-return → A2A `Task.state` mapping:** * - * | Handler returned… | A2A result | - * |-------------------------------------|----------------------------------------------------| - * | Success arm | `Task.state = 'completed'` + DataPart artifact | - * | Submitted arm (`status:'submitted'`)| `Task.state = 'submitted'` + DataPart artifact[^1] | - * | Error arm (`errors:[]`) | `Task.state = 'failed'` + DataPart artifact | - * | `adcpError()` envelope | `Task.state = 'failed'` + DataPart artifact | + * | Handler returned… | A2A `Task.state` | Artifact payload | + * |-------------------------------------|-------------------|--------------------------------------------------| + * | Success arm | `completed` | DataPart with the typed AdCP response | + * | Submitted arm (`status:'submitted'`)| `completed` [^1] | DataPart with AdCP response + `metadata.adcp_task_id` | + * | Error arm (`errors:[]`) | `failed` | DataPart with the AdCP Error arm payload | + * | `adcpError()` envelope | `failed` | DataPart with `adcp_error` | * - * [^1]: A2A owns `Task.id`. The AdCP-level `task_id` rides on the DataPart - * artifact's `data.adcp_task_id` — buyers poll the A2A `Task.id` via - * `tasks/get`; the `adcp_task_id` is the handle they'd use against AdCP - * tool-task APIs if they were calling the agent over MCP directly. + * [^1]: A2A `submitted` is the INITIAL lifecycle state (before + * `working`); A2A `completed` marks the transport call as done. + * When the AdCP handler returns a Submitted arm the HTTP call itself + * has completed — the AdCP-level async work is queued and buyers + * resume it via `adcp_task_id` on `artifact.metadata`. The DataPart's + * `data` still carries `status: 'submitted'` from the AdCP response, + * so a buyer reading the artifact payload sees the ad-tech state + * directly; the A2A Task.state only mirrors whether the transport + * call itself terminated. * - * **Message shape.** A client addresses a tool by sending a `Message` with - * a single `DataPart`: `{ kind: 'data', data: { skill, input } }`. The + * **Message shape.** A client addresses a tool by sending a `Message` + * with a single `DataPart`: `{ kind: 'data', data: { skill, input } }`. * `skill` must match a registered AdCP tool name (e.g. `get_products`); - * `input` becomes the tool arguments before AdCP schema validation runs. + * `input` becomes the tool arguments before AdCP schema validation + * runs. The older client convention `{ skill, parameters }` (used by + * `src/lib/protocols/a2a.ts`) is also accepted — `data.parameters` is + * treated as an alias for `data.input`; new callers should prefer + * `input` since A2A `DataPart.data` is free-form and `input` matches + * the AdCP tool's typed request shape. + * + * **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 successfully + * but the ad-tech operation itself is still queued. Buyers resume the + * AdCP work via the `adcp_task_id` on the artifact's `metadata`, not + * by re-polling the A2A Task. * * @preview v0 surface — field semantics may shift while the ecosystem * converges on AdCP-over-A2A conventions. Pinning a minor version is @@ -260,12 +279,18 @@ function extractInvocation(message: Message): ExtractedInvocation { } const payload = rawData as Record; const skill = payload.skill; - const input = payload.input; + // Accept `parameters` as an alias for `input` — the in-tree A2A + // client (`src/lib/protocols/a2a.ts`) shipped first and uses + // `parameters`, so downstream agents already emit that key. Going + // forward prefer `input`: A2A `DataPart.data` is free-form JSON and + // `input` matches the AdCP tool's typed request shape. The alias + // stays for ecosystem compatibility; no deprecation timer in v0. + const input = payload.input ?? payload.parameters; if (typeof skill !== 'string' || skill.length === 0) { throw new A2AInvocationError('DataPart must include a non-empty string `skill` field naming the AdCP tool.'); } if (input != null && (typeof input !== 'object' || Array.isArray(input))) { - throw new A2AInvocationError('DataPart `input` must be an object (or omitted).'); + throw new A2AInvocationError('DataPart `input` (or legacy `parameters`) must be an object (or omitted).'); } return { skill, input: (input as Record) ?? {} }; } @@ -375,11 +400,19 @@ class AdcpA2AAgentExecutor implements AgentExecutor { const classified = classifyResponse(response); this.publishArtifact(eventBus, taskId, contextId, classified); + // A2A Task.state maps the TRANSPORT call lifecycle, not the AdCP + // work. A `submitted` AdCP arm means the HTTP call itself + // completed — the ad-tech work is queued, resumed via + // `adcp_task_id` on the artifact metadata. Emitting A2A + // `state: 'submitted'` with `final: true` would be a + // non-conformant transition per A2A 0.3.0 (`submitted` is the + // INITIAL state before `working`, never terminal). Buyers read + // the AdCP-level status from the artifact's `data.status` field. this.publishStatus( eventBus, taskId, contextId, - classified.kind === 'success' ? 'completed' : classified.kind === 'submitted' ? 'submitted' : 'failed', + classified.kind === 'success' || classified.kind === 'submitted' ? 'completed' : 'failed', true ); } finally { @@ -454,18 +487,19 @@ class AdcpA2AAgentExecutor implements AgentExecutor { ): void { const artifactName = classified.kind === 'success' ? 'result' : classified.kind === 'submitted' ? 'submitted' : 'error'; + // DataPart `data` is the AdCP tool's typed response — no + // transport-level fields injected here so the payload still + // validates against the tool's AdCP response schema. AdCP + // transport metadata (`adcp_task_id` pointing at the async handle) + // rides on `artifact.metadata` per A2A 0.3.0's extension + // convention. const artifact: Artifact = { artifactId: randomUUID(), name: artifactName, - parts: [ - { - kind: 'data', - data: - classified.kind === 'submitted' - ? { ...classified.data, adcp_task_id: classified.adcpTaskId } - : classified.data, - }, - ], + parts: [{ kind: 'data', data: classified.data }], + ...(classified.kind === 'submitted' && { + metadata: { adcp_task_id: classified.adcpTaskId }, + }), }; const event: TaskArtifactUpdateEvent = { kind: 'artifact-update', @@ -626,7 +660,7 @@ const DEFAULT_LOGGER: AdcpLogger = { * }); * * app.use('/a2a', a2a.jsonRpcHandler); - * app.get('/.well-known/agent-card.json', a2a.agentCardHandler); + * app.use('/.well-known/agent-card.json', a2a.agentCardHandler); * ``` * * @preview — see the module docstring. diff --git a/test/server-a2a-adapter.test.js b/test/server-a2a-adapter.test.js index 507dcea30..aed88c66b 100644 --- a/test/server-a2a-adapter.test.js +++ b/test/server-a2a-adapter.test.js @@ -176,7 +176,7 @@ describe('createA2AAdapter', () => { assert.deepStrictEqual(dataPart.data.products, [{ product_id: 'p1' }]); }); - it('Submitted arm → Task.state=submitted + adcp_task_id on the artifact', async () => { + it('Submitted AdCP arm → A2A state=completed; adcp_task_id on artifact.metadata', async () => { const adcp = createAdcpServer({ mediaBuy: { createMediaBuy: async () => ({ @@ -198,12 +198,23 @@ describe('createA2AAdapter', () => { }) ) ); - assert.strictEqual(res.body.result.status.state, 'submitted'); - const dataPart = res.body.result.artifacts[0].parts[0]; - assert.strictEqual(dataPart.data.adcp_task_id, 'tk_async_1', 'AdCP task_id surfaced on artifact'); - assert.strictEqual(dataPart.data.status, 'submitted'); + // A2A Task.state tracks the transport call — completed means the HTTP + // call finished; the AdCP-level async state lives inside the artifact. + assert.strictEqual(res.body.result.status.state, 'completed'); + const artifact = res.body.result.artifacts[0]; + assert.strictEqual( + artifact.metadata?.adcp_task_id, + 'tk_async_1', + 'AdCP task_id lives on artifact.metadata (A2A extension convention), not in DataPart data' + ); + const dataPart = artifact.parts[0]; + assert.strictEqual(dataPart.data.status, 'submitted', 'AdCP response preserved in DataPart'); + assert.strictEqual(dataPart.data.task_id, 'tk_async_1', 'AdCP task_id also on the wire payload per spec'); // A2A task id is the SDK-generated one, not the AdCP task_id. assert.notStrictEqual(res.body.result.id, 'tk_async_1'); + // Transport metadata must NOT leak into DataPart data — that shape + // is the AdCP tool's typed response and needs to validate cleanly. + assert.strictEqual(dataPart.data.adcp_task_id, undefined, 'transport metadata stays out of AdCP payload'); }); it('Error arm → Task.state=failed with errors[] preserved on artifact', async () => { @@ -268,6 +279,37 @@ describe('createA2AAdapter', () => { assert.strictEqual(dataPart.data.detail, 'hand-rolled'); }); + it('accepts { skill, parameters } as an alias for { skill, input }', async () => { + // Backward-compat: the in-tree A2A client at src/lib/protocols/a2a.ts + // shipped first and uses `parameters`. The adapter tolerates both so + // same-SDK send/receive works end-to-end. + let sawBrief; + const adcp = createAdcpServer({ + mediaBuy: { + getProducts: async params => { + sawBrief = params.brief; + return { products: [] }; + }, + }, + }); + const app = mountAdapter(createA2AAdapter({ server: adcp, agentCard: baseCard() })); + const res = await postJsonRpc(app, { + jsonrpc: '2.0', + id: 1, + method: 'message/send', + params: { + message: { + kind: 'message', + messageId: randomUuid(), + role: 'user', + parts: [{ kind: 'data', data: { skill: 'get_products', parameters: { brief: 'premium' } } }], + }, + }, + }); + assert.strictEqual(res.body.result.status.state, 'completed'); + assert.strictEqual(sawBrief, 'premium', 'parameters alias threaded into handler args'); + }); + it('DataPart with null data surfaces as failed with INVALID_INVOCATION (not uncaught TypeError)', async () => { const adcp = createAdcpServer({ mediaBuy: { getProducts: async () => ({ products: [] }) }, From f5228d1129b3e569556ad4635bda1104210bc0ba Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 13:39:32 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat(a2a):=20A2AAdapter.mount(app)=20?= =?UTF-8?q?=E2=80=94=20dual-host=20well-known=20card=20without=20footgun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0 serve pattern required the seller to know TWO paths for the agent card: `/.well-known/agent-card.json` (origin-root, for generic host probes) AND `{agentCard.url-pathname}/.well-known/agent-card.json` (A2A SDK derives this from the URL in the card). Sellers mounting only one hit a 404 on the first storyboard run — the exact failure mode the CLI smoke surfaced during storyboard testing. `adapter.mount(app)` wires all four routes from one call: - JSON-RPC at `basePath` (defaults to the `agentCard.url` pathname) - Agent card at `${basePath}/.well-known/agent-card.json` (A2A discovery) - Agent card at `/.well-known/agent-card.json` (origin-root probes) Options: - `basePath` — override when mounting behind a non-matching prefix - `wellKnownAtRoot: false` — skip origin-root mount (when an upstream proxy owns that route) Falls back to `/a2a` when `agentCard.url` has no pathname. The raw `jsonRpcHandler` and `agentCardHandler` properties stay exposed for deployments that need custom mounting (CDN-served cards, custom auth layers, in-process test harnesses). Four new tests cover: - Default derivation from agent-card URL pathname (all four routes reachable) - `wellKnownAtRoot: false` suppresses origin-root mount - Explicit `basePath` override - Fallback to `/a2a` when URL has no pathname BUILD-AN-AGENT example updated to `a2a.mount(app)` with a comment pointing at the exposed handlers for custom-mount cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guides/BUILD-AN-AGENT.md | 8 ++- src/lib/index.ts | 2 + src/lib/server/a2a-adapter.ts | 85 ++++++++++++++++++++++--- src/lib/server/index.ts | 8 ++- test/server-a2a-adapter.test.js | 106 ++++++++++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 10 deletions(-) diff --git a/docs/guides/BUILD-AN-AGENT.md b/docs/guides/BUILD-AN-AGENT.md index 617ea251f..f31f54d1f 100644 --- a/docs/guides/BUILD-AN-AGENT.md +++ b/docs/guides/BUILD-AN-AGENT.md @@ -132,8 +132,12 @@ const a2a = createA2AAdapter({ const app = express(); app.use(express.json()); -app.use('/.well-known/agent-card.json', a2a.agentCardHandler); -app.use('/a2a', a2a.jsonRpcHandler); +// 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); ``` diff --git a/src/lib/index.ts b/src/lib/index.ts index 7ceb5739a..ce97bec13 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -661,6 +661,8 @@ export type { A2AAdapter, A2AAdapterOptions, A2AAgentCardOverrides, + A2AMountOptions, + ExpressAppLike, } from './server'; // ====== ERROR HANDLING & RETRY ====== diff --git a/src/lib/server/a2a-adapter.ts b/src/lib/server/a2a-adapter.ts index 9579d43f1..2c457e8ec 100644 --- a/src/lib/server/a2a-adapter.ts +++ b/src/lib/server/a2a-adapter.ts @@ -185,18 +185,57 @@ export interface A2AAdapterOptions { logger?: AdcpLogger; } +/** Minimal Express app surface the adapter's `mount()` helper needs. */ +export interface ExpressAppLike { + use(path: string, ...handlers: RequestHandler[]): unknown; +} + +/** Options for {@link A2AAdapter.mount}. */ +export interface A2AMountOptions { + /** + * URL-path prefix for the JSON-RPC endpoint. Defaults to the pathname + * of the agent card's `url` field (so `url: 'https://host/a2a'` mounts + * JSON-RPC at `/a2a`). Override to mount under a different path. + */ + basePath?: string; + /** + * When true (default), also mounts the agent card at the origin root + * (`/.well-known/agent-card.json`) so simple discovery probes targeting + * the host itself find the card. Some deployments disable this when an + * upstream proxy owns origin-root routes — set `false` to mount only + * at `{basePath}/.well-known/agent-card.json`. + */ + wellKnownAtRoot?: boolean; +} + /** - * Value returned by {@link createA2AAdapter}. `jsonRpcHandler` accepts - * A2A JSON-RPC posts (`message/send`, `tasks/get`, `tasks/cancel`); - * mount it on the path your agent card advertises. `agentCardHandler` - * serves the discovery GET — mount it at - * `/.well-known/agent-card.json`. + * Value returned by {@link createA2AAdapter}. + * + * For almost every seller, `adapter.mount(app)` is the right entry + * point — it wires all four routes (JSON-RPC at the agent-card's + * path, the agent card at both `{basePath}/.well-known/agent-card.json` + * for A2A discovery and `/.well-known/agent-card.json` for origin-root + * probes) with one call. + * + * The `jsonRpcHandler` and `agentCardHandler` fields stay exposed for + * deployments that need finer control (mounting behind a custom auth + * layer, serving the card from a CDN, testing). */ export interface A2AAdapter { + /** The A2A JSON-RPC middleware (`message/send`, `tasks/get`, `tasks/cancel`). */ jsonRpcHandler: RequestHandler; + /** The agent-card discovery GET middleware. */ agentCardHandler: RequestHandler; /** Returns the merged, validated agent card. */ getAgentCard(): Promise; + /** + * Wire all A2A routes onto an Express-compatible app in one call. + * Eliminates the "card mounted at only one location" footgun: the + * A2A SDK derives `${agentCard.url}/.well-known/agent-card.json` for + * discovery, while many clients also probe origin root — this helper + * satisfies both. See {@link A2AMountOptions} to override paths. + */ + mount(app: ExpressAppLike, options?: A2AMountOptions): void; } // --------------------------------------------------------------------------- @@ -691,13 +730,45 @@ export function createA2AAdapter(options: A2AAdapterOptions): A2AAdapter { }; const jsonRpc = jsonRpcHandler({ requestHandler, userBuilder }); - const agentCard = agentCardHandler({ agentCardProvider: requestHandler }); + const agentCardMiddleware = agentCardHandler({ agentCardProvider: requestHandler }); + + // Derive the default basePath from the agent-card URL's pathname so + // `mount(app)` "just works" for the common case where the URL the + // seller advertised and the URL their app serves are aligned. + // Falls back to `/a2a` when the URL has no path (empty or `/`). + const defaultBasePath = (() => { + try { + const parsed = new URL(card.url); + const pathname = parsed.pathname.replace(/\/+$/, ''); + return pathname.length > 0 ? pathname : '/a2a'; + } catch { + // `agentCard.url` might be a relative path or otherwise unparseable + // — fall back to the conventional A2A mount point. + return '/a2a'; + } + })(); + + const mount = (app: ExpressAppLike, mountOptions: A2AMountOptions = {}): void => { + const basePath = mountOptions.basePath ?? defaultBasePath; + const wellKnownAtRoot = mountOptions.wellKnownAtRoot ?? true; + // A2A SDK clients derive `${agentCard.url}/.well-known/agent-card.json` + // for discovery, so mount there first. + app.use(`${basePath}/.well-known/agent-card.json`, agentCardMiddleware); + if (wellKnownAtRoot) { + // Origin-root is where simple "what agent lives at this host?" + // probes look. Serving both locations matches what deployments + // already hand-roll; the helper just bakes it in. + app.use('/.well-known/agent-card.json', agentCardMiddleware); + } + app.use(basePath, jsonRpc); + }; return { jsonRpcHandler: jsonRpc, - agentCardHandler: agentCard, + agentCardHandler: agentCardMiddleware, async getAgentCard() { return card; }, + mount, }; } diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index a4e88c0bf..6f5b75789 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -242,7 +242,13 @@ export type { } from './idempotency'; export { createA2AAdapter, A2AInvocationError } from './a2a-adapter'; -export type { A2AAdapter, A2AAdapterOptions, A2AAgentCardOverrides } from './a2a-adapter'; +export type { + A2AAdapter, + A2AAdapterOptions, + A2AAgentCardOverrides, + A2AMountOptions, + ExpressAppLike, +} from './a2a-adapter'; export { createWebhookEmitter, memoryWebhookKeyStore } from './webhook-emitter'; export type { diff --git a/test/server-a2a-adapter.test.js b/test/server-a2a-adapter.test.js index aed88c66b..65b568110 100644 --- a/test/server-a2a-adapter.test.js +++ b/test/server-a2a-adapter.test.js @@ -158,6 +158,112 @@ describe('createA2AAdapter', () => { }); }); + describe('mount() helper', () => { + async function jsonRpcAt(app, path, body) { + const server = app.listen(0); + try { + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; + } finally { + server.close(); + } + } + + async function cardAt(app, path) { + const server = app.listen(0); + try { + const port = server.address().port; + const res = await fetch(`http://127.0.0.1:${port}${path}`); + return { status: res.status, body: res.status === 200 ? await res.json() : null }; + } finally { + server.close(); + } + } + + it('derives basePath from agent-card URL pathname and mounts all four routes', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [{ product_id: 'p1' }] }) }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard({ url: 'https://example.com/a2a' }), + }); + const app = express(); + app.use(express.json()); + a2a.mount(app); + + // Agent card at both locations + const atBase = await cardAt(app, '/a2a/.well-known/agent-card.json'); + assert.strictEqual(atBase.status, 200); + assert.strictEqual(atBase.body.name, 'Test Agent'); + const atRoot = await cardAt(app, '/.well-known/agent-card.json'); + assert.strictEqual(atRoot.status, 200); + assert.strictEqual(atRoot.body.name, 'Test Agent'); + + // JSON-RPC at the derived basePath + const rpc = await jsonRpcAt(app, '/a2a', messageSend(dataPartMessage('get_products', { brief: 'ctv' }))); + assert.strictEqual(rpc.status, 200); + assert.strictEqual(rpc.body.result?.status?.state, 'completed'); + }); + + it('wellKnownAtRoot: false omits origin-root mount', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard({ url: 'https://example.com/a2a' }), + }); + const app = express(); + app.use(express.json()); + a2a.mount(app, { wellKnownAtRoot: false }); + + const atBase = await cardAt(app, '/a2a/.well-known/agent-card.json'); + assert.strictEqual(atBase.status, 200, 'base-path card still mounted'); + const atRoot = await cardAt(app, '/.well-known/agent-card.json'); + assert.strictEqual(atRoot.status, 404, 'origin-root mount suppressed'); + }); + + it('accepts explicit basePath override', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard({ url: 'https://example.com/a2a' }), + }); + const app = express(); + app.use(express.json()); + a2a.mount(app, { basePath: '/agents/foo' }); + + const card = await cardAt(app, '/agents/foo/.well-known/agent-card.json'); + assert.strictEqual(card.status, 200); + const missed = await cardAt(app, '/a2a/.well-known/agent-card.json'); + assert.strictEqual(missed.status, 404, 'default basePath not used when overridden'); + }); + + it('falls back to /a2a when agent-card URL has no pathname', async () => { + const adcp = createAdcpServer({ + mediaBuy: { getProducts: async () => ({ products: [] }) }, + }); + const a2a = createA2AAdapter({ + server: adcp, + agentCard: baseCard({ url: 'https://example.com' }), // no path + }); + const app = express(); + app.use(express.json()); + a2a.mount(app); + + const card = await cardAt(app, '/a2a/.well-known/agent-card.json'); + assert.strictEqual(card.status, 200, 'fallback basePath is /a2a'); + }); + }); + describe('message/send routing', () => { it('Success arm → Task.state=completed + DataPart artifact', async () => { const adcp = createAdcpServer({ From 580a5b0acbff3e010ab4ccff66aaf68d980eed42 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 14:08:23 -0400 Subject: [PATCH 4/4] docs(changeset): refresh to reflect final PR state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier iteration of the changeset described pre-protocol-fix behavior (Submitted → A2A 'submitted' state) and predated the mount() helper commit. Update to describe: - A2A state: 'completed' for Submitted AdCP arms (A2A 0.3.0 lifecycle fix) - adcp_task_id on artifact.metadata (not DataPart data) - mount(app) convenience helper - parameters/input DataPart alias - Full export list including A2AMountOptions, ExpressAppLike Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/a2a-adapter-v0-preview.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/a2a-adapter-v0-preview.md b/.changeset/a2a-adapter-v0-preview.md index f6167663c..29b783dea 100644 --- a/.changeset/a2a-adapter-v0-preview.md +++ b/.changeset/a2a-adapter-v0-preview.md @@ -6,19 +6,21 @@ **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`:** +**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'`) → `submitted` + DataPart artifact with `adcp_task_id` surfaced alongside the AdCP payload. A2A's `Task.id` is SDK-generated; the AdCP-level handle rides on the artifact. +- 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` -**Agent card.** Seller supplies identity (`name`, `description`, `url`, `version`, `provider`, `securitySchemes`); the SDK seeds `skills[]` from registered AdCP tools, defaults `capabilities.streaming=false` / `pushNotifications=false` (v0 ships neither), and validates the merged card against A2A's required-field set at boot — unserviceable cards fail `createA2AAdapter()` rather than shipping to the wire. +**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`. -**Skill addressing.** Clients send a `Message` with a single `DataPart` carrying `{ skill: '', input: { ... } }`. Non-conforming messages surface as `Task.state='failed'` with `reason: 'INVALID_INVOCATION'` rather than silently misrouting. +**`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. -**New public surface.** `AdcpServer.invoke({ toolName, args, authInfo, signal })` — production-safe alias of the tool-call path both transports run through. Documented as requiring the caller to have authenticated the principal. `dispatchTestRequest` stays as the test-only sibling with its "never mount behind HTTP" docstring intact. +**Skill addressing.** Clients send a `Message` with a single `DataPart` carrying `{ skill: '', 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 exports** (from `@adcp/client` and `@adcp/client/server`): `createA2AAdapter`, `A2AInvocationError`, `A2AAdapter`, `A2AAdapterOptions`, `A2AAgentCardOverrides`, plus `AdcpAuthInfo` and `AdcpInvokeOptions` for transport authors building custom adapters. +**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`.