Skip to main content
A brand agent is an MCP server that implements brand protocol tasks. DAMs, talent agencies, and brand portals build brand agents to make their data available to buyer agents over AdCP. The agent declares supported_protocols: ["brand"] in get_adcp_capabilities. The specific tasks it implements define its role:
RoleTasksExample
Identity providerget_brand_identityAcme DAM serving brand assets and guidelines
Rights managerget_rights + acquire_rightsPinnacle Agency licensing talent
BothAll threeNova Talent managing identity and rights

Server setup

Every brand agent starts with an MCP server that registers AdCP tasks as tools.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const server = new McpServer({
  name: "acme-brand-agent",
  version: "1.0.0",
});
Register get_adcp_capabilities so buyer agents can discover your supported protocols:
server.tool("get_adcp_capabilities", {}, async () => ({
  content: [{
    type: "text",
    text: JSON.stringify({
      supported_protocols: ["brand"],
      supported_tasks: ["get_brand_identity"],
    }),
  }],
}));

Transport and HTTP setup

Wire the MCP server to an HTTP endpoint so buyer agents can reach it over the network:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => console.log("Brand agent listening on port 3000"));
This gives you a stateless HTTP endpoint at /mcp. For production, add authentication middleware and CORS headers.

Tier 1: identity only

Implement get_brand_identity to serve brand data from your DAM or brand portal.
const FIELDS_ENUM = [
  "description", "industries", "keller_type", "logos", "colors",
  "fonts", "visual_guidelines", "tone", "tagline",
  "voice_synthesis", "assets", "rights",
] as const;

server.tool(
  "get_brand_identity",
  "Returns brand identity data. Core fields are always public.",
  {
    brand_id: z.string().describe("Brand identifier"),
    fields: z.array(z.enum(FIELDS_ENUM)).optional()
      .describe("Sections to include. Omit for all authorized sections."),
    use_case: z.string().optional()
      .describe("Intended use case — agent tailors content accordingly"),
  },
  async ({ brand_id, fields, use_case }, extra) => {
    const brand = await loadBrand(brand_id);
    if (!brand) {
      return {
        content: [{ type: "text", text: JSON.stringify({
          errors: [{ code: "brand_not_found", message: `No brand with id '${brand_id}'` }],
        }) }],
        isError: true,
      };
    }

    const isAuthorized = await checkLinkedAccount(extra);
    const response = buildIdentityResponse(brand, { fields, use_case, isAuthorized });

    return { content: [{ type: "text", text: JSON.stringify(response) }] };
  }
);

Public vs authorized data

Every get_brand_identity response includes the public baseline: brand_id, house, names, description, industries, keller_type, basic logos, and tagline. No authentication required. Authorized callers — linked via sync_accounts — get deeper data on top of that baseline: high-res assets, voice synthesis configs, tone guidelines, and rights availability.
function buildIdentityResponse(brand, { fields, use_case, isAuthorized }) {
  // Core fields are always returned
  const response = {
    brand_id: brand.id,
    house: brand.house,
    names: brand.names,
  };

  // Determine which sections to include
  const publicFields = ["description", "industries", "keller_type", "logos", "tagline"];
  const authorizedFields = ["colors", "fonts", "visual_guidelines", "tone",
                            "voice_synthesis", "assets", "rights"];

  const requested = fields ?? [...publicFields, ...authorizedFields];
  const withheld = [];

  for (const field of requested) {
    if (publicFields.includes(field)) {
      response[field] = brand[field];
    } else if (isAuthorized) {
      response[field] = brand[field];
    } else {
      withheld.push(field);
    }
  }

  // Signal what's behind auth
  if (withheld.length > 0) {
    response.available_fields = withheld;
  }

  return response;
}
When a public caller requests fields: ["logos", "tone"], they get logos but not tone. The response includes available_fields: ["tone"] so the caller knows what linking their account would unlock.

Tier 2: rights only

Add get_rights and acquire_rights for rights discovery and licensing. This is the path for talent agencies and music sync platforms.
server.tool(
  "get_rights",
  "Search for licensable rights with pricing",
  {
    query: z.string().describe("Natural language description of desired rights"),
    uses: z.array(z.string()).describe("Rights uses: likeness, voice, name, endorsement"),
    buyer_brand: z.object({
      domain: z.string(),
      brand_id: z.string().optional(),
    }).optional(),
    brand_id: z.string().optional(),
    include_excluded: z.boolean().optional(),
  },
  async ({ query, uses, buyer_brand, brand_id, include_excluded }) => {
    const matches = await searchRights({ query, uses, brand_id });

    // Filter by buyer compatibility when buyer_brand is provided
    const { rights, excluded } = buyer_brand
      ? await filterByBuyerCompatibility(matches, buyer_brand)
      : { rights: matches, excluded: [] };

    const response = { rights };
    if (include_excluded) response.excluded = excluded;

    return { content: [{ type: "text", text: JSON.stringify(response) }] };
  }
);
acquire_rights follows the same pattern — accept a rights_id and pricing_option_id from get_rights, clear against existing contracts, and return terms with generation credentials. The response includes an authenticated approval_webhook (using push-notification-config) so buyers can submit creatives for review. See the acquire_rights task reference for the full schema.

Confidential brand rules

Brands often have rules they cannot disclose — public figure policies, internal exclusion lists, legal restrictions. Your agent evaluates these internally and returns a sanitized reason without revealing the rule itself. The protocol supports this through a simple convention: if the rejection includes suggestions, the buyer can fix the problem. If it doesn’t, the rejection is final and the buyer should move on.
async function evaluateAcquisition(request, talent) {
  // Confidential rules — buyer never sees these
  const confidentialResult = await evaluateConfidentialRules(request, talent);
  if (confidentialResult.blocked) {
    return {
      status: "rejected",
      reason: confidentialResult.sanitized_reason,
      // No suggestions — this is final, nothing the buyer can change
    };
  }

  // Actionable rejection — buyer can adjust their request
  const exclusivityConflict = await checkExclusivity(request, talent);
  if (exclusivityConflict) {
    return {
      status: "rejected",
      reason: `Exclusive conflict in ${exclusivityConflict.country} through ${exclusivityConflict.end_date}`,
      suggestions: [
        `Available in ${exclusivityConflict.alternative_countries.join(", ")}`,
        `Available after ${exclusivityConflict.end_date}`,
      ],
    };
  }

  // Approved — proceed with terms
  return { status: "acquired", /* ... */ };
}
The same pattern applies to get_rights exclusions: include suggestions on excluded results when the buyer can adjust their query (different market, different dates), omit them when the exclusion is non-negotiable.

Defending against probing

A determined buyer agent could call get_rights with slight variations — different brands, industries, countries — to map out your confidential rules through the pattern of rejections. Mitigate this by:
  • Using consistent generic language across similar confidential rejections. If three different rules all produce “This conflicts with our talent lifestyle guidelines,” the buyer learns nothing from repeated attempts.
  • Returning the same reason regardless of which specific rule triggered it. Don’t vary the wording based on the rule — that creates a side channel.
  • Rate limiting discovery calls per buyer. Track query volume per buyer_brand and return progressively less specific reasons after a threshold.
The exclusivity_status.existing_exclusives field in get_rights responses deserves special care. Populating it with specific deal terms (“exclusive with Acme Sports in NL through Q3”) reveals competitive intelligence. Use vague descriptions (“exclusive commitment in this category”) or omit the field entirely when confidentiality is a concern.

Field selection and use case

The fields parameter lets callers request only the sections they need. Implement this efficiently — avoid loading expensive data (asset catalogs, voice configs) when not requested:
async function loadBrandData(brand_id, fields) {
  const brand = await db.getBrandCore(brand_id);
  if (!fields || fields.includes("assets")) {
    brand.assets = await db.getBrandAssets(brand_id);
  }
  if (!fields || fields.includes("voice_synthesis")) {
    brand.voice_synthesis = await voiceProvider.getConfig(brand_id);
  }
  return brand;
}
The use_case parameter is advisory — it tailors content within returned sections but does not override fields. A "likeness" use case prioritizes action photos in the logos section; a "creative_production" use case prioritizes vector logos and brand marks.

Multi-tenancy

