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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/e2e-directory-inverse-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"adcontextprotocol": patch
---

feat(scripts): exercise the AAO directory inverse-lookup in the agent-resolution e2e script.

`scripts/e2e-resolve-training-agent.ts` now optionally appends a directory inverse-lookup after the 8-step forward chain. Given the resolved agent URL, the script calls `fetchAgentAuthorizationsFromDirectory` (shipped in `@adcp/sdk@7.10.0`) against the AAO's `GET /v1/agents/{agent_url}/publishers` endpoint and prints the publishers whose `adagents.json` authorize the agent.

- HTTP mode: defaults the directory URL to `<base-url>/api` (where the registry router is mounted in `server/src/http.ts`). Pass `--directory <url>` to point at a different directory, or `--directory none` to skip.
- In-process mode: skipped (the inline Express app doesn't mount the AAO routes, which require database access).

Pairs PR #4836 (server endpoint) with the SDK's consumer-side wrapper, giving a runnable demo of the full directory chain. Directory failures are caught and reported but don't fail the script — the forward chain is the primary contract, the inverse lookup is an additive demo.
9 changes: 9 additions & 0 deletions .changeset/sdk-7-10-2-bump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"adcontextprotocol": patch
---

chore(deps): bump @adcp/sdk 7.7 → 7.10.2 — catches the spec repo up on the 7.x line.

Pulls in 7.8's `impairment.coherence` audience-inverse grading + `creative_approvals[]` walk, 7.8's `ctx.input` surface on v6 platform methods (adoption in our v6 shims is a follow-up), 7.9's `pgCtxMetadataStore.resource` round-trip, and 7.10's `fetchAgentAuthorizationsFromDirectory` + typed `AGENT_SUSPENDED`/`AGENT_BLOCKED` codes. 7.10.0/7.10.1 had v2/projection packaging gaps that crashed `/sales` storyboards; both fixed via adcp-client#1909 (catalog) and adcp-client#1917 (registry).

Spec-side behavior unchanged; storyboard floors held without modification.
14 changes: 14 additions & 0 deletions .changeset/v6-shim-ctx-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"adcontextprotocol": patch
---

fix(training-agent): thread `dry_run` and `assignments[]` through v6 platform shims via `ctx.input`.

The v6 SDK's typed `SalesPlatform.syncCreatives`, `AudiencePlatform.syncAudiences`, and `AccountStore.upsert` signatures destructure the request envelope and pass only the typed first-arg (`creatives[]` / `audiences[]` / `refs[]`) to the platform method — fields like `dry_run` and inline `assignments[]` were dropped on the v6 path while the legacy `/mcp` route preserved them (adcp-client#1842). 7.8 fixed this by exposing the original envelope as `ctx.input: Readonly<Record<string, unknown>>`; this change lifts the dropped fields back out for our v5-shimming v6 platforms.

Adopted in:

- `v6-sales-platform.ts` and `v6-creative-platform.ts` and `v6-creative-builder-platform.ts` — `syncCreatives` now threads `dry_run` (suppresses session persistence) and `assignments[]` (writes inline package bindings) through to `handleSyncCreatives`. The v6 response signature returns only `SyncCreativesRow[]`, so assignment results are observable via subsequent `get_media_buys` rather than in the sync response itself.
- `v6-account-helpers.ts` — `syncAccountsUpsert` threads `dry_run` to `handleSyncAccounts`. `delete_missing` is on the SDK's drop list but the v5 handler doesn't implement it yet, so threading it would be inert — wire when v5 grows support.

Helper `pickFromInput` in `v6-input-helpers.ts` does the named-field lift; per SDK guidance, `ctx.input` is buyer-controlled and untrusted, so the helper reads only named fields and never logs wholesale.
12 changes: 8 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"dev:docs": "node scripts/dev-docs.mjs"
},
"dependencies": {
"@adcp/sdk": "^7.7.0",
"@adcp/sdk": "^7.10.2",
"@anthropic-ai/sdk": "^0.96.0",
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@contentauth/c2pa-node": "^0.5.4",
Expand Down
71 changes: 70 additions & 1 deletion scripts/e2e-resolve-training-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
import { getDomain } from 'tldts';
import express from 'express';
import request from 'supertest';
import {
fetchAgentAuthorizationsFromDirectory,
type DirectoryPublisherEntry,
} from '@adcp/sdk';

const MAX_CAPABILITIES_BYTES = 65_536;
const MAX_BRAND_JSON_BYTES = 262_144;
Expand Down Expand Up @@ -369,6 +373,46 @@ async function resolveAgent(agentUrl: string, ctx: CallContext): Promise<Resolut
};
}

