357 lines
15 KiB
TypeScript
357 lines
15 KiB
TypeScript
|
|
// ============================================
|
||
|
|
// Local MCP Client — Wraps Meta Graph API
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
export interface Client {
|
||
|
|
accessToken: string;
|
||
|
|
adAccountId?: string;
|
||
|
|
close: () => Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function createMetaMCPClient(accessToken: string): Promise<Client> {
|
||
|
|
return {
|
||
|
|
accessToken,
|
||
|
|
close: async () => {},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper para requests GET
|
||
|
|
async function fetchGraphGet(path: string, token: string, params: Record<string, any> = {}) {
|
||
|
|
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 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<string, any> = {}) {
|
||
|
|
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<string, any>, 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<string, any> = {}
|
||
|
|
) {
|
||
|
|
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': {
|
||
|
|
resultData = await fetchGraphGet('/me/accounts', token, {
|
||
|
|
fields: 'id,name,access_token,instagram_business_account{id,username,profile_picture_url}',
|
||
|
|
});
|
||
|
|
return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] };
|
||
|
|
}
|
||
|
|
|
||
|
|
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_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;
|
||
|
|
}
|
||
|
|
}
|