A single MCP endpoint can serve multiple brands. The brand_id parameter in every request disambiguates which brand the caller is asking about.
// One agent, many brands
const brands = {
  "emma_torres": { house: { domain: "pinnacleagency.com", name: "Pinnacle Agency" }, ... },
  "kai_nakamura": { house: { domain: "pinnacleagency.com", name: "Pinnacle Agency" }, ... },
};

async function loadBrand(brand_id) {
  return brands[brand_id] ?? null;
}
Each brand in your roster should also appear in your brand.json file’s brands array so buyer agents can discover them before making MCP calls.

Account linking

Buyers establish authorization by calling sync_accounts on your agent. After linking, their subsequent get_brand_identity requests are recognized as authorized. Implement the accounts protocol to support this. The linked account is identified by the caller’s credentials in the MCP transport — you do not need to pass account IDs in brand protocol requests.

Extracting caller identity

async function checkLinkedAccount(extra: any): Promise<boolean> {
  // The caller's identity comes from your auth middleware.
  // After sync_accounts links a buyer, store their credentials
  // and check them on subsequent requests.
  const sessionId = extra?.sessionId;
  if (!sessionId) return false;
  return await db.isLinkedAccount(sessionId);
}
How you identify callers depends on your authentication setup. The MCP transport provides session information; your auth middleware maps that to a linked account. See the authentication guide for patterns.

Rights and creative integration

After a buyer acquires rights through acquire_rights, they receive generation_credentials and a rights_constraint. These connect the rights grant to creative production.

From the brand agent’s perspective

When implementing acquire_rights, return both pieces in the response:
// In your acquire_rights handler, after approval:
const response = {
  status: "acquired",
  rights_id: "rgt_dj_001",
  terms: { /* ... pricing, dates, restrictions */ },
  generation_credentials: [
    {
      provider: "midjourney",
      rights_key: "rk_dj_likeness_2026_abc",
      uses: ["likeness"],
      expires_at: "2026-06-15T00:00:00Z",
    },
  ],
  rights_constraint: {
    rights_id: "rgt_dj_001",
    rights_agent: { url: "https://rights.lotientertainment.com/mcp", id: "loti_entertainment" },
    valid_from: "2026-03-15T00:00:00Z",
    valid_until: "2026-06-15T23:59:59Z",
    uses: ["likeness"],
    countries: ["NL"],
    impression_cap: 100000,
    approval_status: "approved",
  },
};

How buyers use these

The buyer’s orchestrator passes generation_credentials to their creative agent, which uses them with the AI provider. The rights_constraint is embedded in the creative manifest’s rights array — it travels with the creative through the supply chain so every system in the chain knows the usage terms.
// Buyer-side: passing rights to a creative agent
const creative = await creativeAgent.callTool({
  name: "build_creative",
  arguments: {
    brand: { domain: "bistro-oranje.nl" },
    format_id: { agent_url: "https://ads.example.com", id: "video_social_1080x1920" },
    brief: "15-second vertical video featuring Daan Janssen endorsing Bistro Oranje",
    generation_credentials: acquireResponse.generation_credentials,
    rights: [acquireResponse.rights_constraint],
  },
});
The creative agent uses the generation_credentials to authenticate with the AI provider (Midjourney, ElevenLabs, etc.) and produces the asset. The rights array becomes part of the creative manifest metadata — downstream systems (ad servers, verification vendors) can inspect it to confirm the creative is properly licensed. For the full creative manifest specification, see creative manifests.

Testing

Use the validate_brand_agent MCP tool to verify your agent is reachable and responding correctly. For automated testing during development, use the MCP SDK’s in-memory transport:
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);

const result = await client.callTool({
  name: "get_brand_identity",
  arguments: { brand_id: "emma_torres" },
});
Key things to verify: core fields returned for public callers, deeper data for authorized callers, available_fields lists withheld sections, and brand_not_found errors for invalid IDs.

Deployment checklist

  • brand.json hosted at /.well-known/brand.json with brand_agent.url pointing to your MCP endpoint
  • get_adcp_capabilities returns supported_protocols: ["brand"]
  • get_brand_identity returns core fields for public callers
  • get_brand_identity returns deeper data for authorized callers
  • available_fields correctly lists withheld sections
  • Error responses use the errors array format
  • If implementing rights: get_rights returns pricing options and acquire_rights returns terms