/**
* AAO directory inverse-lookup: given the resolved agent URL, ask the
* directory which publishers' adagents.json authorize it. Companion to
* the forward chain — answers "where does this agent sell?" via the
* directory shipped in spec PR #4828 / SDK 7.10.
*
* Trust posture: the directory is discovery, not authorization. Each
* entry here is a hint that the listed publisher's adagents.json
* authorizes the agent; verifying that claim still requires fetching
* the publisher's file directly. Don't grant access on directory
* output alone.
*/
async function lookupPublishers(
agentUrl: string,
directoryUrl: string,
): Promise<DirectoryPublisherEntry[]> {
const iter = fetchAgentAuthorizationsFromDirectory(agentUrl, {
directoryUrl,
status: ['authorized'],
});
return iter.toArray();
}

function printPublishers(directoryUrl: string, publishers: DirectoryPublisherEntry[]): void {
console.log('');
console.log(`directory ${directoryUrl}`);
console.log(`publishers ${publishers.length} authorized`);
if (publishers.length === 0) {
console.log(' (no publishers — agent may not be present in any indexed adagents.json)');
return;
}
for (const p of publishers) {
const mgr = p.manager_domain ? ` via=${p.manager_domain}` : '';
const pinned = p.signing_keys_pinned ? ' pinned' : '';
console.log(
` ${p.publisher_domain.padEnd(40)} ${p.discovery_method.padEnd(24)}${mgr.padEnd(24)} props=${p.properties_authorized}/${p.properties_total}${pinned} verified=${p.last_verified_at}`,
);
}
}

function printResult(result: ResolutionResult): void {
console.log('');
console.log(`agent_url ${result.agentUrl}`);
Expand All @@ -392,10 +436,17 @@ function printResult(result: ResolutionResult): void {
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: tsx scripts/e2e-resolve-training-agent.ts <base-url|--inproc>');
console.error('Usage: tsx scripts/e2e-resolve-training-agent.ts <base-url|--inproc> [--directory <url>]');
console.error('');
console.error(' --directory <url> Run the AAO directory inverse-lookup after the forward chain.');
console.error(' Defaults to <base-url>/api when HTTP mode is used and the');
console.error(' flag is omitted. Pass --directory none to skip.');
process.exit(2);
}

const directoryFlagIdx = args.indexOf('--directory');
const directoryArg = directoryFlagIdx >= 0 ? args[directoryFlagIdx + 1] : undefined;

if (args[0] === '--inproc') {
// In-process mode: spin up Express with the training-agent router AND the
// brand.json + jwks.json well-known endpoints (mirroring the production
Expand Down Expand Up @@ -454,6 +505,24 @@ async function main(): Promise<void> {
},
});
printResult(result);

// Forward chain done. Now the AAO directory inverse-lookup: which
// publishers' adagents.json authorize this agent? Defaults to the
// same dev-server host (router is mounted under `/api`).
const directoryUrl = directoryArg === 'none'
? undefined
: directoryArg ?? `${baseUrl}/api`;
if (directoryUrl) {
try {
const publishers = await lookupPublishers(agentUrl, directoryUrl);
printPublishers(directoryUrl, publishers);
} catch (err) {
console.error(`\ndirectory lookup failed: ${(err as Error).message}`);
// Don't propagate — the forward chain is the primary contract;
// directory lookup is an additive demo.
}
}

console.log('OK');
}

Expand Down
10 changes: 9 additions & 1 deletion server/src/training-agent/v6-account-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
SyncAccountsResultRow,
} from '@adcp/sdk/server';
import { handleSyncAccounts } from './account-handlers.js';
import { pickFromInput } from './v6-input-helpers.js';
import type { ToolArgs, TrainingContext } from './types.js';

