From 24ff0ae47a5c4dffa5cad18991a8e99893e5e4be Mon Sep 17 00:00:00 2001 From: Manik Date: Wed, 18 Mar 2026 09:44:04 +0530 Subject: [PATCH 1/2] feat: add Meta Ads integration (v2) Implements Meta Ads as an IntegrationDefinitionV2 following the existing integrations v2 patterns (Codeberg, Resend, etc.). - OAuth2 auth via Facebook v19.0 dialog/token endpoints (scopes: ads_read, ads_management, read_insights) - MCP CLI tools: list_ad_accounts, list_campaigns, list_ad_sets, list_ads, get_insights (with pagination cursor support) - account-create.ts fetches /me to store user name/id/email - scripts/register.ts for database registration - Meta Ads SVG icon + icon-utils.tsx mapping Closes #500 Co-Authored-By: Claude Sonnet 4.6 --- apps/webapp/app/components/icon-utils.tsx | 2 + apps/webapp/app/components/icons/meta-ads.tsx | 39 ++ integrations/meta-ads/.gitignore | 3 + integrations/meta-ads/package.json | 57 +++ integrations/meta-ads/scripts/register.ts | 71 +++ integrations/meta-ads/src/account-create.ts | 46 ++ integrations/meta-ads/src/index.ts | 84 ++++ integrations/meta-ads/src/mcp.ts | 1 + integrations/meta-ads/src/mcp/index.ts | 450 ++++++++++++++++++ integrations/meta-ads/tsconfig.json | 31 ++ 10 files changed, 784 insertions(+) create mode 100644 apps/webapp/app/components/icons/meta-ads.tsx create mode 100644 integrations/meta-ads/.gitignore create mode 100644 integrations/meta-ads/package.json create mode 100644 integrations/meta-ads/scripts/register.ts create mode 100644 integrations/meta-ads/src/account-create.ts create mode 100644 integrations/meta-ads/src/index.ts create mode 100644 integrations/meta-ads/src/mcp.ts create mode 100644 integrations/meta-ads/src/mcp/index.ts create mode 100644 integrations/meta-ads/tsconfig.json diff --git a/apps/webapp/app/components/icon-utils.tsx b/apps/webapp/app/components/icon-utils.tsx index 1e7042d36..7ad9ea34a 100644 --- a/apps/webapp/app/components/icon-utils.tsx +++ b/apps/webapp/app/components/icon-utils.tsx @@ -27,6 +27,7 @@ import { Todoist } from "./icons/todoist"; import { Ghost } from "./icons/ghost"; import { Fireflies } from "./icons/fireflies"; import { Whatsapp } from "./icons/whatsapp"; +import { MetaAds } from "./icons/meta-ads"; import { Metabase } from "./icons/metabase"; import { Resend } from "./icons/resend"; import { Ynab } from "./icons/ynab"; @@ -77,6 +78,7 @@ export const ICON_MAPPING = { ghost: Ghost, fireflies: Fireflies, whatsapp: Whatsapp, + "meta-ads": MetaAds, metabase: Metabase, resend: Resend, ynab: Ynab, diff --git a/apps/webapp/app/components/icons/meta-ads.tsx b/apps/webapp/app/components/icons/meta-ads.tsx new file mode 100644 index 000000000..8e78839c9 --- /dev/null +++ b/apps/webapp/app/components/icons/meta-ads.tsx @@ -0,0 +1,39 @@ +import type { IconProps } from './types'; + +export function MetaAds({ size = 18, className }: IconProps) { + return ( + + + + + + + + + ); +} diff --git a/integrations/meta-ads/.gitignore b/integrations/meta-ads/.gitignore new file mode 100644 index 000000000..458e8b864 --- /dev/null +++ b/integrations/meta-ads/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +bin diff --git a/integrations/meta-ads/package.json b/integrations/meta-ads/package.json new file mode 100644 index 000000000..01e267006 --- /dev/null +++ b/integrations/meta-ads/package.json @@ -0,0 +1,57 @@ +{ + "name": "@core/meta-ads", + "version": "0.1.0", + "description": "Meta Ads extension for CORE", + "main": "./bin/index.js", + "module": "./bin/index.mjs", + "type": "module", + "files": [ + "meta-ads", + "bin" + ], + "bin": { + "meta-ads": "./bin/index.js" + }, + "scripts": { + "build": "rimraf dist && bun build src/index.ts --outfile dist/index.js --target node --minify", + "lint": "eslint --ext js,ts,tsx src/ --fix", + "prettier": "prettier --config .prettierrc --write .", + "copy:spec": "cp spec.json bin/" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.26.0", + "@prisma/client": "^7.4.0", + "@types/node": "^18.0.20", + "@types/pg": "^8.10.9", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-unused-imports": "^2.0.0", + "ncc": "0.3.6", + "prettier": "^3.4.2", + "rimraf": "^3.0.2", + "tslib": "^2.8.1", + "tsup": "^8.0.1", + "tsx": "4.20.6", + "typescript": "^4.7.2" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@core/database": "file:../../packages/database", + "@modelcontextprotocol/sdk": "^0.4.0", + "@redplanethq/sdk": "0.1.9", + "axios": "^1.7.9", + "commander": "^12.0.0", + "pg": "^8.11.3", + "react-query": "^3.39.3", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.1" + } +} diff --git a/integrations/meta-ads/scripts/register.ts b/integrations/meta-ads/scripts/register.ts new file mode 100644 index 000000000..26ffe8f29 --- /dev/null +++ b/integrations/meta-ads/scripts/register.ts @@ -0,0 +1,71 @@ +import pg from 'pg'; +const { Client } = pg; + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const client = new Client({ + connectionString, + }); + + const spec = { + name: 'Meta Ads', + key: 'meta-ads', + description: + 'Connect your Meta Ads account to manage campaigns, ad sets, ads, and retrieve performance insights across Facebook and Instagram.', + icon: 'meta-ads', + mcp: { + type: 'cli', + }, + auth: { + OAuth2: { + authorization_url: 'https://www.facebook.com/v19.0/dialog/oauth', + token_url: 'https://graph.facebook.com/v19.0/oauth/access_token', + scopes: ['ads_read', 'ads_management', 'read_insights'], + scope_separator: ',', + }, + }, + }; + + try { + await client.connect(); + + await client.query( + ` + INSERT INTO core."IntegrationDefinitionV2" ("id", "name", "slug", "description", "icon", "spec", "config", "version", "url", "updatedAt", "createdAt") + VALUES (gen_random_uuid(), 'Meta Ads', 'meta-ads', $4, 'meta-ads', $1, $2, '0.1.0', $3, NOW(), NOW()) + ON CONFLICT (name) DO UPDATE SET + "slug" = EXCLUDED."slug", + "description" = EXCLUDED."description", + "icon" = EXCLUDED."icon", + "spec" = EXCLUDED."spec", + "config" = EXCLUDED."config", + "version" = EXCLUDED."version", + "url" = EXCLUDED."url", + "updatedAt" = NOW() + RETURNING *; + `, + [ + JSON.stringify(spec), + JSON.stringify({ + clientId: process.env.META_ADS_CLIENT_ID, + clientSecret: process.env.META_ADS_CLIENT_SECRET, + }), + '../../integrations/meta-ads/bin/index.cjs', + 'Connect your Meta Ads account to manage campaigns, ad sets, ads, and retrieve performance insights across Facebook and Instagram.', + ], + ); + + console.log('Meta Ads integration registered successfully in the database.'); + } catch (error) { + console.error('Error registering Meta Ads integration:', error); + } finally { + await client.end(); + } +} + +main().catch(console.error); diff --git a/integrations/meta-ads/src/account-create.ts b/integrations/meta-ads/src/account-create.ts new file mode 100644 index 000000000..63101eb74 --- /dev/null +++ b/integrations/meta-ads/src/account-create.ts @@ -0,0 +1,46 @@ +import axios from 'axios'; + +const META_API_BASE = 'https://graph.facebook.com/v19.0'; + +export async function integrationCreate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any, +) { + const { oauthResponse } = data; + const accessToken = oauthResponse.access_token; + + const userResponse = await axios.get(`${META_API_BASE}/me`, { + params: { + access_token: accessToken, + fields: 'id,name,email', + }, + }); + + const user = userResponse.data; + + const integrationConfiguration = { + access_token: accessToken, + token_type: oauthResponse.token_type || 'Bearer', + scope: oauthResponse.scope, + userId: user.id, + userName: user.name, + userEmail: user.email, + }; + + return [ + { + type: 'account', + data: { + settings: { + userName: user.name, + userEmail: user.email, + }, + accountId: user.id, + config: { + ...integrationConfiguration, + mcp: { tokens: { access_token: accessToken } }, + }, + }, + }, + ]; +} diff --git a/integrations/meta-ads/src/index.ts b/integrations/meta-ads/src/index.ts new file mode 100644 index 000000000..bd621c6be --- /dev/null +++ b/integrations/meta-ads/src/index.ts @@ -0,0 +1,84 @@ +import { integrationCreate } from './account-create'; + +import { + IntegrationCLI, + IntegrationEventPayload, + IntegrationEventType, + Spec, +} from '@redplanethq/sdk'; +import { callTool, getTools } from './mcp'; +import { fileURLToPath } from 'url'; + +export async function run(eventPayload: IntegrationEventPayload) { + switch (eventPayload.event) { + case IntegrationEventType.SETUP: + return await integrationCreate(eventPayload.eventBody); + + case IntegrationEventType.GET_TOOLS: { + try { + const tools = await getTools(); + return tools; + } catch (e: any) { + return { message: `Error ${e.message}` }; + } + } + + case IntegrationEventType.CALL_TOOL: { + const integrationDefinition = eventPayload.integrationDefinition; + + if (!integrationDefinition) { + return null; + } + + const config = eventPayload.config as any; + const { name, arguments: args } = eventPayload.eventBody; + + const result = await callTool(name, args, config); + + return result; + } + + default: + return { message: `The event payload type is ${eventPayload.event}` }; + } +} + +class MetaAdsCLI extends IntegrationCLI { + constructor() { + super('meta-ads', '0.1.0'); + } + + protected async handleEvent(eventPayload: IntegrationEventPayload): Promise { + return await run(eventPayload); + } + + protected async getSpec(): Promise { + return { + name: 'Meta Ads', + key: 'meta-ads', + description: + 'Connect your Meta Ads account to manage campaigns, ad sets, ads, and retrieve performance insights across Facebook and Instagram.', + icon: 'meta-ads', + mcp: { + type: 'cli', + }, + auth: { + OAuth2: { + authorization_url: 'https://www.facebook.com/v19.0/dialog/oauth', + token_url: 'https://graph.facebook.com/v19.0/oauth/access_token', + scopes: ['ads_read', 'ads_management', 'read_insights'], + scope_separator: ',', + }, + }, + }; + } +} + +function main() { + const metaAdsCLI = new MetaAdsCLI(); + metaAdsCLI.parse(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/integrations/meta-ads/src/mcp.ts b/integrations/meta-ads/src/mcp.ts new file mode 100644 index 000000000..6d8405e9e --- /dev/null +++ b/integrations/meta-ads/src/mcp.ts @@ -0,0 +1 @@ +export { getTools, callTool } from './mcp/index.js'; diff --git a/integrations/meta-ads/src/mcp/index.ts b/integrations/meta-ads/src/mcp/index.ts new file mode 100644 index 000000000..2453d9584 --- /dev/null +++ b/integrations/meta-ads/src/mcp/index.ts @@ -0,0 +1,450 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import axios, { AxiosInstance } from 'axios'; + +const META_API_BASE = 'https://graph.facebook.com/v19.0'; + +function createMetaClient(accessToken: string): AxiosInstance { + return axios.create({ + baseURL: META_API_BASE, + params: { + access_token: accessToken, + }, + }); +} + +// ============================================================================ +// SCHEMA DEFINITIONS +// ============================================================================ + +const ListAdAccountsSchema = z.object({ + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const ListCampaignsSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + status_filter: z + .array(z.enum(['ACTIVE', 'PAUSED', 'DELETED', 'ARCHIVED'])) + .optional() + .describe('Filter campaigns by status. Defaults to all statuses.'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const ListAdSetsSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + campaign_id: z.string().optional().describe('Filter ad sets by campaign ID'), + status_filter: z + .array(z.enum(['ACTIVE', 'PAUSED', 'DELETED', 'ARCHIVED'])) + .optional() + .describe('Filter ad sets by status. Defaults to all statuses.'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const ListAdsSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + campaign_id: z.string().optional().describe('Filter ads by campaign ID'), + adset_id: z.string().optional().describe('Filter ads by ad set ID'), + status_filter: z + .array(z.enum(['ACTIVE', 'PAUSED', 'DELETED', 'ARCHIVED'])) + .optional() + .describe('Filter ads by status. Defaults to all statuses.'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const GetInsightsSchema = z.object({ + object_id: z + .string() + .describe( + 'ID of the object to get insights for. Can be an ad account (act_xxx), campaign, ad set, or ad ID.', + ), + level: z + .enum(['account', 'campaign', 'adset', 'ad']) + .optional() + .default('account') + .describe('Level of aggregation for the insights'), + date_preset: z + .enum([ + 'today', + 'yesterday', + 'this_month', + 'last_month', + 'this_quarter', + 'last_3d', + 'last_7d', + 'last_14d', + 'last_28d', + 'last_30d', + 'last_90d', + 'last_year', + 'this_year', + ]) + .optional() + .default('last_30d') + .describe('Predefined date range for the insights'), + time_range: z + .object({ + since: z.string().describe('Start date in YYYY-MM-DD format'), + until: z.string().describe('End date in YYYY-MM-DD format'), + }) + .optional() + .describe('Custom date range. Overrides date_preset if provided.'), + fields: z + .array(z.string()) + .optional() + .default([ + 'impressions', + 'clicks', + 'spend', + 'reach', + 'cpm', + 'cpc', + 'ctr', + 'conversions', + 'cost_per_conversion', + ]) + .describe( + 'Metrics to retrieve. Common fields: impressions, clicks, spend, reach, cpm, cpc, ctr, conversions, cost_per_conversion.', + ), + limit: z.number().optional().default(25).describe('Number of results per page'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +// ============================================================================ +// TOOL EXPORT FUNCTION +// ============================================================================ + +export async function getTools() { + return [ + { + name: 'list_ad_accounts', + description: + 'List all ad accounts accessible to the authenticated user. Returns account IDs, names, currency, timezone, and account status.', + inputSchema: zodToJsonSchema(ListAdAccountsSchema), + }, + { + name: 'list_campaigns', + description: + 'List campaigns for a given ad account. Returns campaign IDs, names, objectives, status, budget, and schedule.', + inputSchema: zodToJsonSchema(ListCampaignsSchema), + }, + { + name: 'list_ad_sets', + description: + 'List ad sets for a given ad account, optionally filtered by campaign. Returns ad set IDs, names, targeting, budget, schedule, and status.', + inputSchema: zodToJsonSchema(ListAdSetsSchema), + }, + { + name: 'list_ads', + description: + 'List ads for a given ad account, optionally filtered by campaign or ad set. Returns ad IDs, names, creative info, and status.', + inputSchema: zodToJsonSchema(ListAdsSchema), + }, + { + name: 'get_insights', + description: + 'Retrieve performance insights (impressions, clicks, spend, reach, CTR, CPC, conversions, etc.) for an ad account, campaign, ad set, or ad over a specified time range.', + inputSchema: zodToJsonSchema(GetInsightsSchema), + }, + ]; +} + +// ============================================================================ +// TOOL CALL HANDLERS +// ============================================================================ + +export async function callTool( + name: string, + args: Record, + config: Record, +) { + const accessToken = config?.access_token || config?.mcp?.tokens?.access_token; + const client = createMetaClient(accessToken); + + try { + switch (name) { + case 'list_ad_accounts': { + const { limit, after } = ListAdAccountsSchema.parse(args); + const params: Record = { + fields: 'id,name,currency,timezone_name,account_status,business', + limit, + }; + if (after) params.after = after; + + const response = await client.get('/me/adaccounts', { params }); + const accounts = response.data.data || []; + const paging = response.data.paging; + + const formatted = accounts + .map((acc: any) => { + const statusMap: Record = { + 1: 'ACTIVE', + 2: 'DISABLED', + 3: 'UNSETTLED', + 7: 'PENDING_REVIEW', + 9: 'IN_GRACE_PERIOD', + 101: 'TEMPORARILY_UNAVAILABLE', + 100: 'PENDING_CLOSURE', + }; + const status = statusMap[acc.account_status] ?? String(acc.account_status); + return `ID: ${acc.id} | Name: ${acc.name} +Currency: ${acc.currency} | Timezone: ${acc.timezone_name} +Status: ${status}${acc.business ? ` | Business: ${acc.business.name}` : ''}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Ad Accounts (${accounts.length}):\n\n${formatted}${pagination}` + : 'No ad accounts found.', + }, + ], + }; + } + + case 'list_campaigns': { + const { ad_account_id, status_filter, limit, after } = ListCampaignsSchema.parse(args); + const params: Record = { + fields: + 'id,name,objective,status,effective_status,daily_budget,lifetime_budget,start_time,stop_time', + limit, + }; + if (status_filter && status_filter.length > 0) { + params.effective_status = JSON.stringify(status_filter); + } + if (after) params.after = after; + + const response = await client.get(`/${ad_account_id}/campaigns`, { params }); + const campaigns = response.data.data || []; + const paging = response.data.paging; + + const formatted = campaigns + .map((c: any) => { + const budget = c.daily_budget + ? `Daily: ${(parseInt(c.daily_budget) / 100).toFixed(2)}` + : c.lifetime_budget + ? `Lifetime: ${(parseInt(c.lifetime_budget) / 100).toFixed(2)}` + : 'No budget set'; + return `ID: ${c.id} | Name: ${c.name} +Objective: ${c.objective} | Status: ${c.effective_status} +Budget: ${budget}${c.start_time ? ` | Start: ${c.start_time}` : ''}${c.stop_time ? ` | End: ${c.stop_time}` : ''}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Campaigns for ${ad_account_id} (${campaigns.length}):\n\n${formatted}${pagination}` + : `No campaigns found for ${ad_account_id}.`, + }, + ], + }; + } + + case 'list_ad_sets': { + const { ad_account_id, campaign_id, status_filter, limit, after } = + ListAdSetsSchema.parse(args); + const params: Record = { + fields: + 'id,name,campaign_id,status,effective_status,daily_budget,lifetime_budget,targeting,start_time,end_time,optimization_goal,billing_event', + limit, + }; + if (campaign_id) params.campaign_id = campaign_id; + if (status_filter && status_filter.length > 0) { + params.effective_status = JSON.stringify(status_filter); + } + if (after) params.after = after; + + const response = await client.get(`/${ad_account_id}/adsets`, { params }); + const adsets = response.data.data || []; + const paging = response.data.paging; + + const formatted = adsets + .map((s: any) => { + const budget = s.daily_budget + ? `Daily: ${(parseInt(s.daily_budget) / 100).toFixed(2)}` + : s.lifetime_budget + ? `Lifetime: ${(parseInt(s.lifetime_budget) / 100).toFixed(2)}` + : 'No budget set'; + return `ID: ${s.id} | Name: ${s.name} +Campaign ID: ${s.campaign_id} | Status: ${s.effective_status} +Budget: ${budget} | Optimization: ${s.optimization_goal} | Billing: ${s.billing_event}${s.start_time ? ` | Start: ${s.start_time}` : ''}${s.end_time ? ` | End: ${s.end_time}` : ''}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Ad Sets for ${ad_account_id} (${adsets.length}):\n\n${formatted}${pagination}` + : `No ad sets found for ${ad_account_id}.`, + }, + ], + }; + } + + case 'list_ads': { + const { ad_account_id, campaign_id, adset_id, status_filter, limit, after } = + ListAdsSchema.parse(args); + const params: Record = { + fields: + 'id,name,campaign_id,adset_id,status,effective_status,creative{id,name,thumbnail_url},created_time,updated_time', + limit, + }; + if (campaign_id) params.campaign_id = campaign_id; + if (adset_id) params.adset_id = adset_id; + if (status_filter && status_filter.length > 0) { + params.effective_status = JSON.stringify(status_filter); + } + if (after) params.after = after; + + const response = await client.get(`/${ad_account_id}/ads`, { params }); + const ads = response.data.data || []; + const paging = response.data.paging; + + const formatted = ads + .map((ad: any) => { + return `ID: ${ad.id} | Name: ${ad.name} +Campaign ID: ${ad.campaign_id} | Ad Set ID: ${ad.adset_id} +Status: ${ad.effective_status}${ad.creative ? ` | Creative: ${ad.creative.name || ad.creative.id}` : ''} +Created: ${ad.created_time} | Updated: ${ad.updated_time}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Ads for ${ad_account_id} (${ads.length}):\n\n${formatted}${pagination}` + : `No ads found for ${ad_account_id}.`, + }, + ], + }; + } + + case 'get_insights': { + const { object_id, level, date_preset, time_range, fields, limit, after } = + GetInsightsSchema.parse(args); + const params: Record = { + fields: fields.join(','), + level, + limit, + }; + if (time_range) { + params.time_range = JSON.stringify(time_range); + } else { + params.date_preset = date_preset; + } + if (after) params.after = after; + + const response = await client.get(`/${object_id}/insights`, { params }); + const insights = response.data.data || []; + const paging = response.data.paging; + + if (insights.length === 0) { + return { + content: [ + { + type: 'text', + text: `No insights data found for ${object_id} in the requested time range.`, + }, + ], + }; + } + + const formatted = insights + .map((row: any) => { + const lines = [`Period: ${row.date_start} to ${row.date_stop}`]; + if (row.campaign_name) lines.push(`Campaign: ${row.campaign_name}`); + if (row.adset_name) lines.push(`Ad Set: ${row.adset_name}`); + if (row.ad_name) lines.push(`Ad: ${row.ad_name}`); + + const metricLines: string[] = []; + if (row.impressions) metricLines.push(`Impressions: ${parseInt(row.impressions).toLocaleString()}`); + if (row.reach) metricLines.push(`Reach: ${parseInt(row.reach).toLocaleString()}`); + if (row.clicks) metricLines.push(`Clicks: ${parseInt(row.clicks).toLocaleString()}`); + if (row.spend) metricLines.push(`Spend: $${parseFloat(row.spend).toFixed(2)}`); + if (row.cpm) metricLines.push(`CPM: $${parseFloat(row.cpm).toFixed(2)}`); + if (row.cpc) metricLines.push(`CPC: $${parseFloat(row.cpc).toFixed(2)}`); + if (row.ctr) metricLines.push(`CTR: ${parseFloat(row.ctr).toFixed(2)}%`); + if (row.conversions) { + const convTotal = Array.isArray(row.conversions) + ? row.conversions.reduce((sum: number, c: any) => sum + parseInt(c.value || '0'), 0) + : parseInt(row.conversions); + metricLines.push(`Conversions: ${convTotal.toLocaleString()}`); + } + if (row.cost_per_conversion) { + const cpp = Array.isArray(row.cost_per_conversion) + ? row.cost_per_conversion[0]?.value + : row.cost_per_conversion; + if (cpp) metricLines.push(`Cost/Conv: $${parseFloat(cpp).toFixed(2)}`); + } + + // Include any other requested fields not handled above + const handledFields = new Set([ + 'date_start', 'date_stop', 'campaign_name', 'adset_name', 'ad_name', + 'impressions', 'reach', 'clicks', 'spend', 'cpm', 'cpc', 'ctr', + 'conversions', 'cost_per_conversion', + ]); + for (const [key, val] of Object.entries(row)) { + if (!handledFields.has(key) && val !== undefined && val !== null) { + metricLines.push(`${key}: ${val}`); + } + } + + return [...lines, ...metricLines].join('\n'); + }) + .join('\n\n---\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: `Insights for ${object_id} (${insights.length} rows):\n\n${formatted}${pagination}`, + }, + ], + }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error: any) { + const apiError = error.response?.data?.error; + const errorMessage = apiError + ? `${apiError.message} (code: ${apiError.code}, type: ${apiError.type})` + : error.message; + throw new Error(`Meta Ads API Error: ${errorMessage}`); + } +} diff --git a/integrations/meta-ads/tsconfig.json b/integrations/meta-ads/tsconfig.json new file mode 100644 index 000000000..2ec31262b --- /dev/null +++ b/integrations/meta-ads/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["dom", "dom.iterable", "esnext"], + "baseUrl": ".", + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "dist", "scripts"] +} From 7511b94d5152a8bfc1b4f10ac27c96909b3ca34f Mon Sep 17 00:00:00 2001 From: Manik Date: Mon, 23 Mar 2026 10:16:34 +0530 Subject: [PATCH 2/2] feat: add remaining Meta Ads MCP tools Adds 44 new tools to the Meta Ads integration covering creatives, campaign/ad CRUD, business manager edges, audience sharing, targeting search, analytics, and media upload. Co-Authored-By: Claude Sonnet 4.6 --- integrations/meta-ads/src/mcp/index.ts | 1828 +++++++++++++++++++++--- 1 file changed, 1609 insertions(+), 219 deletions(-) diff --git a/integrations/meta-ads/src/mcp/index.ts b/integrations/meta-ads/src/mcp/index.ts index 2453d9584..c2a9280f0 100644 --- a/integrations/meta-ads/src/mcp/index.ts +++ b/integrations/meta-ads/src/mcp/index.ts @@ -119,6 +119,391 @@ const GetInsightsSchema = z.object({ after: z.string().optional().describe('Pagination cursor for next page'), }); +const GetUserSchema = z.object({ + fields: z + .array(z.string()) + .optional() + .default(['id', 'name', 'email']) + .describe('Fields to retrieve for the user'), +}); + +const GetMetaObjectSchema = z.object({ + object_id: z.string().describe('ID of the Meta object to retrieve'), + fields: z + .array(z.string()) + .optional() + .describe('Fields to retrieve. Defaults to all default fields for the object type.'), +}); + +const GetPageAccountsSchema = z.object({ + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const GetVideoSchema = z.object({ + video_id: z.string().describe('ID of the video to retrieve'), + fields: z + .array(z.string()) + .optional() + .default(['id', 'title', 'description', 'source', 'permalink_url', 'length', 'thumbnails']) + .describe('Fields to retrieve for the video'), +}); + +const CreateCampaignSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + name: z.string().describe('Name of the campaign'), + objective: z + .enum([ + 'OUTCOME_AWARENESS', + 'OUTCOME_TRAFFIC', + 'OUTCOME_ENGAGEMENT', + 'OUTCOME_LEADS', + 'OUTCOME_APP_PROMOTION', + 'OUTCOME_SALES', + ]) + .describe('Campaign objective'), + status: z + .enum(['ACTIVE', 'PAUSED']) + .optional() + .default('PAUSED') + .describe('Initial campaign status'), + special_ad_categories: z + .array(z.enum(['NONE', 'EMPLOYMENT', 'HOUSING', 'CREDIT', 'ISSUES_ELECTIONS_POLITICS'])) + .optional() + .default(['NONE']) + .describe('Special ad categories if applicable'), + daily_budget: z + .number() + .optional() + .describe('Daily budget in cents (e.g. 1000 = $10.00). Cannot be used with lifetime_budget.'), + lifetime_budget: z + .number() + .optional() + .describe( + 'Lifetime budget in cents (e.g. 10000 = $100.00). Cannot be used with daily_budget.', + ), + bid_strategy: z + .enum(['LOWEST_COST_WITHOUT_CAP', 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP']) + .optional() + .describe('Bidding strategy for the campaign'), +}); + +const UpdateCampaignSchema = z.object({ + campaign_id: z.string().describe('ID of the campaign to update'), + name: z.string().optional().describe('New name for the campaign'), + status: z.enum(['ACTIVE', 'PAUSED', 'ARCHIVED']).optional().describe('New campaign status'), + daily_budget: z.number().optional().describe('New daily budget in cents'), + lifetime_budget: z.number().optional().describe('New lifetime budget in cents'), + bid_strategy: z + .enum(['LOWEST_COST_WITHOUT_CAP', 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP']) + .optional() + .describe('New bidding strategy'), +}); + +const DeleteCampaignSchema = z.object({ + campaign_id: z.string().describe('ID of the campaign to delete'), +}); + +const CreateAdSetSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + campaign_id: z.string().describe('Campaign ID this ad set belongs to'), + name: z.string().describe('Name of the ad set'), + optimization_goal: z + .enum([ + 'NONE', + 'APP_INSTALLS', + 'BRAND_AWARENESS', + 'CLICKS', + 'ENGAGED_USERS', + 'EVENT_RESPONSES', + 'IMPRESSIONS', + 'LEAD_GENERATION', + 'LINK_CLICKS', + 'OFFER_CLAIMS', + 'OFFSITE_CONVERSIONS', + 'PAGE_ENGAGEMENT', + 'PAGE_LIKES', + 'POST_ENGAGEMENT', + 'QUALITY_LEAD', + 'REACH', + 'SOCIAL_IMPRESSIONS', + 'VIDEO_VIEWS', + 'VISIT_INSTAGRAM_PROFILE', + 'VALUE', + 'THRUPLAY', + ]) + .describe('Optimization goal for the ad set'), + billing_event: z + .enum(['APP_INSTALLS', 'CLICKS', 'IMPRESSIONS', 'LINK_CLICKS', 'PAGE_LIKES', 'POST_ENGAGEMENT', 'THRUPLAY', 'VIDEO_VIEWS']) + .describe('Billing event for the ad set'), + bid_amount: z.number().optional().describe('Bid amount in cents'), + daily_budget: z + .number() + .optional() + .describe('Daily budget in cents. Cannot be used with lifetime_budget.'), + lifetime_budget: z + .number() + .optional() + .describe('Lifetime budget in cents. Cannot be used with daily_budget.'), + start_time: z.string().optional().describe('Start time in ISO 8601 format'), + end_time: z.string().optional().describe('End time in ISO 8601 format'), + status: z + .enum(['ACTIVE', 'PAUSED']) + .optional() + .default('PAUSED') + .describe('Initial ad set status'), + targeting: z + .record(z.any()) + .optional() + .describe( + 'Targeting spec object. See Meta API docs for full targeting options (geo_locations, age_min, age_max, genders, interests, etc.)', + ), +}); + +const CreateAdCreativeSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + name: z.string().describe('Name of the ad creative'), + object_story_spec: z + .record(z.any()) + .optional() + .describe( + 'Object story spec for the creative. Defines the page, link data, video data, etc.', + ), + asset_feed_spec: z + .record(z.any()) + .optional() + .describe('Asset feed spec for dynamic creative with multiple assets'), + image_hash: z.string().optional().describe('Hash of an uploaded image to use in the creative'), + image_url: z.string().optional().describe('URL of an image to use in the creative'), + video_id: z.string().optional().describe('ID of a video to use in the creative'), + title: z.string().optional().describe('Title text for the creative'), + body: z.string().optional().describe('Body text for the creative'), + link_url: z.string().optional().describe('Destination URL for the ad'), + call_to_action_type: z + .string() + .optional() + .describe('Call to action type (e.g. LEARN_MORE, SHOP_NOW, SIGN_UP)'), +}); + +const GetAdCreativeSchema = z.object({ + creative_id: z.string().describe('ID of the ad creative to retrieve'), + fields: z + .array(z.string()) + .optional() + .default([ + 'id', + 'name', + 'status', + 'thumbnail_url', + 'object_story_spec', + 'asset_feed_spec', + 'effective_object_story_id', + ]) + .describe('Fields to retrieve for the creative'), +}); + +const UpdateAdCreativeSchema = z.object({ + creative_id: z.string().describe('ID of the ad creative to update'), + name: z.string().optional().describe('New name for the creative'), + object_story_spec: z.record(z.any()).optional().describe('Updated object story spec'), + asset_feed_spec: z.record(z.any()).optional().describe('Updated asset feed spec'), + title: z.string().optional().describe('New title text'), + body: z.string().optional().describe('New body text'), +}); + +const DeleteAdCreativeSchema = z.object({ + creative_id: z.string().describe('ID of the ad creative to delete'), +}); + +const ListAdCreativesSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + fields: z + .array(z.string()) + .optional() + .default(['id', 'name', 'status', 'thumbnail_url', 'effective_object_story_id']) + .describe('Fields to retrieve for each creative'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const PreviewAdCreativeSchema = z.object({ + creative_id: z.string().describe('ID of the ad creative to preview'), + ad_format: z + .enum([ + 'DESKTOP_FEED_STANDARD', + 'MOBILE_FEED_STANDARD', + 'MOBILE_FEED_BASIC', + 'MOBILE_INTERSTITIAL', + 'INSTAGRAM_STANDARD', + 'INSTAGRAM_STORY', + 'AUDIENCE_NETWORK_OUTSTREAM_VIDEO', + 'RIGHT_COLUMN_STANDARD', + ]) + .optional() + .default('DESKTOP_FEED_STANDARD') + .describe('Ad format for the preview'), +}); + +const CreateAdSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + name: z.string().describe('Name of the ad'), + adset_id: z.string().describe('Ad set ID this ad belongs to'), + creative: z + .object({ + creative_id: z.string().describe('ID of an existing ad creative'), + }) + .or( + z.object({ + name: z.string().optional(), + object_story_spec: z.record(z.any()), + }), + ) + .describe('Creative to use for the ad. Either an existing creative ID or inline creative spec.'), + status: z + .enum(['ACTIVE', 'PAUSED']) + .optional() + .default('PAUSED') + .describe('Initial ad status'), + tracking_specs: z + .array(z.record(z.any())) + .optional() + .describe('Tracking specs for conversion tracking'), +}); + +const CreateCustomAudienceSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + name: z.string().describe('Name of the custom audience'), + description: z.string().optional().describe('Description of the custom audience'), + subtype: z + .enum([ + 'CUSTOM', + 'WEBSITE', + 'APP', + 'OFFLINE_CONVERSION', + 'CLAIM', + 'PARTNER', + 'MANAGED', + 'VIDEO', + 'LOOKALIKE', + 'ENGAGEMENT', + 'BAG_OF_ACCOUNTS', + 'STUDY_RULE_AUDIENCE', + 'FOX', + ]) + .describe('Subtype of the custom audience'), + customer_file_source: z + .enum(['USER_PROVIDED_ONLY', 'PARTNER_PROVIDED_ONLY', 'BOTH_USER_AND_PARTNER_PROVIDED']) + .optional() + .describe('Source of the customer data (required for CUSTOM subtype)'), + rule: z + .string() + .optional() + .describe('Audience rule for WEBSITE or APP subtypes (JSON string)'), + lookalike_spec: z + .record(z.any()) + .optional() + .describe('Lookalike spec for LOOKALIKE subtype'), +}); + +const UploadAdImageSchema = z.object({ + ad_account_id: z + .string() + .describe('Ad account ID (e.g. act_123456789). Include the "act_" prefix.'), + image_url: z + .string() + .optional() + .describe('URL of the image to upload'), + filename: z.string().optional().describe('Filename for the image'), + bytes: z.string().optional().describe('Base64-encoded image bytes'), +}); + +const ListTargetingSearchSchema = z.object({ + q: z.string().describe('Search query string'), + type: z + .enum([ + 'adinterest', + 'adTargetingCategory', + 'adeducationschool', + 'adeducationmajor', + 'adworkemployer', + 'adworkposition', + 'adlocale', + 'adgeolocation', + 'adgeolocationmeta', + 'adzipcode', + ]) + .describe('Type of targeting option to search for'), + limit: z.number().optional().default(25).describe('Number of results to return'), +}); + +const ListAdNetworkAnalyticsSchema = z.object({ + business_id: z.string().describe('Meta Business ID'), + aggregation_period: z + .enum(['HOUR', 'DAY', 'TOTAL']) + .optional() + .default('DAY') + .describe('Aggregation period for analytics'), + breakdowns: z + .array(z.string()) + .optional() + .describe('Breakdown dimensions (e.g. ["app", "country"])'), + metrics: z + .array(z.string()) + .optional() + .default(['fb_ad_network_imp', 'fb_ad_network_click', 'fb_ad_network_revenue']) + .describe('Metrics to retrieve'), + since: z.string().optional().describe('Start date in YYYY-MM-DD format'), + until: z.string().optional().describe('End date in YYYY-MM-DD format'), +}); + +const ListAdNetworkAnalyticsResultsSchema = z.object({ + business_id: z.string().describe('Meta Business ID'), + query_ids: z + .array(z.string()) + .optional() + .describe('Query IDs from a previous adnetworkanalytics call'), + limit: z.number().optional().default(25).describe('Number of results per page'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +// Business-level schemas (shared pagination pattern) +const BusinessIdWithPaginationSchema = z.object({ + business_id: z.string().describe('Meta Business Manager ID'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const ListAssignedUsersSchema = z.object({ + object_id: z.string().describe('Page ID or Ad Account ID'), + business_id: z.string().describe('Business ID context for the assignment'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const ListAssignedPagesSchema = z.object({ + business_user_id: z.string().describe('Business user ID'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + +const ListAgenciesSchema = z.object({ + object_id: z.string().describe('Business ID or Ad Account ID'), + limit: z.number().optional().default(25).describe('Number of results per page (max 200)'), + after: z.string().optional().describe('Pagination cursor for next page'), +}); + // ============================================================================ // TOOL EXPORT FUNCTION // ============================================================================ @@ -155,94 +540,846 @@ export async function getTools() { 'Retrieve performance insights (impressions, clicks, spend, reach, CTR, CPC, conversions, etc.) for an ad account, campaign, ad set, or ad over a specified time range.', inputSchema: zodToJsonSchema(GetInsightsSchema), }, - ]; -} - -// ============================================================================ -// TOOL CALL HANDLERS -// ============================================================================ - -export async function callTool( - name: string, - args: Record, - config: Record, -) { - const accessToken = config?.access_token || config?.mcp?.tokens?.access_token; - const client = createMetaClient(accessToken); - - try { - switch (name) { - case 'list_ad_accounts': { - const { limit, after } = ListAdAccountsSchema.parse(args); - const params: Record = { - fields: 'id,name,currency,timezone_name,account_status,business', - limit, - }; - if (after) params.after = after; - - const response = await client.get('/me/adaccounts', { params }); - const accounts = response.data.data || []; - const paging = response.data.paging; - - const formatted = accounts - .map((acc: any) => { - const statusMap: Record = { - 1: 'ACTIVE', - 2: 'DISABLED', - 3: 'UNSETTLED', - 7: 'PENDING_REVIEW', - 9: 'IN_GRACE_PERIOD', - 101: 'TEMPORARILY_UNAVAILABLE', - 100: 'PENDING_CLOSURE', - }; - const status = statusMap[acc.account_status] ?? String(acc.account_status); - return `ID: ${acc.id} | Name: ${acc.name} -Currency: ${acc.currency} | Timezone: ${acc.timezone_name} -Status: ${status}${acc.business ? ` | Business: ${acc.business.name}` : ''}`; - }) - .join('\n\n'); - - const nextCursor = paging?.cursors?.after; - const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; - - return { - content: [ - { - type: 'text', - text: formatted - ? `Ad Accounts (${accounts.length}):\n\n${formatted}${pagination}` - : 'No ad accounts found.', - }, - ], - }; - } - - case 'list_campaigns': { - const { ad_account_id, status_filter, limit, after } = ListCampaignsSchema.parse(args); - const params: Record = { - fields: - 'id,name,objective,status,effective_status,daily_budget,lifetime_budget,start_time,stop_time', - limit, - }; - if (status_filter && status_filter.length > 0) { - params.effective_status = JSON.stringify(status_filter); - } + { + name: 'get_user', + description: + 'Retrieve information about the authenticated Meta user. Returns profile information such as name, email, and other user details.', + inputSchema: zodToJsonSchema(GetUserSchema), + }, + { + name: 'get_meta_object', + description: + 'Retrieve data for any Meta Marketing API object by its ID. Use for ad accounts, campaigns, ad sets, ads, creatives, pages, or any other Meta object with flexible field selection.', + inputSchema: zodToJsonSchema(GetMetaObjectSchema), + }, + { + name: 'get_page_accounts', + description: + 'Retrieve permanent page access tokens for pages managed by the authenticated user.', + inputSchema: zodToJsonSchema(GetPageAccountsSchema), + }, + { + name: 'get_video', + description: + 'Retrieve video information from Meta by video ID, including source URL, permalink, title, description, length, and thumbnails.', + inputSchema: zodToJsonSchema(GetVideoSchema), + }, + { + name: 'create_campaign', + description: + 'Create a new advertising campaign. Supports various objectives, budgets, and bidding strategies.', + inputSchema: zodToJsonSchema(CreateCampaignSchema), + }, + { + name: 'update_campaign', + description: + 'Update an existing campaign. Only include fields that need to be changed.', + inputSchema: zodToJsonSchema(UpdateCampaignSchema), + }, + { + name: 'delete_campaign', + description: + 'Delete a campaign. Marks it as DELETED to stop delivery. The campaign is not permanently removed and can still appear in reports.', + inputSchema: zodToJsonSchema(DeleteCampaignSchema), + }, + { + name: 'create_ad_set', + description: + 'Create a new ad set within a campaign. Supports detailed targeting, budgets, and optimization goals.', + inputSchema: zodToJsonSchema(CreateAdSetSchema), + }, + { + name: 'create_ad_creative', + description: + 'Create a new reusable ad creative defining how an ad looks. Supports image, video, and carousel formats.', + inputSchema: zodToJsonSchema(CreateAdCreativeSchema), + }, + { + name: 'get_ad_creative', + description: + 'Retrieve details for a specific ad creative by its ID.', + inputSchema: zodToJsonSchema(GetAdCreativeSchema), + }, + { + name: 'update_ad_creative', + description: + 'Update an existing ad creative.', + inputSchema: zodToJsonSchema(UpdateAdCreativeSchema), + }, + { + name: 'delete_ad_creative', + description: + 'Delete an ad creative by its ID.', + inputSchema: zodToJsonSchema(DeleteAdCreativeSchema), + }, + { + name: 'list_ad_creatives', + description: + 'List all ad creatives under an ad account.', + inputSchema: zodToJsonSchema(ListAdCreativesSchema), + }, + { + name: 'preview_ad_creative', + description: + 'Generate a preview of an ad creative in a specified ad format.', + inputSchema: zodToJsonSchema(PreviewAdCreativeSchema), + }, + { + name: 'create_ad', + description: + 'Create a new ad within an ad set. Supports image, video, carousel, and collection ad formats.', + inputSchema: zodToJsonSchema(CreateAdSchema), + }, + { + name: 'create_custom_audience', + description: + 'Create a new custom audience. Supports customer lists, website visitors, app users, and lookalike audiences.', + inputSchema: zodToJsonSchema(CreateCustomAudienceSchema), + }, + { + name: 'upload_ad_image', + description: + 'Upload an image for use in Meta ad creatives. Returns the image hash to reference in creatives.', + inputSchema: zodToJsonSchema(UploadAdImageSchema), + }, + { + name: 'list_targeting_search', + description: + 'Search for targeting options such as interests, locations, demographics, schools, or employers for ad targeting.', + inputSchema: zodToJsonSchema(ListTargetingSearchSchema), + }, + { + name: 'list_ad_network_analytics', + description: + 'Retrieve ad network analytics for a Meta Business including revenue and impressions from Facebook Audience Network.', + inputSchema: zodToJsonSchema(ListAdNetworkAnalyticsSchema), + }, + { + name: 'list_ad_network_analytics_results', + description: + 'Retrieve ad network analytics results for Facebook Audience Network for a specific business.', + inputSchema: zodToJsonSchema(ListAdNetworkAnalyticsResultsSchema), + }, + { + name: 'list_business_ad_accounts', + description: + 'List all ad accounts owned by a specific Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_client_ad_accounts', + description: + 'List all client ad accounts accessible to a Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_client_ad_accounts', + description: + 'List pending client ad account access requests for a Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_owned_ad_accounts', + description: + 'List ad accounts with pending ownership status for a Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_owned_pages', + description: + 'List Facebook Pages owned by a Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_client_pages', + description: + 'List client pages associated with a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_client_pages', + description: + 'List Facebook Pages with pending access requests from the business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_owned_pages', + description: + 'List Facebook Pages with pending ownership status for a Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_owned_apps', + description: + 'List apps owned by a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_client_apps', + description: + 'List client apps associated with a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_client_apps', + description: + 'List apps pending approval or connection to a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_owned_instagram_assets', + description: + 'List Instagram Business Accounts owned by a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_client_instagram_assets', + description: + 'List Instagram assets shared with a Meta Business as a client.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_clients', + description: + 'List client businesses associated with a Meta Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_owned_businesses', + description: + 'List child businesses owned by a parent Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_system_users', + description: + 'List system users for a Meta Business Manager account.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_users', + description: + 'List users with pending invitations to a Business Manager.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_business_invoices', + description: + 'List business invoices for a Meta Business including amounts, due dates, and payment status.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_initiated_audience_sharing_requests', + description: + 'List audience sharing requests initiated by a business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_received_audience_sharing_requests', + description: + 'List audience sharing requests received by a business from other businesses.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_collaborative_ads_collaboration_requests', + description: + 'List collaborative ads collaboration requests for a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_collaborative_ads_suggested_partners', + description: + 'List suggested partners for collaborative advertising campaigns.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_owned_offsite_signal_container_business_objects', + description: + 'List owned offsite signal container business objects for a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_client_offsite_signal_container_business_objects', + description: + 'List client offsite signal container business objects for a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_pending_shared_offsite_signal_container_business_objects', + description: + 'List pending shared offsite signal container business objects for a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_managed_partner_ads_funding_source_details', + description: + 'List managed partner ads funding source details for a Meta Business.', + inputSchema: zodToJsonSchema(BusinessIdWithPaginationSchema), + }, + { + name: 'list_agencies', + description: + 'List agencies associated with a Meta Business or Ad Account.', + inputSchema: zodToJsonSchema(ListAgenciesSchema), + }, + { + name: 'list_assigned_pages', + description: + 'List Facebook Pages assigned to a business user.', + inputSchema: zodToJsonSchema(ListAssignedPagesSchema), + }, + { + name: 'list_assigned_users', + description: + 'List users assigned to a Facebook Page or Ad Account within a business context, including their task permissions.', + inputSchema: zodToJsonSchema(ListAssignedUsersSchema), + }, + ]; +} + +// ============================================================================ +// TOOL CALL HANDLERS +// ============================================================================ + +export async function callTool( + name: string, + args: Record, + config: Record, +) { + const accessToken = config?.access_token || config?.mcp?.tokens?.access_token; + const client = createMetaClient(accessToken); + + // Helper for business edge requests with pagination + async function fetchBusinessEdge( + businessId: string, + edge: string, + fields: string, + limit: number, + after?: string, + ) { + const params: Record = { fields, limit }; + if (after) params.after = after; + const response = await client.get(`/${businessId}/${edge}`, { params }); + return response.data; + } + + try { + switch (name) { + case 'list_ad_accounts': { + const { limit, after } = ListAdAccountsSchema.parse(args); + const params: Record = { + fields: 'id,name,currency,timezone_name,account_status,business', + limit, + }; + if (after) params.after = after; + + const response = await client.get('/me/adaccounts', { params }); + const accounts = response.data.data || []; + const paging = response.data.paging; + + const formatted = accounts + .map((acc: any) => { + const statusMap: Record = { + 1: 'ACTIVE', + 2: 'DISABLED', + 3: 'UNSETTLED', + 7: 'PENDING_REVIEW', + 9: 'IN_GRACE_PERIOD', + 101: 'TEMPORARILY_UNAVAILABLE', + 100: 'PENDING_CLOSURE', + }; + const status = statusMap[acc.account_status] ?? String(acc.account_status); + return `ID: ${acc.id} | Name: ${acc.name} +Currency: ${acc.currency} | Timezone: ${acc.timezone_name} +Status: ${status}${acc.business ? ` | Business: ${acc.business.name}` : ''}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Ad Accounts (${accounts.length}):\n\n${formatted}${pagination}` + : 'No ad accounts found.', + }, + ], + }; + } + + case 'list_campaigns': { + const { ad_account_id, status_filter, limit, after } = ListCampaignsSchema.parse(args); + const params: Record = { + fields: + 'id,name,objective,status,effective_status,daily_budget,lifetime_budget,start_time,stop_time', + limit, + }; + if (status_filter && status_filter.length > 0) { + params.effective_status = JSON.stringify(status_filter); + } + if (after) params.after = after; + + const response = await client.get(`/${ad_account_id}/campaigns`, { params }); + const campaigns = response.data.data || []; + const paging = response.data.paging; + + const formatted = campaigns + .map((c: any) => { + const budget = c.daily_budget + ? `Daily: ${(parseInt(c.daily_budget) / 100).toFixed(2)}` + : c.lifetime_budget + ? `Lifetime: ${(parseInt(c.lifetime_budget) / 100).toFixed(2)}` + : 'No budget set'; + return `ID: ${c.id} | Name: ${c.name} +Objective: ${c.objective} | Status: ${c.effective_status} +Budget: ${budget}${c.start_time ? ` | Start: ${c.start_time}` : ''}${c.stop_time ? ` | End: ${c.stop_time}` : ''}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Campaigns for ${ad_account_id} (${campaigns.length}):\n\n${formatted}${pagination}` + : `No campaigns found for ${ad_account_id}.`, + }, + ], + }; + } + + case 'list_ad_sets': { + const { ad_account_id, campaign_id, status_filter, limit, after } = + ListAdSetsSchema.parse(args); + const params: Record = { + fields: + 'id,name,campaign_id,status,effective_status,daily_budget,lifetime_budget,targeting,start_time,end_time,optimization_goal,billing_event', + limit, + }; + if (campaign_id) params.campaign_id = campaign_id; + if (status_filter && status_filter.length > 0) { + params.effective_status = JSON.stringify(status_filter); + } + if (after) params.after = after; + + const response = await client.get(`/${ad_account_id}/adsets`, { params }); + const adsets = response.data.data || []; + const paging = response.data.paging; + + const formatted = adsets + .map((s: any) => { + const budget = s.daily_budget + ? `Daily: ${(parseInt(s.daily_budget) / 100).toFixed(2)}` + : s.lifetime_budget + ? `Lifetime: ${(parseInt(s.lifetime_budget) / 100).toFixed(2)}` + : 'No budget set'; + return `ID: ${s.id} | Name: ${s.name} +Campaign ID: ${s.campaign_id} | Status: ${s.effective_status} +Budget: ${budget} | Optimization: ${s.optimization_goal} | Billing: ${s.billing_event}${s.start_time ? ` | Start: ${s.start_time}` : ''}${s.end_time ? ` | End: ${s.end_time}` : ''}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Ad Sets for ${ad_account_id} (${adsets.length}):\n\n${formatted}${pagination}` + : `No ad sets found for ${ad_account_id}.`, + }, + ], + }; + } + + case 'list_ads': { + const { ad_account_id, campaign_id, adset_id, status_filter, limit, after } = + ListAdsSchema.parse(args); + const params: Record = { + fields: + 'id,name,campaign_id,adset_id,status,effective_status,creative{id,name,thumbnail_url},created_time,updated_time', + limit, + }; + if (campaign_id) params.campaign_id = campaign_id; + if (adset_id) params.adset_id = adset_id; + if (status_filter && status_filter.length > 0) { + params.effective_status = JSON.stringify(status_filter); + } + if (after) params.after = after; + + const response = await client.get(`/${ad_account_id}/ads`, { params }); + const ads = response.data.data || []; + const paging = response.data.paging; + + const formatted = ads + .map((ad: any) => { + return `ID: ${ad.id} | Name: ${ad.name} +Campaign ID: ${ad.campaign_id} | Ad Set ID: ${ad.adset_id} +Status: ${ad.effective_status}${ad.creative ? ` | Creative: ${ad.creative.name || ad.creative.id}` : ''} +Created: ${ad.created_time} | Updated: ${ad.updated_time}`; + }) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Ads for ${ad_account_id} (${ads.length}):\n\n${formatted}${pagination}` + : `No ads found for ${ad_account_id}.`, + }, + ], + }; + } + + case 'get_insights': { + const { object_id, level, date_preset, time_range, fields, limit, after } = + GetInsightsSchema.parse(args); + const params: Record = { + fields: fields.join(','), + level, + limit, + }; + if (time_range) { + params.time_range = JSON.stringify(time_range); + } else { + params.date_preset = date_preset; + } + if (after) params.after = after; + + const response = await client.get(`/${object_id}/insights`, { params }); + const insights = response.data.data || []; + const paging = response.data.paging; + + if (insights.length === 0) { + return { + content: [ + { + type: 'text', + text: `No insights data found for ${object_id} in the requested time range.`, + }, + ], + }; + } + + const formatted = insights + .map((row: any) => { + const lines = [`Period: ${row.date_start} to ${row.date_stop}`]; + if (row.campaign_name) lines.push(`Campaign: ${row.campaign_name}`); + if (row.adset_name) lines.push(`Ad Set: ${row.adset_name}`); + if (row.ad_name) lines.push(`Ad: ${row.ad_name}`); + + const metricLines: string[] = []; + if (row.impressions) metricLines.push(`Impressions: ${parseInt(row.impressions).toLocaleString()}`); + if (row.reach) metricLines.push(`Reach: ${parseInt(row.reach).toLocaleString()}`); + if (row.clicks) metricLines.push(`Clicks: ${parseInt(row.clicks).toLocaleString()}`); + if (row.spend) metricLines.push(`Spend: $${parseFloat(row.spend).toFixed(2)}`); + if (row.cpm) metricLines.push(`CPM: $${parseFloat(row.cpm).toFixed(2)}`); + if (row.cpc) metricLines.push(`CPC: $${parseFloat(row.cpc).toFixed(2)}`); + if (row.ctr) metricLines.push(`CTR: ${parseFloat(row.ctr).toFixed(2)}%`); + if (row.conversions) { + const convTotal = Array.isArray(row.conversions) + ? row.conversions.reduce((sum: number, c: any) => sum + parseInt(c.value || '0'), 0) + : parseInt(row.conversions); + metricLines.push(`Conversions: ${convTotal.toLocaleString()}`); + } + if (row.cost_per_conversion) { + const cpp = Array.isArray(row.cost_per_conversion) + ? row.cost_per_conversion[0]?.value + : row.cost_per_conversion; + if (cpp) metricLines.push(`Cost/Conv: $${parseFloat(cpp).toFixed(2)}`); + } + + const handledFields = new Set([ + 'date_start', 'date_stop', 'campaign_name', 'adset_name', 'ad_name', + 'impressions', 'reach', 'clicks', 'spend', 'cpm', 'cpc', 'ctr', + 'conversions', 'cost_per_conversion', + ]); + for (const [key, val] of Object.entries(row)) { + if (!handledFields.has(key) && val !== undefined && val !== null) { + metricLines.push(`${key}: ${val}`); + } + } + + return [...lines, ...metricLines].join('\n'); + }) + .join('\n\n---\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: `Insights for ${object_id} (${insights.length} rows):\n\n${formatted}${pagination}`, + }, + ], + }; + } + + case 'get_user': { + const { fields } = GetUserSchema.parse(args); + const response = await client.get('/me', { params: { fields: fields.join(',') } }); + const user = response.data; + const lines = Object.entries(user) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + return { content: [{ type: 'text', text: `User Info:\n\n${lines}` }] }; + } + + case 'get_meta_object': { + const { object_id, fields } = GetMetaObjectSchema.parse(args); + const params: Record = {}; + if (fields && fields.length > 0) params.fields = fields.join(','); + const response = await client.get(`/${object_id}`, { params }); + return { + content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }], + }; + } + + case 'get_page_accounts': { + const { limit, after } = GetPageAccountsSchema.parse(args); + const params: Record = { + fields: 'id,name,access_token,category,tasks', + limit, + }; + if (after) params.after = after; + const response = await client.get('/me/accounts', { params }); + const pages = response.data.data || []; + const paging = response.data.paging; + + const formatted = pages + .map((p: any) => `ID: ${p.id} | Name: ${p.name}\nCategory: ${p.category || 'N/A'} | Tasks: ${(p.tasks || []).join(', ')}`) + .join('\n\n'); + + const nextCursor = paging?.cursors?.after; + const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + + return { + content: [ + { + type: 'text', + text: formatted + ? `Page Accounts (${pages.length}):\n\n${formatted}${pagination}` + : 'No page accounts found.', + }, + ], + }; + } + + case 'get_video': { + const { video_id, fields } = GetVideoSchema.parse(args); + const response = await client.get(`/${video_id}`, { + params: { fields: fields.join(',') }, + }); + const v = response.data; + const lines = Object.entries(v) + .map(([k, val]) => `${k}: ${typeof val === 'object' ? JSON.stringify(val) : val}`) + .join('\n'); + return { content: [{ type: 'text', text: `Video ${video_id}:\n\n${lines}` }] }; + } + + case 'create_campaign': { + const { + ad_account_id, + name, + objective, + status, + special_ad_categories, + daily_budget, + lifetime_budget, + bid_strategy, + } = CreateCampaignSchema.parse(args); + + const payload: Record = { + name, + objective, + status, + special_ad_categories, + }; + if (daily_budget) payload.daily_budget = daily_budget; + if (lifetime_budget) payload.lifetime_budget = lifetime_budget; + if (bid_strategy) payload.bid_strategy = bid_strategy; + + const response = await client.post(`/${ad_account_id}/campaigns`, payload); + return { + content: [ + { + type: 'text', + text: `Campaign created successfully.\nID: ${response.data.id}`, + }, + ], + }; + } + + case 'update_campaign': { + const { campaign_id, ...rest } = UpdateCampaignSchema.parse(args); + const payload: Record = {}; + if (rest.name !== undefined) payload.name = rest.name; + if (rest.status !== undefined) payload.status = rest.status; + if (rest.daily_budget !== undefined) payload.daily_budget = rest.daily_budget; + if (rest.lifetime_budget !== undefined) payload.lifetime_budget = rest.lifetime_budget; + if (rest.bid_strategy !== undefined) payload.bid_strategy = rest.bid_strategy; + + await client.post(`/${campaign_id}`, payload); + return { + content: [{ type: 'text', text: `Campaign ${campaign_id} updated successfully.` }], + }; + } + + case 'delete_campaign': { + const { campaign_id } = DeleteCampaignSchema.parse(args); + await client.delete(`/${campaign_id}`); + return { + content: [ + { + type: 'text', + text: `Campaign ${campaign_id} deleted (marked as DELETED). It will no longer deliver ads.`, + }, + ], + }; + } + + case 'create_ad_set': { + const { + ad_account_id, + campaign_id, + name, + optimization_goal, + billing_event, + bid_amount, + daily_budget, + lifetime_budget, + start_time, + end_time, + status, + targeting, + } = CreateAdSetSchema.parse(args); + + const payload: Record = { + campaign_id, + name, + optimization_goal, + billing_event, + status, + }; + if (bid_amount !== undefined) payload.bid_amount = bid_amount; + if (daily_budget !== undefined) payload.daily_budget = daily_budget; + if (lifetime_budget !== undefined) payload.lifetime_budget = lifetime_budget; + if (start_time) payload.start_time = start_time; + if (end_time) payload.end_time = end_time; + if (targeting) payload.targeting = JSON.stringify(targeting); + + const response = await client.post(`/${ad_account_id}/adsets`, payload); + return { + content: [ + { + type: 'text', + text: `Ad set created successfully.\nID: ${response.data.id}`, + }, + ], + }; + } + + case 'create_ad_creative': { + const { + ad_account_id, + name, + object_story_spec, + asset_feed_spec, + image_hash, + image_url, + video_id, + title, + body, + link_url, + call_to_action_type, + } = CreateAdCreativeSchema.parse(args); + + const payload: Record = { name }; + if (object_story_spec) payload.object_story_spec = JSON.stringify(object_story_spec); + if (asset_feed_spec) payload.asset_feed_spec = JSON.stringify(asset_feed_spec); + if (image_hash) payload.image_hash = image_hash; + if (image_url) payload.image_url = image_url; + if (video_id) payload.video_id = video_id; + if (title) payload.title = title; + if (body) payload.body = body; + if (link_url) payload.link_url = link_url; + if (call_to_action_type) payload.call_to_action_type = call_to_action_type; + + const response = await client.post(`/${ad_account_id}/adcreatives`, payload); + return { + content: [ + { + type: 'text', + text: `Ad creative created successfully.\nID: ${response.data.id}`, + }, + ], + }; + } + + case 'get_ad_creative': { + const { creative_id, fields } = GetAdCreativeSchema.parse(args); + const response = await client.get(`/${creative_id}`, { + params: { fields: fields.join(',') }, + }); + const c = response.data; + const lines = Object.entries(c) + .map(([k, val]) => `${k}: ${typeof val === 'object' ? JSON.stringify(val) : val}`) + .join('\n'); + return { content: [{ type: 'text', text: `Ad Creative ${creative_id}:\n\n${lines}` }] }; + } + + case 'update_ad_creative': { + const { creative_id, ...rest } = UpdateAdCreativeSchema.parse(args); + const payload: Record = {}; + if (rest.name !== undefined) payload.name = rest.name; + if (rest.object_story_spec !== undefined) payload.object_story_spec = JSON.stringify(rest.object_story_spec); + if (rest.asset_feed_spec !== undefined) payload.asset_feed_spec = JSON.stringify(rest.asset_feed_spec); + if (rest.title !== undefined) payload.title = rest.title; + if (rest.body !== undefined) payload.body = rest.body; + + await client.post(`/${creative_id}`, payload); + return { + content: [{ type: 'text', text: `Ad creative ${creative_id} updated successfully.` }], + }; + } + + case 'delete_ad_creative': { + const { creative_id } = DeleteAdCreativeSchema.parse(args); + await client.delete(`/${creative_id}`); + return { + content: [{ type: 'text', text: `Ad creative ${creative_id} deleted successfully.` }], + }; + } + + case 'list_ad_creatives': { + const { ad_account_id, fields, limit, after } = ListAdCreativesSchema.parse(args); + const params: Record = { fields: fields.join(','), limit }; if (after) params.after = after; - const response = await client.get(`/${ad_account_id}/campaigns`, { params }); - const campaigns = response.data.data || []; + const response = await client.get(`/${ad_account_id}/adcreatives`, { params }); + const creatives = response.data.data || []; const paging = response.data.paging; - const formatted = campaigns + const formatted = creatives .map((c: any) => { - const budget = c.daily_budget - ? `Daily: ${(parseInt(c.daily_budget) / 100).toFixed(2)}` - : c.lifetime_budget - ? `Lifetime: ${(parseInt(c.lifetime_budget) / 100).toFixed(2)}` - : 'No budget set'; - return `ID: ${c.id} | Name: ${c.name} -Objective: ${c.objective} | Status: ${c.effective_status} -Budget: ${budget}${c.start_time ? ` | Start: ${c.start_time}` : ''}${c.stop_time ? ` | End: ${c.stop_time}` : ''}`; + const lines = [`ID: ${c.id} | Name: ${c.name}`]; + if (c.status) lines.push(`Status: ${c.status}`); + if (c.effective_object_story_id) lines.push(`Story: ${c.effective_object_story_id}`); + return lines.join('\n'); }) .join('\n\n'); @@ -254,187 +1391,440 @@ Budget: ${budget}${c.start_time ? ` | Start: ${c.start_time}` : ''}${c.stop_time { type: 'text', text: formatted - ? `Campaigns for ${ad_account_id} (${campaigns.length}):\n\n${formatted}${pagination}` - : `No campaigns found for ${ad_account_id}.`, + ? `Ad Creatives for ${ad_account_id} (${creatives.length}):\n\n${formatted}${pagination}` + : `No ad creatives found for ${ad_account_id}.`, }, ], }; } - case 'list_ad_sets': { - const { ad_account_id, campaign_id, status_filter, limit, after } = - ListAdSetsSchema.parse(args); - const params: Record = { - fields: - 'id,name,campaign_id,status,effective_status,daily_budget,lifetime_budget,targeting,start_time,end_time,optimization_goal,billing_event', - limit, + case 'preview_ad_creative': { + const { creative_id, ad_format } = PreviewAdCreativeSchema.parse(args); + const response = await client.get(`/${creative_id}/previews`, { + params: { ad_format }, + }); + const previews = response.data.data || []; + const formatted = previews + .map((p: any) => `Format: ${p.ad_format || ad_format}\nBody:\n${p.body}`) + .join('\n\n---\n\n'); + return { + content: [ + { + type: 'text', + text: formatted || `No preview available for creative ${creative_id}.`, + }, + ], }; - if (campaign_id) params.campaign_id = campaign_id; - if (status_filter && status_filter.length > 0) { - params.effective_status = JSON.stringify(status_filter); - } - if (after) params.after = after; - - const response = await client.get(`/${ad_account_id}/adsets`, { params }); - const adsets = response.data.data || []; - const paging = response.data.paging; + } - const formatted = adsets - .map((s: any) => { - const budget = s.daily_budget - ? `Daily: ${(parseInt(s.daily_budget) / 100).toFixed(2)}` - : s.lifetime_budget - ? `Lifetime: ${(parseInt(s.lifetime_budget) / 100).toFixed(2)}` - : 'No budget set'; - return `ID: ${s.id} | Name: ${s.name} -Campaign ID: ${s.campaign_id} | Status: ${s.effective_status} -Budget: ${budget} | Optimization: ${s.optimization_goal} | Billing: ${s.billing_event}${s.start_time ? ` | Start: ${s.start_time}` : ''}${s.end_time ? ` | End: ${s.end_time}` : ''}`; - }) - .join('\n\n'); + case 'create_ad': { + const { ad_account_id, name, adset_id, creative, status, tracking_specs } = + CreateAdSchema.parse(args); - const nextCursor = paging?.cursors?.after; - const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + const payload: Record = { + name, + adset_id, + status, + creative: JSON.stringify(creative), + }; + if (tracking_specs) payload.tracking_specs = JSON.stringify(tracking_specs); + const response = await client.post(`/${ad_account_id}/ads`, payload); return { content: [ { type: 'text', - text: formatted - ? `Ad Sets for ${ad_account_id} (${adsets.length}):\n\n${formatted}${pagination}` - : `No ad sets found for ${ad_account_id}.`, + text: `Ad created successfully.\nID: ${response.data.id}`, }, ], }; } - case 'list_ads': { - const { ad_account_id, campaign_id, adset_id, status_filter, limit, after } = - ListAdsSchema.parse(args); - const params: Record = { - fields: - 'id,name,campaign_id,adset_id,status,effective_status,creative{id,name,thumbnail_url},created_time,updated_time', - limit, + case 'create_custom_audience': { + const { + ad_account_id, + name, + description, + subtype, + customer_file_source, + rule, + lookalike_spec, + } = CreateCustomAudienceSchema.parse(args); + + const payload: Record = { name, subtype }; + if (description) payload.description = description; + if (customer_file_source) payload.customer_file_source = customer_file_source; + if (rule) payload.rule = rule; + if (lookalike_spec) payload.lookalike_spec = JSON.stringify(lookalike_spec); + + const response = await client.post(`/${ad_account_id}/customaudiences`, payload); + return { + content: [ + { + type: 'text', + text: `Custom audience created successfully.\nID: ${response.data.id}`, + }, + ], }; - if (campaign_id) params.campaign_id = campaign_id; - if (adset_id) params.adset_id = adset_id; - if (status_filter && status_filter.length > 0) { - params.effective_status = JSON.stringify(status_filter); - } - if (after) params.after = after; + } - const response = await client.get(`/${ad_account_id}/ads`, { params }); - const ads = response.data.data || []; - const paging = response.data.paging; + case 'upload_ad_image': { + const { ad_account_id, image_url, filename, bytes } = UploadAdImageSchema.parse(args); + const payload: Record = {}; + if (image_url) payload.url = image_url; + if (filename) payload.filename = filename; + if (bytes) payload.bytes = bytes; - const formatted = ads - .map((ad: any) => { - return `ID: ${ad.id} | Name: ${ad.name} -Campaign ID: ${ad.campaign_id} | Ad Set ID: ${ad.adset_id} -Status: ${ad.effective_status}${ad.creative ? ` | Creative: ${ad.creative.name || ad.creative.id}` : ''} -Created: ${ad.created_time} | Updated: ${ad.updated_time}`; - }) + const response = await client.post(`/${ad_account_id}/adimages`, payload); + const images = response.data.images || {}; + const imageEntries = Object.entries(images); + const formatted = imageEntries + .map(([fname, img]: [string, any]) => `Filename: ${fname}\nHash: ${img.hash}\nURL: ${img.url || 'N/A'}`) .join('\n\n'); + return { + content: [ + { + type: 'text', + text: formatted + ? `Image uploaded successfully:\n\n${formatted}` + : `Image uploaded: ${JSON.stringify(response.data)}`, + }, + ], + }; + } - const nextCursor = paging?.cursors?.after; - const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; - + case 'list_targeting_search': { + const { q, type, limit } = ListTargetingSearchSchema.parse(args); + const response = await client.get('/search', { + params: { q, type, limit }, + }); + const results = response.data.data || []; + const formatted = results + .map((r: any) => { + const parts = [`Name: ${r.name}`]; + if (r.id) parts.push(`ID: ${r.id}`); + if (r.audience_size_lower_bound) parts.push(`Audience: ${r.audience_size_lower_bound.toLocaleString()} - ${r.audience_size_upper_bound?.toLocaleString()}`); + if (r.type) parts.push(`Type: ${r.type}`); + if (r.country_code) parts.push(`Country: ${r.country_code}`); + return parts.join(' | '); + }) + .join('\n'); return { content: [ { type: 'text', text: formatted - ? `Ads for ${ad_account_id} (${ads.length}):\n\n${formatted}${pagination}` - : `No ads found for ${ad_account_id}.`, + ? `Targeting results for "${q}" (${results.length}):\n\n${formatted}` + : `No targeting options found for "${q}".`, }, ], }; } - case 'get_insights': { - const { object_id, level, date_preset, time_range, fields, limit, after } = - GetInsightsSchema.parse(args); + case 'list_ad_network_analytics': { + const { business_id, aggregation_period, breakdowns, metrics, since, until } = + ListAdNetworkAnalyticsSchema.parse(args); const params: Record = { - fields: fields.join(','), - level, - limit, + aggregation_period, + metrics: JSON.stringify(metrics), }; - if (time_range) { - params.time_range = JSON.stringify(time_range); - } else { - params.date_preset = date_preset; - } + if (breakdowns && breakdowns.length > 0) params.breakdowns = JSON.stringify(breakdowns); + if (since) params.since = since; + if (until) params.until = until; + + const response = await client.get(`/${business_id}/adnetworkanalytics`, { params }); + return { + content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }], + }; + } + + case 'list_ad_network_analytics_results': { + const { business_id, query_ids, limit, after } = + ListAdNetworkAnalyticsResultsSchema.parse(args); + const params: Record = { limit }; + if (query_ids && query_ids.length > 0) params.query_ids = JSON.stringify(query_ids); if (after) params.after = after; - const response = await client.get(`/${object_id}/insights`, { params }); - const insights = response.data.data || []; - const paging = response.data.paging; + const response = await client.get(`/${business_id}/adnetworkanalytics_results`, { params }); + return { + content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }], + }; + } - if (insights.length === 0) { - return { - content: [ - { - type: 'text', - text: `No insights data found for ${object_id} in the requested time range.`, - }, - ], - }; - } + // ---- Business edge tools (shared pattern) ---- - const formatted = insights - .map((row: any) => { - const lines = [`Period: ${row.date_start} to ${row.date_stop}`]; - if (row.campaign_name) lines.push(`Campaign: ${row.campaign_name}`); - if (row.adset_name) lines.push(`Ad Set: ${row.adset_name}`); - if (row.ad_name) lines.push(`Ad: ${row.ad_name}`); + case 'list_business_ad_accounts': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'owned_ad_accounts', + 'id,name,currency,timezone_name,account_status', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } - const metricLines: string[] = []; - if (row.impressions) metricLines.push(`Impressions: ${parseInt(row.impressions).toLocaleString()}`); - if (row.reach) metricLines.push(`Reach: ${parseInt(row.reach).toLocaleString()}`); - if (row.clicks) metricLines.push(`Clicks: ${parseInt(row.clicks).toLocaleString()}`); - if (row.spend) metricLines.push(`Spend: $${parseFloat(row.spend).toFixed(2)}`); - if (row.cpm) metricLines.push(`CPM: $${parseFloat(row.cpm).toFixed(2)}`); - if (row.cpc) metricLines.push(`CPC: $${parseFloat(row.cpc).toFixed(2)}`); - if (row.ctr) metricLines.push(`CTR: ${parseFloat(row.ctr).toFixed(2)}%`); - if (row.conversions) { - const convTotal = Array.isArray(row.conversions) - ? row.conversions.reduce((sum: number, c: any) => sum + parseInt(c.value || '0'), 0) - : parseInt(row.conversions); - metricLines.push(`Conversions: ${convTotal.toLocaleString()}`); - } - if (row.cost_per_conversion) { - const cpp = Array.isArray(row.cost_per_conversion) - ? row.cost_per_conversion[0]?.value - : row.cost_per_conversion; - if (cpp) metricLines.push(`Cost/Conv: $${parseFloat(cpp).toFixed(2)}`); - } + case 'list_client_ad_accounts': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'client_ad_accounts', + 'id,name,currency,account_status', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } - // Include any other requested fields not handled above - const handledFields = new Set([ - 'date_start', 'date_stop', 'campaign_name', 'adset_name', 'ad_name', - 'impressions', 'reach', 'clicks', 'spend', 'cpm', 'cpc', 'ctr', - 'conversions', 'cost_per_conversion', - ]); - for (const [key, val] of Object.entries(row)) { - if (!handledFields.has(key) && val !== undefined && val !== null) { - metricLines.push(`${key}: ${val}`); - } - } + case 'list_pending_client_ad_accounts': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'pending_client_ad_accounts', + 'id,name,account_status', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } - return [...lines, ...metricLines].join('\n'); - }) - .join('\n\n---\n\n'); + case 'list_pending_owned_ad_accounts': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'pending_owned_ad_accounts', + 'id,name,account_status', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } - const nextCursor = paging?.cursors?.after; - const pagination = nextCursor ? `\n\nNext page cursor: ${nextCursor}` : ''; + case 'list_owned_pages': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'owned_pages', 'id,name,category', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } - return { - content: [ - { - type: 'text', - text: `Insights for ${object_id} (${insights.length} rows):\n\n${formatted}${pagination}`, - }, - ], + case 'list_client_pages': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'client_pages', 'id,name,category', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_pending_client_pages': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'pending_client_pages', 'id,name', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_pending_owned_pages': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'pending_owned_pages', 'id,name', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_owned_apps': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'owned_apps', 'id,name,category', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_client_apps': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'client_apps', 'id,name', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_pending_client_apps': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'pending_client_apps', 'id,name', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_owned_instagram_assets': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'owned_instagram_accounts', + 'id,name,username,profile_pic', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_client_instagram_assets': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'client_instagram_accounts', + 'id,name,username', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_clients': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'clients', 'id,name', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_owned_businesses': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'owned_businesses', 'id,name', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_system_users': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'system_users', 'id,name,role', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_pending_users': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge(business_id, 'pending_users', 'id,name,email,role', limit, after); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_business_invoices': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'business_invoices', + 'id,invoice_date,due_date,payment_status,amount,currency', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_initiated_audience_sharing_requests': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'initiated_audience_sharing_requests', + 'id,status,recipient_id,audience_id', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_received_audience_sharing_requests': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'received_audience_sharing_requests', + 'id,status,initiator_id,audience_id', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_collaborative_ads_collaboration_requests': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'collaborative_ads_collaboration_requests', + 'id,status,requester_agency_or_brand,contact_email', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_collaborative_ads_suggested_partners': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'collaborative_ads_suggested_partners', + 'id,name', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_owned_offsite_signal_container_business_objects': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'owned_offsite_signal_container_business_objects', + 'id,name', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_client_offsite_signal_container_business_objects': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'client_offsite_signal_container_business_objects', + 'id,name', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_pending_shared_offsite_signal_container_business_objects': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'pending_shared_offsite_signal_container_business_objects', + 'id,name', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_managed_partner_ads_funding_source_details': { + const { business_id, limit, after } = BusinessIdWithPaginationSchema.parse(args); + const data = await fetchBusinessEdge( + business_id, + 'managed_partner_ads_funding_source_details', + 'id,name', + limit, + after, + ); + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; + } + + case 'list_agencies': { + const { object_id, limit, after } = ListAgenciesSchema.parse(args); + const params: Record = { fields: 'id,name', limit }; + if (after) params.after = after; + const response = await client.get(`/${object_id}/agencies`, { params }); + return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; + } + + case 'list_assigned_pages': { + const { business_user_id, limit, after } = ListAssignedPagesSchema.parse(args); + const params: Record = { fields: 'id,name,tasks', limit }; + if (after) params.after = after; + const response = await client.get(`/${business_user_id}/assigned_pages`, { params }); + return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; + } + + case 'list_assigned_users': { + const { object_id, business_id, limit, after } = ListAssignedUsersSchema.parse(args); + const params: Record = { + fields: 'id,name,tasks', + business: business_id, + limit, }; + if (after) params.after = after; + const response = await client.get(`/${object_id}/assigned_users`, { params }); + return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] }; } default: