// ============================================ // Local MCP Client — Wraps Meta Graph API // ============================================ export interface Client { accessToken: string; adAccountId?: string; close: () => Promise; } export async function createMetaMCPClient(accessToken: string): Promise { return { accessToken, close: async () => {}, }; } // Helper para requests GET async function fetchGraphGet(path: string, token: string, params: Record = {}) { const url = new URL(`https://graph.facebook.com/v20.0${path}`); Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, String(value))); url.searchParams.append('access_token', token); const res = await fetch(url.toString(), { method: 'GET' }); const data = await res.json(); if (data.error) throwMetaError(data.error); return data; } // Helper para requests GET que percorre todas as páginas (paging.next) — // necessário pra endpoints como /me/accounts, que retornam só um lote por vez. async function fetchGraphGetAll(path: string, token: string, params: Record = {}) { const first = await fetchGraphGet(path, token, { limit: 100, ...params }); const results: unknown[] = [...(first.data || [])]; let nextUrl: string | undefined = first.paging?.next; while (nextUrl) { const res = await fetch(nextUrl); const data = await res.json(); if (data.error) throwMetaError(data.error); results.push(...(data.data || [])); nextUrl = data.paging?.next; } return results; } // Helper para requests POST com JSON body async function fetchGraphPostJson(path: string, token: string, body: any = {}) { const url = new URL(`https://graph.facebook.com/v20.0${path}`); url.searchParams.append('access_token', token); const res = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (data.error) throwMetaError(data.error); return data; } // Helper para requests POST com Form-UrlEncoded (Obrigatório para a maioria dos endpoints de Ads da Meta) async function fetchGraphPostForm(path: string, token: string, body: Record = {}) { const url = new URL(`https://graph.facebook.com/v20.0${path}`); const formParams = new URLSearchParams(); formParams.append('access_token', token); Object.entries(body).forEach(([key, value]) => { if (value === undefined || value === null) return; // Meta requires nested objects/arrays to be JSON stringified in form data if (typeof value === 'object') { formParams.append(key, JSON.stringify(value)); } else { formParams.append(key, String(value)); } }); const res = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formParams.toString(), }); const data = await res.json(); if (data.error) throwMetaError(data.error); return data; } function throwMetaError(err: any): never { const msg = err.error_user_msg || err.message || 'Unknown Meta API Error'; const fullError = `Meta API Error: ${msg} (Code: ${err.code}, Subcode: ${err.error_subcode || 'N/A'}, Type: ${err.type}, TraceID: ${err.fbtrace_id})`; console.error('[Meta API] Raw error response:', JSON.stringify(err)); throw new Error(fullError); } function requireFields(args: Record, fields: string[], toolName: string) { for (const field of fields) { if (args[field] === undefined || args[field] === null) { throw new Error(`Missing required field '${field}' for tool '${toolName}'`); } } } /** * Mocks the MCP callTool interface but routes to actual Graph API endpoints */ export async function callMetaTool( client: Client, toolName: string, args: Record = {} ) { const token = client.accessToken; // 1. Get Ad Account if not set and not explicitly passed let accountId = args.ad_account_id || client.adAccountId; if (!accountId) { const accounts = await fetchGraphGet('/me/adaccounts', token); if (!accounts.data || accounts.data.length === 0) { return { content: [{ type: 'text', text: JSON.stringify([]) }] }; } accountId = accounts.data[0].id; // Use first ad account as fallback client.adAccountId = accountId; } try { let resultData: any = {}; switch (toolName) { case 'list_campaigns': { const preset = args.date_preset || 'last_30d'; const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,actions,purchase_roas}`; const fields = `id,name,status,daily_budget,objective,buying_type,${ins},adsets{id,name,status,daily_budget,${ins},ads{id,name,status,${ins}}}`; resultData = await fetchGraphGet(`/${accountId}/campaigns`, token, { fields }); return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] }; } case 'list_pages': { const allPages = await fetchGraphGetAll('/me/accounts', token, { fields: 'id,name,access_token,instagram_business_account{id,username,profile_picture_url}', }); return { content: [{ type: 'text', text: JSON.stringify(allPages) }] }; } case 'get_insights_for_account': resultData = await fetchGraphGet(`/${accountId}/insights`, token, { date_preset: args.date_preset || 'last_30d', fields: 'impressions,reach,clicks,inline_link_clicks,spend,cpc,cpm,ctr,actions,cost_per_action_type,purchase_roas,website_ctr', }); return { content: [{ type: 'text', text: JSON.stringify(resultData.data?.[0] || {}) }] }; case 'get_insights_for_campaign': requireFields(args, ['campaign_id'], toolName); resultData = await fetchGraphGet(`/${args.campaign_id}/insights`, token, { date_preset: args.date_preset || 'last_30d', fields: 'impressions,reach,clicks,inline_link_clicks,spend,cpc,cpm,ctr,actions,cost_per_action_type,purchase_roas,website_ctr', }); return { content: [{ type: 'text', text: JSON.stringify(resultData.data?.[0] || {}) }] }; case 'get_adset_default_creative': { requireFields(args, ['adset_id'], toolName); resultData = await fetchGraphGet(`/${args.adset_id}`, token, { fields: 'id,name,ads.limit(1){creative{object_story_spec,asset_feed_spec}}', }); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; } case 'get_campaign_details': { requireFields(args, ['campaign_id'], toolName); const preset = args.date_preset || 'last_30d'; const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,actions,purchase_roas}`; const fields = `id,name,status,daily_budget,objective,buying_type,${ins},adsets{id,name,status,daily_budget,${ins},ads{id,name,status,creative{id,name,image_url,thumbnail_url,body,title,object_story_spec,asset_feed_spec},${ins}}}`; resultData = await fetchGraphGet(`/${args.campaign_id}`, token, { fields }); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; } case 'update_campaign': requireFields(args, ['campaign_id'], toolName); const updateCampPayload: any = {}; if (args.status) updateCampPayload.status = args.status; if (args.daily_budget) updateCampPayload.daily_budget = args.daily_budget; // Only works if CBO resultData = await fetchGraphPostForm(`/${args.campaign_id}`, token, updateCampPayload); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'update_adset': requireFields(args, ['adset_id'], toolName); const updateAdsetPayload: any = {}; if (args.status) updateAdsetPayload.status = args.status; if (args.daily_budget) updateAdsetPayload.daily_budget = args.daily_budget; if (args.bid_amount) updateAdsetPayload.bid_amount = args.bid_amount; resultData = await fetchGraphPostForm(`/${args.adset_id}`, token, updateAdsetPayload); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'update_ad': requireFields(args, ['ad_id'], toolName); const updateAdPayload: any = {}; if (args.status) updateAdPayload.status = args.status; resultData = await fetchGraphPostForm(`/${args.ad_id}`, token, updateAdPayload); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'create_campaign': requireFields(args, ['name', 'objective'], toolName); resultData = await fetchGraphPostForm(`/${accountId}/campaigns`, token, { name: args.name, objective: args.objective, // e.g. OUTCOME_TRAFFIC, OUTCOME_LEADS, OUTCOME_SALES status: args.status || 'PAUSED', special_ad_categories: args.special_ad_categories || [], buying_type: args.buying_type || 'AUCTION', }); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'create_adset': requireFields(args, ['name', 'campaign_id', 'daily_budget'], toolName); if (args.daily_budget < 100) { throw new Error('daily_budget must be in cents (e.g. R$ 20.00 = 2000). Values < 100 are rejected by safety check.'); } const adsetPayload: any = { name: args.name, campaign_id: args.campaign_id, daily_budget: args.daily_budget, billing_event: args.billing_event || 'IMPRESSIONS', optimization_goal: args.optimization_goal || 'REACH', targeting: args.targeting || { geo_locations: { countries: ['BR'] } }, status: args.status || 'PAUSED', }; if (args.promoted_object) { adsetPayload.promoted_object = args.promoted_object; } // Support for Bid Cap (Cost Control) if (args.bid_strategy) { adsetPayload.bid_strategy = args.bid_strategy; // e.g. LOWEST_COST_WITH_BID_CAP } if (args.bid_amount) { if (args.bid_amount < 100) throw new Error('bid_amount must be in cents.'); adsetPayload.bid_amount = args.bid_amount; } resultData = await fetchGraphPostForm(`/${accountId}/adsets`, token, adsetPayload); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'create_ad_creative': requireFields(args, ['name', 'page_id', 'link', 'message'], toolName); if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) { // Asset Customization / Dynamic Creative const assetFeedSpec = { images: args.image_hashes.map((hash: any) => ({ hash })), bodies: [{ text: args.message }], titles: [{ text: args.headline || 'Headline' }], descriptions: [{ text: args.description || '' }], call_to_action_types: [args.call_to_action_type || 'LEARN_MORE'], link_urls: [{ website_url: args.link }] }; // Meta deprecated the bundled `standard_enhancements` opt-out (error subcode 3858504, // "Defina recursos individuais") — Advantage+ Creative features are now left at their // platform defaults rather than specified per-feature here. resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, { name: args.name, asset_feed_spec: assetFeedSpec, }); } else { requireFields(args, ['image_hash'], toolName); const objectStorySpec: any = { page_id: args.page_id, link_data: { image_hash: args.image_hash, link: args.link, message: args.message, name: args.headline || 'Headline', description: args.description || '', call_to_action: { type: args.call_to_action_type || 'LEARN_MORE', value: { link: args.link } } } }; if (args.instagram_actor_id) { objectStorySpec.instagram_actor_id = args.instagram_actor_id; } resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, { name: args.name, object_story_spec: objectStorySpec, }); } return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'create_whatsapp_ad_creative': requireFields(args, ['name', 'page_id', 'message'], toolName); if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) { const assetFeedSpecWA = { images: args.image_hashes.map((hash: any) => ({ hash })), bodies: [{ text: args.message }], titles: [{ text: args.headline || 'Headline' }], descriptions: [{ text: args.description || '' }], call_to_action_types: ['WHATSAPP_MESSAGE'], link_urls: [{ website_url: args.link || `https://wa.me/${args.whatsapp_phone_number}` }] }; resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, { name: args.name, asset_feed_spec: assetFeedSpecWA, }); } else { requireFields(args, ['image_hash'], toolName); const waStorySpec: any = { page_id: args.page_id, link_data: { image_hash: args.image_hash, message: args.message, name: args.headline || 'Headline', description: args.description || '', call_to_action: { type: 'WHATSAPP_MESSAGE', value: { link: args.link || `https://wa.me/${args.whatsapp_phone_number}` } } } }; if (args.instagram_actor_id) { waStorySpec.instagram_actor_id = args.instagram_actor_id; } resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, { name: args.name, object_story_spec: waStorySpec, }); } return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'create_ad': requireFields(args, ['name', 'adset_id', 'creative_id'], toolName); resultData = await fetchGraphPostForm(`/${accountId}/ads`, token, { name: args.name, adset_id: args.adset_id, creative: { creative_id: args.creative_id }, status: args.status || 'PAUSED', }); return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; case 'upload_ad_image': { const formData = new FormData(); formData.append('access_token', token); if (args.image_bytes) { let base64Data = args.image_bytes; let mime = 'image/jpeg'; if (base64Data.startsWith('data:image')) { const parts = base64Data.split(','); mime = parts[0].match(/:(.*?);/)?.[1] || mime; base64Data = parts[1]; } const buffer = Buffer.from(base64Data, 'base64'); formData.append('filename', new Blob([buffer], { type: mime }), 'upload.jpg'); } else if (args.image_url) { const imgRes = await fetch(args.image_url); const imgBuffer = await imgRes.arrayBuffer(); formData.append('filename', new Blob([imgBuffer], { type: imgRes.headers.get('content-type') || 'image/png' })); } else { throw new Error('Must provide either image_url or image_bytes'); } const uploadRes = await fetch(`https://graph.facebook.com/v20.0/${accountId}/adimages`, { method: 'POST', body: formData, }); const uploadData = await uploadRes.json(); if (uploadData.error) throwMetaError(uploadData.error); const hashObj = Object.values(uploadData.images)[0] as any; return { content: [{ type: 'text', text: JSON.stringify({ hash: hashObj?.hash }) }] }; } default: throw new Error(`Tool ${toolName} not implemented in local Graph wrapper`); } } catch (error: any) { console.error(`[Graph API Error] Tool ${toolName}:`, error.message); throw error; } }