function trainingCtxFromResolveCtx(ctx: ResolveContext | undefined): TrainingContext {
Expand All @@ -57,8 +58,15 @@ export const syncAccountsUpsert: NonNullable<AccountStore['upsert']> = async (re
// names. If the v6 SDK ever diverges (e.g., renames `billing` to
// `billing_party`), this cast hides the break and the v5 handler will
// see a different shape than it validated against.
//
// `dry_run` is dropped from the v6 typed signature (adcp-client#1842).
// Lift it off `ctx.input` so the v5 handler skips persistence and
// returns the would-be result rows. `delete_missing` is on the SDK's
// drop list too but `handleSyncAccounts` doesn't implement it yet, so
// threading it would be inert — wire when v5 grows support.
const fromInput = pickFromInput(ctx?.input, ['dry_run'] as const);
const v5Result = handleSyncAccounts(
{ accounts: refs as unknown[] } as ToolArgs,
{ accounts: refs as unknown[], ...fromInput } as ToolArgs,
trainingCtx,
);
// v5 handleSyncAccounts returns `{ accounts: [...] }` where each entry
Expand Down
5 changes: 4 additions & 1 deletion server/src/training-agent/v6-creative-builder-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
handleSyncCreatives,
} from './task-handlers.js';
import { syncAccountsUpsert } from './v6-account-helpers.js';
import { pickFromInput } from './v6-input-helpers.js';
import { trainingBuyerAgentRegistry } from './buyer-agent-registry.js';
import type { ToolArgs, TrainingContext } from './types.js';

Expand Down Expand Up @@ -145,7 +146,9 @@ export class TrainingCreativeBuilderPlatform
return translateV5Result(result);
},
syncCreatives: async (creatives, ctx) => {
const result = await handleSyncCreatives({ creatives } as unknown as ToolArgs, buildTrainingCtx(ctx.account));
// Lift `dry_run` and `assignments[]` off ctx.input (adcp-client#1842).
const fromInput = pickFromInput(ctx.input, ['assignments', 'dry_run'] as const);
const result = await handleSyncCreatives({ creatives, ...fromInput } as unknown as ToolArgs, buildTrainingCtx(ctx.account));
const wrapped = translateV5Result<{ creatives?: unknown[] }>(result);
return (wrapped.creatives ?? []) as SyncCreativesRow[];
},
Expand Down
9 changes: 8 additions & 1 deletion server/src/training-agent/v6-creative-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
handleSyncCreatives,
} from './task-handlers.js';
import { syncAccountsUpsert } from './v6-account-helpers.js';
import { pickFromInput } from './v6-input-helpers.js';
import { trainingBuyerAgentRegistry } from './buyer-agent-registry.js';
import type { ToolArgs, TrainingContext } from './types.js';

