diff --git a/docs/protocols/a2a-guide.mdx b/docs/protocols/a2a-guide.mdx index 8bfe9b4f5..f66867dc4 100644 --- a/docs/protocols/a2a-guide.mdx +++ b/docs/protocols/a2a-guide.mdx @@ -711,32 +711,51 @@ console.log('Examples:', getProductsSkill.examples); ] } ], - "extensions": { - "adcp": { - "adcp_version": "2.4.0", - "protocols_supported": ["media_buy"] + "extensions": [ + { + "uri": "https://adcontextprotocol.org/extensions/adcp", + "description": "AdCP media buying protocol support", + "required": false, + "params": { + "adcp_version": "2.6.0", + "protocols_supported": ["media_buy"], + "extensions_supported": ["sustainability"] + } } - } + ] } ``` ### AdCP Extension -**Recommended**: Include the `extensions.adcp` field in your agent card to declare AdCP support programmatically. +**Recommended**: Include the AdCP extension in your agent card's `extensions` array to declare AdCP support programmatically. + +The A2A protocol uses an `extensions` array where each extension has: +- **`uri`**: Extension identifier (use `https://adcontextprotocol.org/extensions/adcp`) +- **`description`**: Human-readable description of how you use AdCP +- **`required`**: Whether clients must support this extension (typically `false` for AdCP) +- **`params`**: AdCP-specific configuration (see schema below) ```javascript // Check if agent supports AdCP const agentCard = await fetch('https://sales.example.com/.well-known/agent.json') .then(r => r.json()); -if (agentCard.extensions?.adcp) { - console.log('AdCP Version:', agentCard.extensions.adcp.adcp_version); - console.log('Supported domains:', agentCard.extensions.adcp.protocols_supported); +// Find the AdCP extension in the extensions array +const adcpExt = agentCard.extensions?.find( + ext => ext.uri === 'https://adcontextprotocol.org/extensions/adcp' +); + +if (adcpExt) { + console.log('AdCP Version:', adcpExt.params.adcp_version); + console.log('Supported domains:', adcpExt.params.protocols_supported); // ["media_buy", "creative", "signals"] + console.log('Typed extensions:', adcpExt.params.extensions_supported); + // ["sustainability"] } ``` -**Extension Structure**: See the [AdCP extension schema](https://adcontextprotocol.org/schemas/v2/protocols/adcp-extension.json) for complete specification. +**Extension Params Schema**: See the [AdCP extension schema](https://adcontextprotocol.org/schemas/v2/protocols/adcp-extension.json) for the complete `params` specification. **Benefits**: - Clients can discover AdCP capabilities without making test calls diff --git a/docs/protocols/mcp-guide.mdx b/docs/protocols/mcp-guide.mdx index c3303454d..a8581b776 100644 --- a/docs/protocols/mcp-guide.mdx +++ b/docs/protocols/mcp-guide.mdx @@ -632,22 +632,60 @@ const adcpTools = tools.filter(t => t.name.startsWith('adcp_') || ['get_products', 'create_media_buy'].includes(t.name)); ``` -### AdCP Extension (Future) +### AdCP Extension via MCP Server Card -**Status**: MCP server cards are expected in a future MCP release. When available, AdCP servers will include the AdCP extension. +MCP servers can declare AdCP support via a server card at `/.well-known/mcp.json` (or `/.well-known/server.json`). AdCP-specific metadata goes in the `_meta` field using the `adcontextprotocol.org` namespace. ```json { - "extensions": { - "adcp": { - "adcp_version": "2.4.0", - "protocols_supported": ["media_buy", "creative", "signals"] + "name": "io.adcontextprotocol/media-buy-agent", + "version": "1.0.0", + "title": "AdCP Media Buy Agent", + "description": "AI-powered media buying agent implementing AdCP", + "tools": [ + { "name": "get_products" }, + { "name": "create_media_buy" }, + { "name": "list_creative_formats" } + ], + "_meta": { + "adcontextprotocol.org": { + "adcp_version": "2.6.0", + "protocols_supported": ["media_buy"], + "extensions_supported": ["sustainability"] } } } ``` -This will allow clients to programmatically discover which AdCP version and protocol domains an MCP server implements. See the [AdCP extension schema](https://adcontextprotocol.org/schemas/v2/protocols/adcp-extension.json) for specification details. +**Discovering AdCP support:** + +```javascript +// Check both possible locations for MCP server card +const serverCard = await fetch('https://sales.example.com/.well-known/mcp.json') + .then(r => r.ok ? r.json() : null) + .catch(() => null) + || await fetch('https://sales.example.com/.well-known/server.json') + .then(r => r.json()); + +// Check for AdCP metadata +const adcpMeta = serverCard?._meta?.['adcontextprotocol.org']; + +if (adcpMeta) { + console.log('AdCP Version:', adcpMeta.adcp_version); + console.log('Supported domains:', adcpMeta.protocols_supported); + // ["media_buy", "creative", "signals"] + console.log('Typed extensions:', adcpMeta.extensions_supported); + // ["sustainability"] +} +``` + +**Benefits:** +- Clients can discover AdCP capabilities without making test calls +- Declare which protocol domains you implement (media_buy, creative, signals) +- Declare which [typed extensions](/docs/reference/extensions-and-context#typed-extensions) you support +- Enable compatibility checks based on version + +**Note:** The `_meta` field uses reverse DNS namespacing per the [MCP server.json spec](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/generic-server-json.md). AdCP servers should support both `/.well-known/mcp.json` and `/.well-known/server.json` locations. ### Parameter Validation ```javascript diff --git a/docs/reference/extensions-and-context.mdx b/docs/reference/extensions-and-context.mdx index 48484c020..e29ec563c 100644 --- a/docs/reference/extensions-and-context.mdx +++ b/docs/reference/extensions-and-context.mdx @@ -181,6 +181,213 @@ Without namespacing: - Impossible to support multiple platforms simultaneously - Harder to deprecate platform-specific features +## Typed Extensions + +AdCP supports formal JSON schemas for extensions, enabling type safety and validation. Typed extensions are published in the `/schemas/extensions/` directory and can be declared in agent cards. + +### How Typed Extensions Work + +Typed extensions provide a middle ground between untyped vendor extensions and core AdCP fields: + +- **Untyped extensions** (`ext.vendor.*`) - Any JSON, no validation, vendor-specific +- **Typed extensions** (`ext.sustainability.*`) - Formal schema, validated, cross-vendor +- **Core fields** - Part of AdCP specification, required for interoperability + +Typed extensions enable SDK code generation, schema validation, and cross-vendor interoperability while remaining optional. + +### Extension Schema Registry + +The registry of available typed extensions is auto-generated from extension files: +- **Registry**: `/schemas/v1/extensions/index.json` +- **Extension schemas**: `/schemas/v1/extensions/{namespace}.json` +- **Meta schema**: `/schemas/v1/extensions/extension-meta.json` + +Each versioned schema path (`/schemas/v2.5/`, `/schemas/v2.6/`) includes only extensions valid for that version, based on the `valid_from` and `valid_until` fields in each extension. + +### Extension Versioning + +Extensions are versioned independently of the AdCP specification: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/extensions/sustainability.json", + "title": "Sustainability Extension", + "description": "Carbon footprint and green certification data", + "valid_from": "2.5", + "valid_until": "4.0", + "docs_url": "https://adcontextprotocol.org/docs/extensions/sustainability", + "type": "object", + "properties": { + "carbon_kg_per_impression": { "type": "number" }, + "certified_green": { "type": "boolean" } + } +} +``` + +- **`valid_from`** (required): Minimum AdCP version this extension works with (e.g., `"2.5"`) +- **`valid_until`** (optional): Last AdCP version this extension works with. Omit if still valid. + +This allows extensions to be backported to earlier AdCP versions and deprecated gracefully. + +### Declaring Extension Support + +Agents declare which typed extensions they support in their discovery metadata. The location depends on the protocol: + +#### A2A Agent Cards + +A2A agents declare AdCP support in the `extensions` array at `/.well-known/agent.json`: + +```json +{ + "name": "AdCP Media Buy Agent", + "skills": [{ "name": "get_products" }, { "name": "create_media_buy" }], + "extensions": [ + { + "uri": "https://adcontextprotocol.org/extensions/adcp", + "description": "AdCP media buying protocol support", + "required": false, + "params": { + "adcp_version": "2.6.0", + "protocols_supported": ["media_buy"], + "extensions_supported": ["sustainability"] + } + } + ] +} +``` + +See the [A2A Guide](/docs/protocols/a2a-guide#adcp-extension) for complete details. + +#### MCP Server Cards + +MCP servers declare AdCP support via the `_meta` field at `/.well-known/mcp.json` or `/.well-known/server.json`: + +```json +{ + "name": "io.adcontextprotocol/media-buy-agent", + "tools": [{ "name": "get_products" }, { "name": "create_media_buy" }], + "_meta": { + "adcontextprotocol.org": { + "adcp_version": "2.6.0", + "protocols_supported": ["media_buy"], + "extensions_supported": ["sustainability"] + } + } +} +``` + +See the [MCP Guide](/docs/protocols/mcp-guide#adcp-extension-via-mcp-server-card) for complete details. + +#### Extension Params Schema + +Both protocols use the same params structure, defined in the [AdCP extension schema](https://adcontextprotocol.org/schemas/v2/protocols/adcp-extension.json): +- **`adcp_version`** (required): Semantic version of AdCP implemented (e.g., "2.6.0") +- **`protocols_supported`** (required): Array of protocol domains (media_buy, creative, signals) +- **`extensions_supported`** (optional): Array of typed extension namespaces + +When an agent declares support for an extension, clients can expect: +- The agent will accept and populate `ext.{namespace}` fields conforming to that extension's schema +- Data in `ext.{namespace}` is strongly typed and can be validated +- SDK code generation can produce type-safe interfaces for the extension + +### Using Typed Extensions + +When an agent supports a typed extension, you can include and expect data in the corresponding namespace: + +```json +{ + "product_id": "premium_ctv", + "name": "Premium CTV Package", + "ext": { + "sustainability": { + "carbon_kg_per_impression": 0.0012, + "certified_green": true + } + } +} +``` + +### Creating a Typed Extension + +To create a typed extension: + +1. **Create the extension file** at `/schemas/extensions/{namespace}.json` +2. **Follow the meta schema** defined in `/schemas/extensions/extension-meta.json` +3. **Set `valid_from`** to the minimum AdCP version your extension requires +4. **Add documentation** via the `docs_url` field + +Example extension file: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/extensions/sustainability.json", + "title": "Sustainability Extension", + "description": "Carbon footprint and green certification data for ad products", + "valid_from": "2.5", + "docs_url": "https://adcontextprotocol.org/docs/extensions/sustainability", + "type": "object", + "properties": { + "carbon_kg_per_impression": { + "type": "number", + "description": "Estimated CO2 emissions per impression in kilograms" + }, + "certified_green": { + "type": "boolean", + "description": "Whether the inventory is certified by a green advertising program" + }, + "certification_provider": { + "type": "string", + "description": "Name of the green certification provider" + } + } +} +``` + +The build process auto-discovers extension files and includes them in the appropriate versioned schema builds. + +### Proposing Typed Extensions + +To propose a typed extension for inclusion in AdCP: + +1. **Validate in production first** - Use vendor-namespaced extensions (`ext.yourcompany.*`) to prove value +2. **Document the schema** - Define clear property names, types, and descriptions +3. **Gather adoption evidence** - Show multiple implementations or strong demand +4. **Submit an RFC** - Propose the extension with schema and use cases +5. **If accepted** - Extension is added to `/schemas/extensions/` with appropriate `valid_from` + +### Reserved Namespaces + +The following namespaces are reserved and cannot be used for typed extensions: +- `adcp`, `core`, `protocol`, `schema`, `meta`, `ext`, `context` + +These are reserved to prevent confusion with core AdCP concepts. + +### Extension Deprecation Policy + +#### When Extensions May Be Deprecated + +1. **Superseded** - A better extension exists with a documented migration path +2. **Promoted to core** - The extension becomes a core AdCP field +3. **Low adoption** - Insufficient usage across implementations +4. **Incompatible** - Breaking changes in AdCP require removal + +#### Deprecation Process + +1. Set `valid_until` to current version + 1 minor release (minimum grace period) +2. Announce deprecation in CHANGELOG +3. Publish migration guide if replacement exists +4. Add deprecation warning to extension docs +5. Extension removed from builds after `valid_until` version + +#### Emergency Deprecation + +Security issues may require immediate deprecation: +- `valid_until` set to current version +- Security advisory published +- Patch release issued + ## Proposing Spec Additions If your extension field represents **common ad tech functionality** that would benefit all AdCP implementations: diff --git a/package.json b/package.json index 8dab66fd6..6d755f394 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:schemas": "node tests/schema-validation.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs", "test:extensions": "node tests/extension-fields.test.cjs", + "test:extension-schemas": "node tests/extension-schemas.test.cjs", "test:snippets": "node tests/snippet-validation.test.cjs", "test:json-schema": "node tests/json-schema-validation.test.cjs", "test:error-handling": "node tests/check-error-handling.cjs", @@ -27,7 +28,7 @@ "test:registry": "vitest run server/tests", "test:docker": "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit", "generate-openapi": "node scripts/generate-openapi.cjs", - "test": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:json-schema && npm run test:composed && npm run test:migrations && npm run test:unit && npm run typecheck", + "test": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:composed && npm run test:migrations && npm run test:unit && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit": "npm test", "prepare": "husky", diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 9db4d8d07..49e241b43 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -21,6 +21,11 @@ * - /schemas/v{major}/ - Points to latest release of that major version * - /schemas/v{major}.{minor}/ - Points to latest release of that minor version * - /schemas/v1/ - Backward compatibility (always points to latest/) + * + * Extension handling: + * - Extensions are auto-discovered from static/schemas/source/extensions/ + * - Each extension has valid_from/valid_until to specify compatible AdCP versions + * - The build generates extensions/index.json with extensions valid for the target version */ const fs = require('fs'); @@ -83,6 +88,228 @@ function ensureDir(dir) { } } +/** + * Compare two minor versions (e.g., "2.5" vs "2.6") + * Returns: negative if a < b, 0 if equal, positive if a > b + */ +function compareMinorVersions(a, b) { + const [aMajor, aMinor] = a.split('.').map(Number); + const [bMajor, bMinor] = b.split('.').map(Number); + if (aMajor !== bMajor) return aMajor - bMajor; + return aMinor - bMinor; +} + +/** + * Reserved namespaces that cannot be used for typed extensions + * These could cause confusion with core AdCP concepts + */ +const RESERVED_NAMESPACES = ['adcp', 'core', 'protocol', 'schema', 'meta', 'ext', 'context']; + +/** + * Validate that an extension namespace is not reserved + * @param {string} namespace - Extension namespace to validate + * @throws {Error} If namespace is reserved + */ +function validateExtensionNamespace(namespace) { + if (RESERVED_NAMESPACES.includes(namespace.toLowerCase())) { + throw new Error(`Namespace "${namespace}" is reserved and cannot be used for extensions`); + } +} + +/** + * Discover extension files from the extensions directory + * Returns array of { namespace, schema, path } objects + */ +function discoverExtensions(extensionsDir) { + if (!fs.existsSync(extensionsDir)) { + return []; + } + + const extensions = []; + const files = fs.readdirSync(extensionsDir); + + for (const file of files) { + // Skip non-JSON files and special files + if (!file.endsWith('.json')) continue; + if (file === 'index.json' || file === 'extension-meta.json') continue; + + const filePath = path.join(extensionsDir, file); + try { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + // Extract namespace from $id (e.g., /schemas/extensions/sustainability.json -> sustainability) + const namespace = file.replace('.json', ''); + + // Validate namespace is not reserved + validateExtensionNamespace(namespace); + + extensions.push({ + namespace, + schema: content, + path: filePath + }); + } catch (error) { + console.warn(` โš ๏ธ Failed to parse extension ${file}: ${error.message}`); + } + } + + return extensions; +} + +/** + * Filter extensions to those valid for a given AdCP version + * @param {Array} extensions - Array of extension objects from discoverExtensions + * @param {string} targetVersion - Target AdCP version (e.g., "2.5.0" or "2.5") + * @returns {Array} Extensions valid for the target version + */ +function filterExtensionsForVersion(extensions, targetVersion) { + // Normalize to minor version for comparison + const targetMinor = getMinorVersion(targetVersion); + + return extensions.filter(ext => { + const { valid_from, valid_until } = ext.schema; + + // Must have valid_from + if (!valid_from) { + console.warn(` โš ๏ธ Extension ${ext.namespace} missing valid_from, skipping`); + return false; + } + + // Check valid_from <= targetVersion + if (compareMinorVersions(valid_from, targetMinor) > 0) { + return false; // Extension requires newer version + } + + // Check valid_until >= targetVersion (if specified) + if (valid_until && compareMinorVersions(valid_until, targetMinor) < 0) { + return false; // Extension no longer valid for this version + } + + return true; + }); +} + +/** + * Generate the extensions/index.json registry for a target version + * @param {Array} extensions - Array of valid extension objects + * @param {string} targetVersion - Target version string for $id paths + * @returns {Object} The generated registry object + */ +function generateExtensionRegistry(extensions, targetVersion) { + const registry = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: `/schemas/${targetVersion}/extensions/index.json`, + title: 'AdCP Extension Registry', + description: 'Auto-generated registry of formal AdCP extensions. Extensions provide typed schemas for vendor-specific or domain-specific data within the ext field. Agents declare which extensions they support in their agent card.', + _generated: true, + _generatedAt: new Date().toISOString(), + extensions: {} + }; + + for (const ext of extensions) { + registry.extensions[ext.namespace] = { + $ref: `/schemas/${targetVersion}/extensions/${ext.namespace}.json`, + title: ext.schema.title, + description: ext.schema.description, + valid_from: ext.schema.valid_from + }; + + // Include valid_until if specified + if (ext.schema.valid_until) { + registry.extensions[ext.namespace].valid_until = ext.schema.valid_until; + } + + // Include docs_url if specified + if (ext.schema.docs_url) { + registry.extensions[ext.namespace].docs_url = ext.schema.docs_url; + } + } + + return registry; +} + +/** + * Build extensions for a target directory + * - Discovers all extensions from source + * - Filters to those valid for target version + * - Copies valid extension schemas + * - Generates the index.json registry + */ +function buildExtensions(sourceDir, targetDir, version) { + const sourceExtensionsDir = path.join(sourceDir, 'extensions'); + const targetExtensionsDir = path.join(targetDir, 'extensions'); + + // Always ensure extensions directory exists + ensureDir(targetExtensionsDir); + + // Discover all extensions + const allExtensions = discoverExtensions(sourceExtensionsDir); + + if (allExtensions.length === 0) { + // No extensions yet - just copy the meta schema and generate empty registry + const metaSchemaPath = path.join(sourceExtensionsDir, 'extension-meta.json'); + if (fs.existsSync(metaSchemaPath)) { + let content = fs.readFileSync(metaSchemaPath, 'utf8'); + // Update $id to include version + content = content.replace( + /"\$id":\s*"\/schemas\//g, + `"$id": "/schemas/${version}/` + ); + fs.writeFileSync(path.join(targetExtensionsDir, 'extension-meta.json'), content); + } + + // Generate empty registry + const registry = generateExtensionRegistry([], version); + fs.writeFileSync( + path.join(targetExtensionsDir, 'index.json'), + JSON.stringify(registry, null, 2) + ); + + return { total: 0, included: 0 }; + } + + // Filter extensions valid for this version + const validExtensions = filterExtensionsForVersion(allExtensions, version); + + // Copy extension-meta.json (with version transform) + const metaSchemaPath = path.join(sourceExtensionsDir, 'extension-meta.json'); + if (fs.existsSync(metaSchemaPath)) { + let content = fs.readFileSync(metaSchemaPath, 'utf8'); + content = content.replace( + /"\$id":\s*"\/schemas\//g, + `"$id": "/schemas/${version}/` + ); + fs.writeFileSync(path.join(targetExtensionsDir, 'extension-meta.json'), content); + } + + // Copy each valid extension schema (with version transform) + for (const ext of validExtensions) { + let content = JSON.stringify(ext.schema, null, 2); + // Update $id to include version + content = content.replace( + /"\$id":\s*"\/schemas\//g, + `"$id": "/schemas/${version}/` + ); + fs.writeFileSync( + path.join(targetExtensionsDir, `${ext.namespace}.json`), + content + ); + } + + // Generate the registry index + const registry = generateExtensionRegistry(validExtensions, version); + fs.writeFileSync( + path.join(targetExtensionsDir, 'index.json'), + JSON.stringify(registry, null, 2) + ); + + return { + total: allExtensions.length, + included: validExtensions.length, + extensions: validExtensions.map(e => e.namespace) + }; +} + function copyAndTransformSchemas(sourceDir, targetDir, version) { const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); @@ -91,6 +318,10 @@ function copyAndTransformSchemas(sourceDir, targetDir, version) { const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { + // Skip extensions directory - handled separately by buildExtensions() + if (entry.name === 'extensions') { + continue; + } ensureDir(targetPath); copyAndTransformSchemas(sourcePath, targetPath, version); } else if (entry.name.endsWith('.json')) { @@ -317,6 +548,15 @@ async function main() { ensureDir(versionDir); copyAndTransformSchemas(SOURCE_DIR, versionDir, version); + // Build extensions (auto-discovered, filtered by version) + console.log(`๐Ÿ”Œ Building extensions for ${version}`); + const extResult = buildExtensions(SOURCE_DIR, versionDir, version); + if (extResult.total === 0) { + console.log(` โœ“ No extensions defined yet (empty registry created)`); + } else { + console.log(` โœ“ Included ${extResult.included}/${extResult.total} extensions: ${extResult.extensions.join(', ') || 'none'}`); + } + // Generate bundled schemas for release const bundledDir = path.join(versionDir, 'bundled'); console.log(`๐Ÿ“ฆ Generating bundled schemas to dist/schemas/${version}/bundled/`); @@ -342,6 +582,9 @@ async function main() { ensureDir(latestDir); copyAndTransformSchemas(SOURCE_DIR, latestDir, 'latest'); + // Build extensions for latest (using full version for filtering) + buildExtensions(SOURCE_DIR, latestDir, version); + // Generate bundled schemas for latest const latestBundledDir = path.join(latestDir, 'bundled'); await generateBundledSchemas(SOURCE_DIR, latestBundledDir, 'latest'); @@ -386,6 +629,15 @@ async function main() { ensureDir(latestDir); copyAndTransformSchemas(SOURCE_DIR, latestDir, 'latest'); + // Build extensions (auto-discovered, filtered by current version) + console.log(`๐Ÿ”Œ Building extensions for ${version}`); + const extResult = buildExtensions(SOURCE_DIR, latestDir, version); + if (extResult.total === 0) { + console.log(` โœ“ No extensions defined yet (empty registry created)`); + } else { + console.log(` โœ“ Included ${extResult.included}/${extResult.total} extensions: ${extResult.extensions.join(', ') || 'none'}`); + } + // Generate bundled schemas for latest const bundledDir = path.join(latestDir, 'bundled'); console.log(`๐Ÿ“ฆ Generating bundled schemas to dist/schemas/latest/bundled/`); diff --git a/static/schemas/source/extensions/extension-meta.json b/static/schemas/source/extensions/extension-meta.json new file mode 100644 index 000000000..75e199a57 --- /dev/null +++ b/static/schemas/source/extensions/extension-meta.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/extensions/extension-meta.json", + "title": "AdCP Extension File Schema", + "description": "Schema that all extension files must follow. Combines metadata (valid_from, docs_url) with the actual extension data schema. Extensions are auto-discovered from /schemas/extensions/*.json and included in versioned builds based on valid_from/valid_until.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "const": "http://json-schema.org/draft-07/schema#" + }, + "$id": { + "type": "string", + "pattern": "^/schemas/extensions/[a-z][a-z0-9_]*\\.json$", + "description": "Extension ID following pattern /schemas/extensions/{namespace}.json" + }, + "title": { + "type": "string", + "description": "Human-readable title for the extension" + }, + "description": { + "type": "string", + "description": "Description of what this extension provides" + }, + "valid_from": { + "type": "string", + "pattern": "^\\d+\\.\\d+$", + "description": "Minimum AdCP version this extension is compatible with (e.g., '2.5'). Extension will be included in all versioned schema builds >= this version." + }, + "valid_until": { + "type": "string", + "pattern": "^\\d+\\.\\d+$", + "description": "Last AdCP version this extension is compatible with (e.g., '3.0'). Omit if extension is still valid for current and future versions." + }, + "docs_url": { + "type": "string", + "format": "uri", + "description": "URL to documentation for implementors of this extension" + }, + "type": { + "const": "object", + "description": "Extensions must be objects (data within ext.{namespace})" + }, + "properties": { + "type": "object", + "description": "Schema properties defining the structure of ext.{namespace} data", + "additionalProperties": true + }, + "required": { + "type": "array", + "items": { "type": "string" }, + "description": "Required properties within the extension data" + }, + "additionalProperties": { + "description": "Whether additional properties are allowed in the extension data" + } + }, + "required": ["$schema", "$id", "title", "description", "valid_from", "type", "properties"] +} diff --git a/static/schemas/source/extensions/index.json b/static/schemas/source/extensions/index.json new file mode 100644 index 000000000..9b393e9ad --- /dev/null +++ b/static/schemas/source/extensions/index.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/extensions/index.json", + "title": "AdCP Extension Registry", + "description": "Auto-generated registry of formal AdCP extensions. Extensions provide typed schemas for vendor-specific or domain-specific data within the ext field. Agents declare which extensions they support in their agent card.", + "_generated": true, + "_note": "This file is auto-generated by the build process. Do not edit manually. Add extensions by creating new .json files in this directory following extension-meta.json schema.", + "extensions": {} +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 2e89bca9c..aa861e116 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -573,9 +573,21 @@ "schemas": { "adcp-extension": { "$ref": "/schemas/protocols/adcp-extension.json", - "description": "AdCP extension for agent cards (MCP and A2A). Declares AdCP version and supported protocol domains." + "description": "AdCP extension for agent cards (MCP and A2A). Declares AdCP version, supported protocol domains, and typed extensions." } } + }, + "extensions": { + "description": "Typed extension schemas for vendor-specific or domain-specific data. Extensions define the structure of data within the ext.{namespace} field. Agents declare which extensions they support in their agent card.", + "registry": { + "$ref": "/schemas/extensions/index.json", + "description": "Auto-generated registry of all available extensions with metadata" + }, + "meta": { + "$ref": "/schemas/extensions/extension-meta.json", + "description": "Schema that all extension files must follow. Defines valid_from, valid_until, and extension data structure." + }, + "schemas": {} } }, "usage": { diff --git a/static/schemas/source/protocols/adcp-extension.json b/static/schemas/source/protocols/adcp-extension.json index b383e4e67..7079b2eda 100644 --- a/static/schemas/source/protocols/adcp-extension.json +++ b/static/schemas/source/protocols/adcp-extension.json @@ -1,14 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/protocols/adcp-extension.json", - "title": "AdCP Agent Card Extension", - "description": "Extension metadata for agent cards (MCP and A2A) declaring AdCP version and supported protocol domains", + "title": "AdCP Agent Card Extension Params", + "description": "Parameters for declaring AdCP support in agent discovery. For A2A, use in extensions[].params. For MCP, use in _meta['adcontextprotocol.org'].", "type": "object", "properties": { "adcp_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", - "description": "Semantic version of the AdCP specification this agent implements (e.g., '2.4.0')" + "description": "Semantic version of the AdCP specification this agent implements (e.g., '2.5.0'). Extension schemas are versioned along with the AdCP spec." }, "protocols_supported": { "type": "array", @@ -19,6 +19,16 @@ "minItems": 1, "uniqueItems": true, "description": "AdCP protocol domains supported by this agent. At least one must be specified." + }, + "extensions_supported": { + "type": "array", + "description": "Typed extensions this agent supports. Each extension has a formal schema in /schemas/extensions/. Extension version is determined by adcp_version.", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Extension namespace (e.g., 'sustainability'). Must be lowercase alphanumeric with underscores." + }, + "uniqueItems": true } }, "required": ["adcp_version", "protocols_supported"], diff --git a/tests/extension-schemas.test.cjs b/tests/extension-schemas.test.cjs new file mode 100644 index 000000000..cf2726020 --- /dev/null +++ b/tests/extension-schemas.test.cjs @@ -0,0 +1,445 @@ +#!/usr/bin/env node +/** + * Extension schema validation test suite + * Tests that the typed extension infrastructure is properly configured + */ + +const fs = require('fs'); +const path = require('path'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const SCHEMA_BASE_DIR = path.join(__dirname, '../static/schemas/source'); +const EXTENSIONS_DIR = path.join(SCHEMA_BASE_DIR, 'extensions'); + +// Initialize AJV with formats and custom loader +const ajv = new Ajv({ + allErrors: true, + verbose: true, + strict: false, + loadSchema: loadExternalSchema +}); +addFormats(ajv); + +// Schema loader for resolving $ref +async function loadExternalSchema(uri) { + let relativePath; + if (uri.startsWith('/schemas/v1/')) { + relativePath = uri.replace('/schemas/v1/', ''); + } else if (uri.startsWith('/schemas/')) { + relativePath = uri.replace('/schemas/', ''); + } else { + throw new Error(`Cannot load external schema: ${uri}`); + } + + const schemaPath = path.join(SCHEMA_BASE_DIR, relativePath); + try { + const content = fs.readFileSync(schemaPath, 'utf8'); + return JSON.parse(content); + } catch (error) { + throw new Error(`Failed to load referenced schema ${uri}: ${error.message}`); + } +} + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; + +function log(message, type = 'info') { + const colors = { + info: '\x1b[0m', + success: '\x1b[32m', + error: '\x1b[31m', + warning: '\x1b[33m' + }; + console.log(`${colors[type]}${message}\x1b[0m`); +} + +async function test(description, testFn) { + totalTests++; + try { + const result = await testFn(); + if (result === true || result === undefined) { + log(`โœ… ${description}`, 'success'); + passedTests++; + } else { + log(`โŒ ${description}: ${result}`, 'error'); + failedTests++; + } + } catch (error) { + log(`โŒ ${description}: ${error.message}`, 'error'); + if (error.errors) { + console.error('Validation errors:', JSON.stringify(error.errors, null, 2)); + } + failedTests++; + } +} + +// Cache for compiled schemas +const schemaCache = new Map(); + +async function loadAndCompileSchema(schemaPath) { + if (schemaCache.has(schemaPath)) { + return schemaCache.get(schemaPath); + } + + const schemaContent = fs.readFileSync(schemaPath, 'utf8'); + const schema = JSON.parse(schemaContent); + const validate = await ajv.compileAsync(schema); + + schemaCache.set(schemaPath, validate); + return validate; +} + +// Discover extension files (excluding index.json and extension-meta.json) +function discoverExtensionFiles() { + if (!fs.existsSync(EXTENSIONS_DIR)) { + return []; + } + + return fs.readdirSync(EXTENSIONS_DIR) + .filter(file => + file.endsWith('.json') && + file !== 'index.json' && + file !== 'extension-meta.json' + ) + .map(file => path.join(EXTENSIONS_DIR, file)); +} + +async function runTests() { + log('๐Ÿงช Starting Extension Schema Validation Tests'); + log('=============================================='); + + // Test 1: Extension directory exists + await test('Extension directory exists', async () => { + if (!fs.existsSync(EXTENSIONS_DIR)) { + throw new Error(`Extension directory not found: ${EXTENSIONS_DIR}`); + } + return true; + }); + + // Test 2: Extension index exists and is valid JSON + await test('Extension index exists and is valid JSON', async () => { + const indexPath = path.join(EXTENSIONS_DIR, 'index.json'); + if (!fs.existsSync(indexPath)) { + throw new Error('Extension index.json not found'); + } + const content = fs.readFileSync(indexPath, 'utf8'); + JSON.parse(content); // Will throw if invalid JSON + return true; + }); + + // Test 3: Extension index has proper structure + await test('Extension index has proper structure', async () => { + const indexPath = path.join(EXTENSIONS_DIR, 'index.json'); + const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + + if (!index.$schema) { + throw new Error('Extension index missing $schema'); + } + if (!index.$id) { + throw new Error('Extension index missing $id'); + } + if (!index.title) { + throw new Error('Extension index missing title'); + } + if (typeof index.extensions !== 'object') { + throw new Error('Extension index missing extensions object'); + } + if (!index._generated) { + throw new Error('Extension index should have _generated: true marker'); + } + return true; + }); + + // Test 4: Extension meta schema exists and is valid + await test('Extension meta schema exists and is valid', async () => { + const metaPath = path.join(EXTENSIONS_DIR, 'extension-meta.json'); + if (!fs.existsSync(metaPath)) { + throw new Error('Extension extension-meta.json not found'); + } + const content = fs.readFileSync(metaPath, 'utf8'); + const meta = JSON.parse(content); + + // Verify it's a proper schema + if (!meta.$schema) { + throw new Error('Extension meta schema missing $schema'); + } + if (!meta.$id) { + throw new Error('Extension meta schema missing $id'); + } + if (!meta.properties.valid_from) { + throw new Error('Extension meta schema missing valid_from property'); + } + if (!meta.properties.valid_until) { + throw new Error('Extension meta schema missing valid_until property'); + } + return true; + }); + + // Test 5: Agent card extension schema supports extensions_supported field + await test('Agent card extension schema supports extensions_supported', async () => { + const validate = await loadAndCompileSchema( + path.join(SCHEMA_BASE_DIR, 'protocols/adcp-extension.json') + ); + + const validData = { + adcp_version: '2.5.0', + protocols_supported: ['media_buy'], + extensions_supported: ['sustainability', 'custom_vendor'] + }; + + const valid = validate(validData); + if (!valid) { + throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`); + } + return true; + }); + + // Test 6: Agent card validates without extensions_supported (optional field) + await test('Agent card validates without extensions_supported', async () => { + const validate = await loadAndCompileSchema( + path.join(SCHEMA_BASE_DIR, 'protocols/adcp-extension.json') + ); + + const validData = { + adcp_version: '2.5.0', + protocols_supported: ['media_buy', 'creative'] + }; + + const valid = validate(validData); + if (!valid) { + throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`); + } + return true; + }); + + // Test 7: Agent card rejects invalid extension namespace format + await test('Agent card rejects invalid extension namespace format', async () => { + const validate = await loadAndCompileSchema( + path.join(SCHEMA_BASE_DIR, 'protocols/adcp-extension.json') + ); + + const invalidData = { + adcp_version: '2.5.0', + protocols_supported: ['media_buy'], + extensions_supported: ['Invalid-Namespace'] // Must be lowercase + }; + + const valid = validate(invalidData); + if (valid) { + throw new Error('Should have rejected invalid namespace format'); + } + return true; + }); + + // Test 8: Product can include arbitrary extension data (untyped) + await test('Product validates with arbitrary extension data', async () => { + const validate = await loadAndCompileSchema( + path.join(SCHEMA_BASE_DIR, 'core/product.json') + ); + + const product = { + product_id: 'test_product', + name: 'Test Product', + description: 'Test description', + publisher_properties: [{ + publisher_domain: 'example.com', + selection_type: 'all' + }], + format_ids: [{ + agent_url: 'https://creative.adcontextprotocol.org', + id: 'display_300x250' + }], + delivery_type: 'guaranteed', + delivery_measurement: { + provider: 'Test Provider' + }, + pricing_options: [{ + is_fixed: true, + pricing_option_id: 'fixed_cpm', + pricing_model: 'cpm', + rate: 10.00, + currency: 'USD' + }], + ext: { + custom_vendor: { + some_field: 'some_value', + nested: { data: true } + }, + another_vendor: { + config: [1, 2, 3] + } + } + }; + + const valid = validate(product); + if (!valid) { + throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`); + } + return true; + }); + + // Test 9: Extension meta schema validates a sample extension + await test('Extension meta schema validates sample extension structure', async () => { + const validate = await loadAndCompileSchema( + path.join(EXTENSIONS_DIR, 'extension-meta.json') + ); + + const sampleExtension = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/extensions/sustainability.json', + title: 'Sustainability Extension', + description: 'Carbon footprint and green certification data', + valid_from: '2.5', + docs_url: 'https://adcontextprotocol.org/docs/extensions/sustainability', + type: 'object', + properties: { + carbon_kg_per_impression: { type: 'number' }, + certified_green: { type: 'boolean' } + } + }; + + const valid = validate(sampleExtension); + if (!valid) { + throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`); + } + return true; + }); + + // Test 10: Extension meta schema validates extension with valid_until + await test('Extension meta schema validates extension with valid_until', async () => { + const validate = await loadAndCompileSchema( + path.join(EXTENSIONS_DIR, 'extension-meta.json') + ); + + const sampleExtension = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/extensions/deprecated_feature.json', + title: 'Deprecated Feature Extension', + description: 'A feature that was deprecated in version 3.0', + valid_from: '2.0', + valid_until: '3.0', + type: 'object', + properties: { + legacy_field: { type: 'string' } + } + }; + + const valid = validate(sampleExtension); + if (!valid) { + throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`); + } + return true; + }); + + // Test 11: Extension meta schema rejects invalid valid_from format + await test('Extension meta schema rejects invalid valid_from format', async () => { + const validate = await loadAndCompileSchema( + path.join(EXTENSIONS_DIR, 'extension-meta.json') + ); + + const invalidExtension = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/extensions/bad_version.json', + title: 'Bad Version Extension', + description: 'Has invalid version format', + valid_from: '2.5.0', // Should be "2.5" not "2.5.0" + type: 'object', + properties: {} + }; + + const valid = validate(invalidExtension); + if (valid) { + throw new Error('Should have rejected invalid valid_from format (semver patch level not allowed)'); + } + return true; + }); + + // Test 12: Extension meta schema rejects invalid $id pattern + await test('Extension meta schema rejects invalid $id pattern', async () => { + const validate = await loadAndCompileSchema( + path.join(EXTENSIONS_DIR, 'extension-meta.json') + ); + + const invalidExtension = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/core/not_an_extension.json', // Wrong path + title: 'Wrong Path Extension', + description: 'Has invalid $id path', + valid_from: '2.5', + type: 'object', + properties: {} + }; + + const valid = validate(invalidExtension); + if (valid) { + throw new Error('Should have rejected invalid $id pattern'); + } + return true; + }); + + // Test 13: Reserved namespaces should be rejected by build script validation + await test('Reserved namespaces are documented', async () => { + // These namespaces are reserved in scripts/build-schemas.cjs + const RESERVED_NAMESPACES = ['adcp', 'core', 'protocol', 'schema', 'meta', 'ext', 'context']; + + // Verify none of the reserved namespaces exist as extension files + for (const reserved of RESERVED_NAMESPACES) { + const reservedPath = path.join(EXTENSIONS_DIR, `${reserved}.json`); + if (fs.existsSync(reservedPath)) { + throw new Error(`Reserved namespace "${reserved}" should not exist as an extension file`); + } + } + return true; + }); + + // Test 14: Discovered extensions validate against meta schema + const extensionFiles = discoverExtensionFiles(); + if (extensionFiles.length > 0) { + for (const extensionPath of extensionFiles) { + const filename = path.basename(extensionPath); + await test(`Extension file ${filename} validates against meta schema`, async () => { + const validate = await loadAndCompileSchema( + path.join(EXTENSIONS_DIR, 'extension-meta.json') + ); + + const content = fs.readFileSync(extensionPath, 'utf8'); + const extension = JSON.parse(content); + + const valid = validate(extension); + if (!valid) { + throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`); + } + return true; + }); + } + } else { + await test('No extension files to validate (registry is empty)', async () => { + log(' โ„น๏ธ No extension files found - this is expected for initial setup', 'warning'); + return true; + }); + } + + // Summary + log(''); + log('=============================================='); + log(`Tests completed: ${totalTests}`); + log(`โœ… Passed: ${passedTests}`, 'success'); + if (failedTests > 0) { + log(`โŒ Failed: ${failedTests}`, 'error'); + log(''); + process.exit(1); + } else { + log(''); + log('๐ŸŽ‰ All extension schema tests passed!', 'success'); + process.exit(0); + } +} + +// Run tests +runTests().catch(error => { + log(`Fatal error: ${error.message}`, 'error'); + console.error(error); + process.exit(1); +});