metaads-pro/src/lib/mcp/client.ts

357 lines
15 KiB
TypeScript
Raw Normal View History

// ============================================
// 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;
}
}