Expand Down Expand Up @@ -154,7 +155,13 @@ export class TrainingCreativePlatform
// aggregateCreativePolicy returns null — provenance enforcement
// silently no-ops.
const brandDomain = (ctx.account as { ctx_metadata?: { brand_domain?: string } } | undefined)?.ctx_metadata?.brand_domain;
const args = brandDomain ? { creatives, brand: { domain: brandDomain } } : { creatives };
// Lift `dry_run` and `assignments[]` off ctx.input (adcp-client#1842).
const fromInput = pickFromInput(ctx.input, ['assignments', 'dry_run'] as const);
const args = {
creatives,
...fromInput,
...(brandDomain && { brand: { domain: brandDomain } }),
};
const result = await handleSyncCreatives(args as unknown as ToolArgs, buildTrainingCtx(ctx.account));
// v5 returns wire-wrapped `{ creatives: [...] }`; v6 wants rows.
const wrapped = translateV5Result<{ creatives?: unknown[] }>(result);
Expand Down
27 changes: 27 additions & 0 deletions server/src/training-agent/v6-input-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Pick named fields off the v6 framework's `ctx.input` envelope so v5
* handlers see fields the v6 platform-method signature destructures away.
*
* Why this exists: the v6 SDK's typed platform-method signatures drop
* spec-meaningful modifiers from the request envelope before invoking
* the method (`sync_creatives_request` loses `assignments[]`, `dry_run`,
* etc.). The framework exposes the original envelope as a
* `Readonly<Record<string, unknown>>` on `ctx.input`. Our v6 shims
* forward to v5 handlers that DO read those fields, so we have to lift
* them back out and pass them through.
*
* Per SDK guidance, treat `ctx.input` as buyer-controlled and untrusted:
* read named fields only, never log wholesale. The v5 handler validates
* shape — we only need to thread the value through.
*/
export function pickFromInput<K extends string>(
input: Readonly<Record<string, unknown>> | undefined,
fields: readonly K[],
): Partial<Record<K, unknown>> {
if (!input) return {};
const out: Partial<Record<K, unknown>> = {};
for (const key of fields) {
if (key in input) out[key] = input[key];
}
return out;
}
15 changes: 14 additions & 1 deletion server/src/training-agent/v6-sales-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from './catalog-event-handlers.js';
import { handleSyncAudiences } from './audience-handlers.js';
import { syncAccountsUpsert } from './v6-account-helpers.js';
import { pickFromInput } from './v6-input-helpers.js';
import { trainingBuyerAgentRegistry } from './buyer-agent-registry.js';
import type { ToolArgs, TrainingContext } from './types.js';

Expand Down Expand Up @@ -250,7 +251,19 @@ export class TrainingSalesPlatform

syncCreatives: async (creatives, ctx) => {
const brandDomain = brandDomainFromCtx(ctx.account);
const args = brandDomain ? { creatives, brand: { domain: brandDomain } } : { creatives };
// `dry_run` and `assignments[]` are dropped from the v6 typed
// signature (adcp-client#1842). Lift them back off `ctx.input` so
// the v5 handler honors dry-run mode and writes inline
// package-binding side effects to session storage. The v6
// response signature returns only `SyncCreativesRow[]`, so
// `assignments[]` are observable via subsequent `get_media_buys`,
// not in the sync_creatives response itself.
const fromInput = pickFromInput(ctx.input, ['assignments', 'dry_run'] as const);
const args = {
creatives,
...fromInput,
...(brandDomain && { brand: { domain: brandDomain } }),
};
const v5Result = await handleSyncCreatives(args as unknown as ToolArgs, buildTrainingCtx(ctx.account));
// v5 returns wire-wrapped `{ creatives: [...] }`; v6 SalesPlatform
// wants rows directly — framework re-wraps.
Expand Down
41 changes: 41 additions & 0 deletions server/tests/unit/v6-input-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* `pickFromInput` lifts named fields off the v6 framework's `ctx.input`
* envelope so v5 handlers see modifiers the v6 typed signature drops
* (adcp-client#1842).
*/

import { describe, expect, it } from 'vitest';
import { pickFromInput } from '../../src/training-agent/v6-input-helpers.js';

describe('pickFromInput', () => {
it('returns named fields when present', () => {
const out = pickFromInput(
{ dry_run: true, assignments: [{ creative_id: 'c1', package_id: 'p1' }], extra: 'ignored' },
['dry_run', 'assignments'] as const,
);
expect(out).toEqual({
dry_run: true,
assignments: [{ creative_id: 'c1', package_id: 'p1' }],
});
});

it('omits fields that are not present (no undefined leakage)', () => {
const out = pickFromInput({ dry_run: true }, ['dry_run', 'assignments'] as const);
expect(out).toEqual({ dry_run: true });
expect('assignments' in out).toBe(false);
});

it('preserves falsy values that are explicitly set', () => {
const out = pickFromInput({ dry_run: false }, ['dry_run'] as const);
expect(out).toEqual({ dry_run: false });
expect('dry_run' in out).toBe(true);
});

it('returns empty when ctx.input is undefined', () => {
expect(pickFromInput(undefined, ['dry_run'] as const)).toEqual({});
});

it('returns empty when no requested fields are present', () => {
expect(pickFromInput({ unrelated: 1 }, ['dry_run'] as const)).toEqual({});
});
});
Loading