@getmcpads/meta-ads-mcp-server 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +147 -108
- package/dist/cli.js +2134 -787
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2141 -794
- package/dist/index.js.map +1 -1
- package/package.json +15 -8
- package/server-card.json +6500 -0
- package/server.json +43 -0
package/dist/cli.js
CHANGED
|
@@ -77,12 +77,816 @@ function isTruthy(value) {
|
|
|
77
77
|
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// src/parameter-descriptions.ts
|
|
81
|
+
var PARAMETER_DESCRIPTIONS = {
|
|
82
|
+
"filters": "Filters combined according to this tool's query schema. Use provider field names and the declared operators.",
|
|
83
|
+
"searchType": "Search surface to report, such as web, image, video, news or Discover. Availability depends on the property.",
|
|
84
|
+
"searchTypes": "Search surfaces to compare. Each surface is queried separately; do not sum overlapping surfaces.",
|
|
85
|
+
"datePreset": "Named reporting period. Use the custom date range when you need exact start and end dates.",
|
|
86
|
+
"dateRange": "Explicit reporting start and end dates in YYYY-MM-DD format.",
|
|
87
|
+
"timeRange": "Explicit provider reporting period with since and until dates.",
|
|
88
|
+
"startDate": "First date of the reporting period, in YYYY-MM-DD format.",
|
|
89
|
+
"endDate": "Last date of the reporting period, in YYYY-MM-DD format, on or after startDate.",
|
|
90
|
+
"since": "Beginning of the requested reporting period; use the format accepted by the provider.",
|
|
91
|
+
"until": "End of the requested reporting period; use the format accepted by the provider.",
|
|
92
|
+
"orderBy": "Field used to order report rows.",
|
|
93
|
+
"orderField": "Provider field used to order report rows.",
|
|
94
|
+
"orderDirection": "Sort direction for the selected ordering field.",
|
|
95
|
+
"orderType": "Sort direction for the selected ordering field.",
|
|
96
|
+
"sortBy": "Metric or dimension used to rank the returned results.",
|
|
97
|
+
"sort": "Native provider ordering expressions.",
|
|
98
|
+
"limit": "Maximum number of returned items or rows. The declared bounds and default apply; use pagination for additional results.",
|
|
99
|
+
"topN": "Maximum number of highest-ranked results to return.",
|
|
100
|
+
"pageSize": "Maximum items requested per page. Follow the returned pagination information for remaining results.",
|
|
101
|
+
"page": "Page number to request, starting at 1.",
|
|
102
|
+
"offset": "Zero-based number of rows to skip before returning results.",
|
|
103
|
+
"bookmark": "Opaque Pinterest pagination bookmark returned by the previous response. Omit on the first request.",
|
|
104
|
+
"after": "Opaque cursor for the next page returned by Meta.",
|
|
105
|
+
"before": "Opaque cursor for the previous page returned by Meta.",
|
|
106
|
+
"includeUnchanged": "Include entries with no observed change in the comparison.",
|
|
107
|
+
"minImpressions": "Minimum reported impressions required for a row to enter the analysis.",
|
|
108
|
+
"objectives": "Analysis goals that determine the recommended large-site sampling strategy.",
|
|
109
|
+
"pageLimit": "Maximum number of pages considered for the sampling plan.",
|
|
110
|
+
"maxInspectionUrls": "Maximum URL Inspection candidates included in the plan; inspection calls consume provider quota.",
|
|
111
|
+
"minImpressionsForLowCtr": "Minimum impressions before a page is considered for low-CTR analysis.",
|
|
112
|
+
"staleSitemapDays": "Number of days after which a sitemap's last download is considered stale.",
|
|
113
|
+
"continueOnError": "Continue with other URLs when one inspection fails, returning individual failures.",
|
|
114
|
+
"dimension": "Reporting dimension used to group and compare results.",
|
|
115
|
+
"maxClusters": "Maximum query clusters to return.",
|
|
116
|
+
"topQueriesPerCluster": "Maximum example queries returned for each cluster.",
|
|
117
|
+
"minPages": "Minimum distinct pages ranking for a query before it is considered a cannibalization candidate.",
|
|
118
|
+
"adAccountId": "Owning advertising account ID. Use the exact ID returned by account discovery; do not substitute a campaign or business ID.",
|
|
119
|
+
"advertiserId": "TikTok advertiser account ID as a string.",
|
|
120
|
+
"customerId": "Google Ads customer ID, without hyphens.",
|
|
121
|
+
"propertyId": "Google Analytics property ID; use the property, not the account or measurement ID.",
|
|
122
|
+
"level": "Entity or aggregation level for the requested report.",
|
|
123
|
+
"dataLevel": "TikTok reporting aggregation level. Metrics and dimensions must be compatible with this level.",
|
|
124
|
+
"granularity": "Time bucket used to aggregate reporting rows.",
|
|
125
|
+
"columns": "Native Pinterest reporting column names. Discover supported columns before selecting metrics.",
|
|
126
|
+
"executionMode": "Choose the supported synchronous, asynchronous or automatic report execution strategy.",
|
|
127
|
+
"entityIds": "Exact IDs of the entities to include in the selected reporting level.",
|
|
128
|
+
"targetingTypes": "Native targeting categories used to break down delivery.",
|
|
129
|
+
"clickWindowDays": "Click-through attribution window, in days.",
|
|
130
|
+
"engagementWindowDays": "Engagement-through attribution window, in days.",
|
|
131
|
+
"viewWindowDays": "View-through attribution window, in days.",
|
|
132
|
+
"conversionReportTime": "Whether conversions are assigned to the ad action date or the conversion date.",
|
|
133
|
+
"attributionTypes": "Provider attribution categories included in the report.",
|
|
134
|
+
"reportingTimezone": "Provider reporting timezone selector. Keep it consistent when comparing periods.",
|
|
135
|
+
"campaignIds": "Restrict results to these campaign IDs.",
|
|
136
|
+
"adGroupIds": "Restrict results to these ad group IDs.",
|
|
137
|
+
"adgroupIds": "Restrict TikTok results to these ad group IDs.",
|
|
138
|
+
"adIds": "Restrict results to these ad IDs.",
|
|
139
|
+
"campaignId": "Exact campaign ID in the selected advertising account.",
|
|
140
|
+
"adGroupId": "Exact ad group ID in the selected advertising account.",
|
|
141
|
+
"adSetId": "Exact Meta ad set ID in the selected advertising account.",
|
|
142
|
+
"adId": "Exact ad ID in the selected advertising account.",
|
|
143
|
+
"entityId": "Exact ID of the entity selected by this operation. It must belong to the selected account.",
|
|
144
|
+
"onlyWithPeriodDelivery": "Keep only creatives with observed delivery in the requested date range.",
|
|
145
|
+
"breakdown": "Native reporting breakdown used to group results.",
|
|
146
|
+
"breakdowns": "Native reporting breakdowns. Check compatibility before combining them.",
|
|
147
|
+
"productGroupIds": "Restrict reporting to these product group IDs.",
|
|
148
|
+
"productItemIds": "Restrict reporting to these product item IDs.",
|
|
149
|
+
"includeProductSamples": "Include a limited sample of product records alongside inventory diagnostics.",
|
|
150
|
+
"sampleProductGroups": "Maximum product groups to sample for inventory diagnostics.",
|
|
151
|
+
"reportName": "Provider report name identifying the requested report recipe.",
|
|
152
|
+
"conversionProductAttributionType": "Pinterest product-conversion attribution category.",
|
|
153
|
+
"conversionProductBreakdown": "Pinterest product-conversion grouping level.",
|
|
154
|
+
"productSkuIds": "Restrict product-conversion reporting to these SKU IDs.",
|
|
155
|
+
"request": "Exact request schema name to inspect before composing a write payload.",
|
|
156
|
+
"entity": "Entity collection or level to inspect.",
|
|
157
|
+
"entityType": "Provider entity type to retrieve.",
|
|
158
|
+
"entityStatuses": "Native status values used to filter the entity collection.",
|
|
159
|
+
"statusFilter": "Native status values used to filter the returned entities.",
|
|
160
|
+
"targetingType": "Targeting dictionary to inspect, such as interests, locations or demographics.",
|
|
161
|
+
"interestId": "Native interest ID for the targeting lookup.",
|
|
162
|
+
"mode": "Operation or analysis mode. Only the values declared in this schema are supported.",
|
|
163
|
+
"matchTypes": "Keyword match types included in the lookup.",
|
|
164
|
+
"countryCode": "Country code used to localize keyword or targeting results.",
|
|
165
|
+
"keywords": "Keyword phrases to analyze; use the language and market of the intended audience.",
|
|
166
|
+
"term": "Search term used for the keyword lookup.",
|
|
167
|
+
"terms": "Search terms used for the keyword lookup.",
|
|
168
|
+
"audienceId": "Exact ID of the audience to inspect or modify.",
|
|
169
|
+
"customerListId": "Exact customer-list ID. Reading metadata does not upload customer data.",
|
|
170
|
+
"businessId": "Exact business account ID used for the lookup.",
|
|
171
|
+
"accountType": "Provider account category used to filter results.",
|
|
172
|
+
"ownershipType": "Filter audiences by provider ownership category.",
|
|
173
|
+
"excludeNca": "Whether to exclude Pinterest new-customer-acquisition audiences from this lookup.",
|
|
174
|
+
"audienceInsightType": "Pinterest audience-insights category to request.",
|
|
175
|
+
"surfaces": "Conversion-setup surfaces to inspect, according to the supported values.",
|
|
176
|
+
"includeDeletedTags": "Include deleted conversion tags in diagnostics.",
|
|
177
|
+
"lookbackPeriod": "Provider lookback period used for conversion diagnostics.",
|
|
178
|
+
"sourcePlatform": "Filter conversion data by source platform.",
|
|
179
|
+
"ingestionSource": "Filter conversion events by ingestion source.",
|
|
180
|
+
"catalogId": "Exact product catalog ID accessible to the selected account.",
|
|
181
|
+
"feedId": "Exact catalog feed ID.",
|
|
182
|
+
"processingResultId": "Exact catalog feed processing result ID to inspect.",
|
|
183
|
+
"productGroupId": "Exact product group ID.",
|
|
184
|
+
"productSetId": "Exact Meta product set ID belonging to the selected catalog.",
|
|
185
|
+
"exportType": "Provider export category to request.",
|
|
186
|
+
"action": "Supported operation within this tool's scoped provider surface.",
|
|
187
|
+
"includeDetails": "Include detailed provider records in addition to summary information.",
|
|
188
|
+
"leadFormId": "Exact lead form ID to inspect.",
|
|
189
|
+
"subscriptionId": "Exact subscription ID to inspect.",
|
|
190
|
+
"assetId": "Exact provider asset ID.",
|
|
191
|
+
"memberId": "Exact business member ID used for the read lookup.",
|
|
192
|
+
"partnerId": "Exact business partner ID used for the read lookup.",
|
|
193
|
+
"pinId": "Exact Pinterest pin ID.",
|
|
194
|
+
"pinIds": "Exact Pinterest pin IDs to include.",
|
|
195
|
+
"metrics": "Native or documented calculated metric names to request. Check compatibility with the selected dimensions.",
|
|
196
|
+
"metric": "Metric used for the analysis; use a name from the platform metric catalogue.",
|
|
197
|
+
"boardId": "Exact Pinterest board ID.",
|
|
198
|
+
"searchTerm": "Text used to filter or search the selected collection.",
|
|
199
|
+
"region": "Provider region selector used to localize trends.",
|
|
200
|
+
"productRegion": "Region used for product trend analysis.",
|
|
201
|
+
"trendType": "Provider trend category to return.",
|
|
202
|
+
"interests": "Native interest categories used to filter trends.",
|
|
203
|
+
"genders": "Native gender categories used to filter aggregate trends.",
|
|
204
|
+
"ageBuckets": "Native age groups used to filter aggregate trends.",
|
|
205
|
+
"includeKeywords": "Include related keyword details in the trend response.",
|
|
206
|
+
"includeDemographics": "Include available aggregate demographic breakdowns.",
|
|
207
|
+
"productCategories": "Native product category IDs or values used to filter trends.",
|
|
208
|
+
"verticals": "Native business verticals used to filter trends.",
|
|
209
|
+
"productLookbackDays": "Product-trend lookback window, in days.",
|
|
210
|
+
"productEngagementType": "Engagement signal used for product-trend analysis.",
|
|
211
|
+
"featuredInterest": "Featured interest category for the trends lookup.",
|
|
212
|
+
"reportType": "Native provider report type; determines supported metrics and dimensions.",
|
|
213
|
+
"promotionId": "Exact Pinterest product-group promotion ID.",
|
|
214
|
+
"type": "Provider object or operation type selected from this schema's allowed values.",
|
|
215
|
+
"productBreakdown": "Native product grouping used to join catalog items with reported insights.",
|
|
216
|
+
"catalogProductLimit": "Maximum catalog product records read for the join.",
|
|
217
|
+
"includeAdsets": "Include ad set configuration when checking brand safety.",
|
|
218
|
+
"includeBlockLists": "Include accessible brand-safety block-list metadata.",
|
|
219
|
+
"includeRawTargeting": "Include the native targeting specification for diagnostics.",
|
|
220
|
+
"cellEntityType": "Entity type represented by experiment cells.",
|
|
221
|
+
"includePagePosts": "Read related Facebook Page posts when the token has permission.",
|
|
222
|
+
"includeInstagramMedia": "Read related Instagram media when the token has permission.",
|
|
223
|
+
"includeInsights": "Include available insight metrics; additional provider permissions may be required.",
|
|
224
|
+
"edge": "Allowlisted Meta Graph edge to read on the selected node.",
|
|
225
|
+
"fields": "Native provider fields to return. Select only fields supported by the chosen object or report.",
|
|
226
|
+
"filtering": "Native provider filter expressions; credentials and account overrides are not allowed.",
|
|
227
|
+
"includeSummary": "Request the provider's available summary or total-count metadata.",
|
|
228
|
+
"actionBreakdowns": "Meta action-level breakdowns, subject to Insights compatibility rules.",
|
|
229
|
+
"actionAttributionWindows": "Meta attribution windows used to attribute action metrics.",
|
|
230
|
+
"timeIncrement": "Provider time-bucket size or supported aggregate value.",
|
|
231
|
+
"locationTypes": "Location dictionary categories to include in targeting search.",
|
|
232
|
+
"adFormat": "Native Meta ad preview format; must be compatible with this ad's creative.",
|
|
233
|
+
"handle": "Asynchronous catalog batch handle returned by the submission to inspect.",
|
|
234
|
+
"keepEmptyRows": "Keep provider rows whose selected metrics are all zero, where supported.",
|
|
235
|
+
"sampleLimit": "Maximum records included in the diagnostic sample.",
|
|
236
|
+
"isOpenFunnel": "Allow users to enter at any funnel step instead of requiring the first step.",
|
|
237
|
+
"visualizationType": "Supported funnel visualization mode.",
|
|
238
|
+
"breakdownLimit": "Maximum breakdown values to include in the funnel response.",
|
|
239
|
+
"nextActionLimit": "Maximum next actions returned for each funnel step.",
|
|
240
|
+
"fallbackToStepCounts": "If the advanced funnel is unavailable, return independent step counts with the limitation made explicit.",
|
|
241
|
+
"report": "Native GA4 report request object for this endpoint.",
|
|
242
|
+
"reports": "Native GA4 report request objects to execute in one batch.",
|
|
243
|
+
"dimensions": "Native reporting dimensions. Validate compatibility with the selected metrics.",
|
|
244
|
+
"compatibilityFilter": "Limit compatibility results to the requested GA4 compatibility category.",
|
|
245
|
+
"collection": "Allowlisted administrative resource collection to read.",
|
|
246
|
+
"settings": "Property configuration surfaces to inspect.",
|
|
247
|
+
"includeRecurring": "Include recurring audience-export configurations.",
|
|
248
|
+
"audienceExportName": "Full GA4 audience-export resource name returned by list_audience_exports.",
|
|
249
|
+
"queryLifetime": "Request lifetime reporting instead of the explicit reporting period where TikTok supports it.",
|
|
250
|
+
"declineThresholdPct": "Percentage decline used as a candidate signal for creative fatigue, requiring further evidence.",
|
|
251
|
+
"includeSavedAudiences": "Include accessible saved-audience metadata in the overlap analysis.",
|
|
252
|
+
"overlapThreshold": "Overlap threshold used to flag audience pairs, in the units declared by this schema.",
|
|
253
|
+
"serviceType": "TikTok report service category.",
|
|
254
|
+
"catalog": "Allowlisted targeting dictionary or catalog to inspect.",
|
|
255
|
+
"query": "Search text used to find matching entries in the selected catalogue.",
|
|
256
|
+
"parameters": "Native query parameters for this allowlisted read endpoint. Do not provide credentials or account overrides.",
|
|
257
|
+
"account_id": "Exact account ID selected for this platform in the GetMCPAds workspace.",
|
|
258
|
+
"platform": "Platform key identifying the connected advertising or analytics source.",
|
|
259
|
+
"id": "Exact saved object ID returned by the corresponding create or list tool.",
|
|
260
|
+
"revision": "Last known object revision, used to reject conflicting updates. Read the object before editing.",
|
|
261
|
+
"name": "Human-readable name for this object.",
|
|
262
|
+
"title": "Human-readable title for the resulting object or review.",
|
|
263
|
+
"clientLabel": "Display label identifying the client in the saved view.",
|
|
264
|
+
"config": "Complete saved-view configuration, including source, selected account, period and display options.",
|
|
265
|
+
"subject": "Entity or creative subject whose dated performance will be analyzed.",
|
|
266
|
+
"compare_previous": "Include the immediately preceding period of equal duration for comparison.",
|
|
267
|
+
"profile": "Creative brief profile containing the client's business context and analysis preferences.",
|
|
268
|
+
"primaryMetric": "Metric used to rank or evaluate the review; it must be available for the selected platform.",
|
|
269
|
+
"direction": "Whether higher or lower values of the primary metric represent improvement.",
|
|
270
|
+
"creative_ids": "Exact creative IDs to include in this review.",
|
|
271
|
+
"request_id": "Caller-generated request identifier. Reuse it for a retry of the same operation to avoid duplicate work.",
|
|
272
|
+
"expiresInDays": "Number of days before the public review sharing link expires.",
|
|
273
|
+
"client_id": "Exact saved business-client workspace ID.",
|
|
274
|
+
"review_id": "Exact review ID returned by the review creation or listing tool.",
|
|
275
|
+
"page_id": "Exact page identifier within the landing-page review.",
|
|
276
|
+
"entity_id": "Exact entity identifier in the selected review or platform context.",
|
|
277
|
+
"source_key": "Source identifier returned by the business review; keep the platform/account association intact.",
|
|
278
|
+
"metric_key": "Exact metric key in the review evidence.",
|
|
279
|
+
"follow_up_on": "Follow-up date for the decision, using the format declared in this schema.",
|
|
280
|
+
"hypothesis": "Specific hypothesis to record, supported by the review evidence.",
|
|
281
|
+
"successCriterion": "Measurable condition used to decide whether the recorded action succeeded.",
|
|
282
|
+
"outcome": "Observed outcome to record; distinguish evidence from an untested expectation.",
|
|
283
|
+
"status": "Status selected from the allowed values for this object or operation.",
|
|
284
|
+
"retry_source": "Source identifier to retry after reviewing its reported error.",
|
|
285
|
+
"site_origin": "Website origin to analyze, including https:// and hostname, without credentials or a path.",
|
|
286
|
+
"matchType": "Google Ads keyword match type for the forecast.",
|
|
287
|
+
"negativeKeywords": "Keyword phrases excluded from the keyword forecast.",
|
|
288
|
+
"negativeMatchType": "Match type applied to the negative keywords.",
|
|
289
|
+
"geoTargetIds": "Google Ads geo-target constant IDs for the intended locations.",
|
|
290
|
+
"languageId": "Google Ads language constant ID for the keyword request.",
|
|
291
|
+
"languageIds": "Google Ads language constant IDs for the intended audience.",
|
|
292
|
+
"network": "Google Ads search network selector used for planning.",
|
|
293
|
+
"biddingStrategy": "Supported bidding strategy for this temporary forecast. The request does not create a live campaign.",
|
|
294
|
+
"includeAdultKeywords": "Allow adult keyword ideas where supported by the provider and the selected market.",
|
|
295
|
+
"seedKeywords": "Seed keyword phrases from which Google generates keyword ideas.",
|
|
296
|
+
"includeAverageCpc": "Request average cost-per-click metrics when available.",
|
|
297
|
+
"includeDeviceBreakdown": "Include device-specific planning metrics when supported.",
|
|
298
|
+
"includeKeywordConcepts": "Include Google's keyword concept grouping metadata.",
|
|
299
|
+
"startYearMonth": "First year and month of the historical metrics period.",
|
|
300
|
+
"endYearMonth": "Last year and month of the historical metrics period.",
|
|
301
|
+
"includeDismissed": "Include recommendations already dismissed in the account.",
|
|
302
|
+
"operation": "Exact allowlisted operation to execute. Arbitrary provider operations are not accepted.",
|
|
303
|
+
"category": "Category selected from the provider dictionary or field catalogue.",
|
|
304
|
+
"selectable": "Filter the field catalogue by whether the field can appear in SELECT.",
|
|
305
|
+
"filterable": "Filter the field catalogue by whether the field can appear in WHERE.",
|
|
306
|
+
"sortable": "Filter the field catalogue by whether the field can appear in ORDER BY.",
|
|
307
|
+
"pageToken": "Opaque continuation token returned by the preceding provider response.",
|
|
308
|
+
"locationNames": "Human-readable location names for Google to resolve into geo-target constants.",
|
|
309
|
+
"locale": "Locale used for human-readable lookup results.",
|
|
310
|
+
"resource": "Native Google Ads resource from which the GAQL query selects rows.",
|
|
311
|
+
"cursor": "Opaque pagination cursor returned by the preceding response; omit for the first page.",
|
|
312
|
+
"kind": "Entity or request kind whose schema or data is being requested.",
|
|
313
|
+
"startTime": "Beginning of the reporting period as an ISO 8601 timestamp with an explicit timezone.",
|
|
314
|
+
"endTime": "End of the reporting period as an ISO 8601 timestamp with an explicit timezone, after startTime.",
|
|
315
|
+
"reportDimension": "Native dimension used to break down the Snapchat report."
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// src/tool-quality.ts
|
|
319
|
+
import { z as z2 } from "zod";
|
|
320
|
+
var RESULT_SCHEMA = {
|
|
321
|
+
type: "object",
|
|
322
|
+
properties: {
|
|
323
|
+
result: {
|
|
324
|
+
description: "Original tool result: parsed JSON when the text is JSON, otherwise the text or multiple MCP content blocks. Provider fields depend on the selected query.",
|
|
325
|
+
type: ["object", "array", "string", "number", "boolean", "null"]
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
required: ["result"],
|
|
329
|
+
additionalProperties: true
|
|
330
|
+
};
|
|
331
|
+
function toolAnnotations(write) {
|
|
332
|
+
return { readOnlyHint: !write, destructiveHint: write, idempotentHint: !write, openWorldHint: true };
|
|
333
|
+
}
|
|
334
|
+
function structuredResult(value) {
|
|
335
|
+
if (value.isError) return value;
|
|
336
|
+
let result = value.content;
|
|
337
|
+
const first = value.content[0];
|
|
338
|
+
if (value.content.length === 1 && first?.type === "text" && typeof first.text === "string") {
|
|
339
|
+
result = first.text;
|
|
340
|
+
try {
|
|
341
|
+
result = JSON.parse(first.text);
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { ...value, structuredContent: { ...value.structuredContent, result } };
|
|
346
|
+
}
|
|
347
|
+
var resultShape = { result: z2.union([z2.object({}).passthrough(), z2.array(z2.unknown()), z2.string(), z2.number(), z2.boolean(), z2.null()]).describe(RESULT_SCHEMA.properties.result.description) };
|
|
348
|
+
function installToolQuality(server) {
|
|
349
|
+
server.tool = ((name2, description, inputSchema, handler) => {
|
|
350
|
+
const write = Object.hasOwn(inputSchema, "confirm");
|
|
351
|
+
inputSchema = Object.fromEntries(Object.entries(inputSchema).map(([key, schema]) => [key, schema.description || PARAMETER_DESCRIPTIONS[key] ? schema.describe(schema.description || PARAMETER_DESCRIPTIONS[key]) : schema]));
|
|
352
|
+
return server.registerTool(name2, {
|
|
353
|
+
title: name2.replace(/_/g, " "),
|
|
354
|
+
description,
|
|
355
|
+
inputSchema,
|
|
356
|
+
outputSchema: resultShape,
|
|
357
|
+
annotations: toolAnnotations(write)
|
|
358
|
+
}, async (...args) => structuredResult(await handler(...args)));
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
80
362
|
// src/server.ts
|
|
81
363
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
82
364
|
|
|
83
|
-
// src/platforms/meta/
|
|
365
|
+
// src/platforms/meta/extended-writes.ts
|
|
84
366
|
import { z as z3 } from "zod";
|
|
85
367
|
|
|
368
|
+
// src/core/money.ts
|
|
369
|
+
function guard(amount2, currency2) {
|
|
370
|
+
if (!Number.isFinite(amount2)) throw new Error(`Amount must be a finite number, received ${amount2}.`);
|
|
371
|
+
if (amount2 <= 0) throw new Error(`Amount must be greater than zero, received ${amount2}.`);
|
|
372
|
+
if (amount2 > 1e6) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Amount ${amount2} ${currency2} is above the safety ceiling of 1,000,000. If this is intentional, make the change in the platform's own interface.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return amount2;
|
|
378
|
+
}
|
|
379
|
+
var ZERO_DECIMAL = /* @__PURE__ */ new Set(["JPY", "KRW", "CLP", "ISK", "VND", "UGX", "PYG", "RWF", "XOF", "XAF", "XPF", "BIF", "DJF", "GNF", "KMF", "MGA", "VUV"]);
|
|
380
|
+
function toMinorUnits(amount2, currency2) {
|
|
381
|
+
const code = currency2.trim().toUpperCase();
|
|
382
|
+
if (!/^[A-Z]{3}$/.test(code)) throw new Error(`Expected a three letter currency code, received "${currency2}".`);
|
|
383
|
+
const factor = ZERO_DECIMAL.has(code) ? 1 : 100;
|
|
384
|
+
return Math.round(guard(amount2, code) * factor);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/platforms/meta/extended-writes.ts
|
|
388
|
+
var parameterDescriptions = {
|
|
389
|
+
adAccountId: "Selected owning ad account ID, with or without act_. Every referenced ad object must belong to it.",
|
|
390
|
+
adSetId: "Numeric ID of the existing ad set in the selected account.",
|
|
391
|
+
campaignId: "Numeric ID of the parent campaign in the selected account. Its budget model and objective determine compatible ad set settings.",
|
|
392
|
+
creativeId: "Numeric ID of an existing ad creative in the selected account; not an image hash or video ID.",
|
|
393
|
+
adId: "Numeric ID of the existing ad to modify in the selected account.",
|
|
394
|
+
name: "Exact business name to give this object. Do not derive it from an uploaded filename.",
|
|
395
|
+
title: "Optional title for the uploaded video.",
|
|
396
|
+
configuration: "Explicit native Meta ad set settings. First read meta_get_adset_configuration for edits. A supplied targeting object replaces the entire targeting spec.",
|
|
397
|
+
dailyBudget: "Daily budget in major account-currency units, e.g. 10 means EUR 10. Cannot be combined with lifetimeBudget or a campaign-owned budget.",
|
|
398
|
+
lifetimeBudget: "Total lifetime budget in major account-currency units. Requires an end time; mutually exclusive with dailyBudget.",
|
|
399
|
+
bidAmount: "Bid amount in major account-currency units. Only use with a compatible capped bidding strategy.",
|
|
400
|
+
spec: "Native Meta creative specification: exactly one existing post ID or an object_story_spec with page_id. Put image/video/carousel content in that spec; asset_feed_spec is optional.",
|
|
401
|
+
trackingSpecs: "Optional native Meta tracking specifications. Do not supply credentials.",
|
|
402
|
+
bytesBase64: "Base64 media bytes without a data-URL prefix. Maximum decoded size 5 MiB; omitted from preview output.",
|
|
403
|
+
fileUrl: "Publicly reachable HTTPS video file URL. Must point to the media file, not a landing page or a logged-in player.",
|
|
404
|
+
subtype: "Audience type. CUSTOM creates an empty customer-list container; WEBSITE and ENGAGEMENT require a rule; LOOKALIKE requires an origin and lookalikeSpec.",
|
|
405
|
+
description: "Description of the audience purpose.",
|
|
406
|
+
rule: "Complete native Meta audience rule. Replaces the existing rule on update.",
|
|
407
|
+
retentionDays: "Audience retention in days, from 1 to 180; Meta enforces eligibility for the selected subtype.",
|
|
408
|
+
pixelId: "Numeric ID of the pixel used by a website audience rule.",
|
|
409
|
+
originAudienceId: "Numeric ID of the source audience in this same ad account.",
|
|
410
|
+
lookalikeSpec: "Native lookalike specification, including explicit geography and size. Subject to Meta eligibility.",
|
|
411
|
+
customerFileSource: "Actual provenance of future customer-list data. Creating the container does not upload customer data.",
|
|
412
|
+
audienceId: "Numeric ID of the existing custom audience in this selected ad account.",
|
|
413
|
+
catalogId: "Numeric catalog ID. Writes require the catalog and selected ad account to have the same owning Business.",
|
|
414
|
+
productSetId: "Numeric product set ID belonging to catalogId.",
|
|
415
|
+
filter: "Complete native product-set filter. Updates replace the existing filter.",
|
|
416
|
+
requests: "One to 50 product CREATE or UPDATE requests with retailer_id and native product data. No DELETE; inspect the asynchronous batch result.",
|
|
417
|
+
startTime: "Optional new start time as ISO 8601 with explicit UTC offset.",
|
|
418
|
+
endTime: "Optional new end time as ISO 8601 with explicit UTC offset, after the start."
|
|
419
|
+
};
|
|
420
|
+
var id = z3.string().regex(/^\d+$/, "Use a numeric Meta ID");
|
|
421
|
+
var accountId = z3.string().regex(/^(act_)?\d+$/);
|
|
422
|
+
var json = z3.record(z3.unknown());
|
|
423
|
+
var name = z3.string().trim().min(1).max(400);
|
|
424
|
+
var date = z3.string().datetime({ offset: true });
|
|
425
|
+
var amount = z3.number().positive().max(1e6);
|
|
426
|
+
var currency = z3.string().regex(/^[A-Z]{3}$/);
|
|
427
|
+
var mode = { confirm: z3.boolean().optional().describe("Apply only when true. Default is a local preview with no API mutation."), validateOnly: z3.boolean().optional().describe("Ask Meta to validate, without creating or changing an object. Mutually exclusive with confirm.") };
|
|
428
|
+
var money = { currency: currency.optional().describe("Required with any monetary amount. Must match the actual account currency; never assume EUR."), dailyBudget: amount.optional(), lifetimeBudget: amount.optional(), bidAmount: amount.optional() };
|
|
429
|
+
var adsetConfig = z3.object({
|
|
430
|
+
optimization_goal: z3.string().min(1).optional(),
|
|
431
|
+
billing_event: z3.string().min(1).optional(),
|
|
432
|
+
targeting: json.optional().describe("Complete Meta targeting spec: geography, demographics, custom audiences, exclusions, placements and targeting_automation. Updates replace the entire targeting spec."),
|
|
433
|
+
promoted_object: json.optional(),
|
|
434
|
+
destination_type: z3.string().optional(),
|
|
435
|
+
bid_strategy: z3.enum(["LOWEST_COST_WITHOUT_CAP", "LOWEST_COST_WITH_BID_CAP", "COST_CAP", "LOWEST_COST_WITH_MIN_ROAS"]).optional(),
|
|
436
|
+
bid_constraints: json.optional(),
|
|
437
|
+
start_time: date.optional(),
|
|
438
|
+
end_time: date.optional(),
|
|
439
|
+
attribution_spec: z3.array(json).max(10).optional(),
|
|
440
|
+
attribution_count_type: z3.string().optional(),
|
|
441
|
+
dsa_beneficiary: name.optional(),
|
|
442
|
+
dsa_payor: name.optional(),
|
|
443
|
+
is_dynamic_creative: z3.boolean().optional(),
|
|
444
|
+
pacing_type: z3.array(z3.string()).optional(),
|
|
445
|
+
adset_schedule: z3.array(json).optional(),
|
|
446
|
+
frequency_control_specs: z3.array(json).optional(),
|
|
447
|
+
optimization_sub_event: z3.string().optional(),
|
|
448
|
+
is_incremental_attribution_enabled: z3.boolean().optional()
|
|
449
|
+
}).strict();
|
|
450
|
+
var creativeSpec = z3.object({
|
|
451
|
+
object_story_id: z3.string().regex(/^\d+_\d+$/).optional(),
|
|
452
|
+
object_story_spec: json.optional(),
|
|
453
|
+
asset_feed_spec: json.optional(),
|
|
454
|
+
instagram_user_id: id.optional(),
|
|
455
|
+
product_set_id: id.optional(),
|
|
456
|
+
degrees_of_freedom_spec: json.optional(),
|
|
457
|
+
url_tags: z3.string().max(4e3).optional(),
|
|
458
|
+
contextual_multi_ads: json.optional(),
|
|
459
|
+
dynamic_ad_voice: z3.string().optional(),
|
|
460
|
+
image_crops: json.optional(),
|
|
461
|
+
template_url: z3.string().url().optional(),
|
|
462
|
+
template_url_spec: json.optional()
|
|
463
|
+
}).strict();
|
|
464
|
+
var out = (data, isError = false) => ({ ...isError ? { isError: true } : {}, content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
|
|
465
|
+
var act = (v) => v.replace(/^act_/, "");
|
|
466
|
+
var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
467
|
+
var has = (v) => v !== void 0 && v !== null;
|
|
468
|
+
function clean(v) {
|
|
469
|
+
return Object.fromEntries(Object.entries(v).filter(([, value]) => value !== void 0));
|
|
470
|
+
}
|
|
471
|
+
function mediaDifferences(requested, returned, path = "") {
|
|
472
|
+
if (!requested || typeof requested !== "object") return [];
|
|
473
|
+
return Object.entries(requested).flatMap(([key, value]) => {
|
|
474
|
+
const field = path ? `${path}.${key}` : key, actual = returned?.[key];
|
|
475
|
+
if ((["image_hash", "video_id", "photo_id"].includes(key) || key === "hash" && path.startsWith("asset_feed_spec.images.")) && value !== actual) return [{ field, requested: value, returned: actual ?? null }];
|
|
476
|
+
return mediaDifferences(value, actual, field);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
function safeJson(value, depth = 0) {
|
|
480
|
+
if (depth > 15) throw new Error("Specification is too deeply nested.");
|
|
481
|
+
if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) {
|
|
482
|
+
if (["access_token", "appsecret_proof", "__proto__", "constructor", "prototype"].includes(k)) throw new Error(`Forbidden specification key: ${k}`);
|
|
483
|
+
safeJson(v, depth + 1);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function validateAdsetState(p) {
|
|
487
|
+
if (p.start_time && p.end_time && Date.parse(p.end_time) <= Date.parse(p.start_time)) throw new Error("end_time must be after start_time.");
|
|
488
|
+
if (Number(p.lifetime_budget) > 0 && !p.end_time) throw new Error("A lifetime budget requires end_time.");
|
|
489
|
+
if (p.bid_strategy === "LOWEST_COST_WITHOUT_CAP" && Number(p.bid_amount) > 0) throw new Error("Automatic lowest-cost bidding cannot include a bid cap.");
|
|
490
|
+
if (["COST_CAP", "LOWEST_COST_WITH_BID_CAP"].includes(p.bid_strategy) && !(Number(p.bid_amount) > 0)) throw new Error("This bidding strategy requires bidAmount.");
|
|
491
|
+
if (p.bid_strategy === "LOWEST_COST_WITH_MIN_ROAS" && !(Number(obj(p.bid_constraints).roas_average_floor) > 0)) throw new Error("Minimum ROAS bidding requires bid_constraints.roas_average_floor.");
|
|
492
|
+
}
|
|
493
|
+
function buildAdsetPayload(a, creating) {
|
|
494
|
+
const config = adsetConfig.parse(a.configuration ?? {});
|
|
495
|
+
safeJson(config);
|
|
496
|
+
if (has(a.dailyBudget) && has(a.lifetimeBudget)) throw new Error("Choose dailyBudget OR lifetimeBudget, not both.");
|
|
497
|
+
if ([a.dailyBudget, a.lifetimeBudget, a.bidAmount].some(has) && !a.currency) throw new Error("Supply the account currency for monetary amounts.");
|
|
498
|
+
const p = { ...config };
|
|
499
|
+
for (const [from, to] of [["dailyBudget", "daily_budget"], ["lifetimeBudget", "lifetime_budget"], ["bidAmount", "bid_amount"]]) if (has(a[from])) {
|
|
500
|
+
p[to] = toMinorUnits(Number(a[from]), String(a.currency));
|
|
501
|
+
if (p[to] < 1) throw new Error(`${from} rounds to zero in ${a.currency}.`);
|
|
502
|
+
}
|
|
503
|
+
if (creating) {
|
|
504
|
+
p.campaign_id = a.campaignId;
|
|
505
|
+
p.name = a.name;
|
|
506
|
+
p.status = "PAUSED";
|
|
507
|
+
for (const field of ["optimization_goal", "billing_event", "targeting"]) if (!p[field]) throw new Error(`${field} is required to create an ad set.`);
|
|
508
|
+
}
|
|
509
|
+
if (p.start_time && p.end_time && Date.parse(p.end_time) <= Date.parse(p.start_time)) throw new Error("end_time must be after start_time.");
|
|
510
|
+
if (creating && p.lifetime_budget && !p.end_time) throw new Error("A lifetime budget requires end_time.");
|
|
511
|
+
const targeting = obj(p.targeting);
|
|
512
|
+
if (obj(p.promoted_object).custom_conversion_id && obj(p.promoted_object).pixel_id) throw new Error("For a custom conversion use custom_conversion_id without pixel_id. Meta resolves the pixel through the conversion.");
|
|
513
|
+
if (p.targeting) {
|
|
514
|
+
for (const key of ["age_min", "age_max"]) if (has(targeting[key]) && (!Number.isInteger(targeting[key]) || Number(targeting[key]) < 13 || Number(targeting[key]) > 65)) throw new Error(`${key} must be an integer from 13 to 65 (65 means 65+).`);
|
|
515
|
+
if (targeting.locales && (!Array.isArray(targeting.locales) || targeting.locales.some((v) => !Number.isInteger(v) || Number(v) <= 0))) throw new Error("Use native numeric locale IDs.");
|
|
516
|
+
const geo = obj(targeting.geo_locations);
|
|
517
|
+
if (geo.countries && (!Array.isArray(geo.countries) || !geo.countries.length || geo.countries.some((v) => typeof v !== "string" || !/^([A-Z]{2})$/.test(v)))) throw new Error("countries must contain explicit two-letter uppercase country codes.");
|
|
518
|
+
if (!Object.entries(geo).some(([key, v]) => key !== "location_types" && Array.isArray(v) && v.length)) throw new Error("Specify nonempty geo_locations explicitly.");
|
|
519
|
+
if (obj(targeting.exclusions).custom_audiences) throw new Error("Use targeting.excluded_custom_audiences for audience exclusions; exclusions.custom_audiences is not supported by Meta.");
|
|
520
|
+
const excluded = new Set((targeting.excluded_custom_audiences ?? []).map((v) => v.id));
|
|
521
|
+
if ((targeting.custom_audiences ?? []).some((v) => excluded.has(v.id))) throw new Error("The same custom audience cannot be included and excluded.");
|
|
522
|
+
}
|
|
523
|
+
if (targeting.age_min && targeting.age_max && Number(targeting.age_min) > Number(targeting.age_max)) throw new Error("Targeting age_min exceeds age_max.");
|
|
524
|
+
if (targeting.exclusions && (obj(targeting.exclusions).interests || obj(targeting.exclusions).behaviors || obj(targeting.exclusions).demographics)) throw new Error("Meta no longer supports detailed-targeting exclusions. Use supported audience or geographic exclusions.");
|
|
525
|
+
if (creating && !Object.keys(obj(targeting.geo_locations)).length) throw new Error("Specify geo_locations explicitly; no country is assumed.");
|
|
526
|
+
if (p.bid_strategy === "LOWEST_COST_WITHOUT_CAP" && p.bid_amount) throw new Error("Automatic lowest-cost bidding cannot include a bid cap.");
|
|
527
|
+
if (creating && ["COST_CAP", "LOWEST_COST_WITH_BID_CAP"].includes(p.bid_strategy) && !p.bid_amount) throw new Error("This bidding strategy requires bidAmount.");
|
|
528
|
+
if (creating && p.bid_strategy === "LOWEST_COST_WITH_MIN_ROAS" && !obj(p.bid_constraints).roas_average_floor) throw new Error("Minimum ROAS bidding requires bid_constraints.roas_average_floor.");
|
|
529
|
+
if (creating) validateAdsetState(p);
|
|
530
|
+
if (!creating && !Object.keys(p).length) throw new Error("No ad set change was supplied.");
|
|
531
|
+
return p;
|
|
532
|
+
}
|
|
533
|
+
function registerMetaExtendedWrites(c, config, readOnly = false) {
|
|
534
|
+
const base = `https://graph.facebook.com/${config.apiVersion || "v26.0"}`;
|
|
535
|
+
async function graph(path, method, data = {}, file) {
|
|
536
|
+
const headers = { authorization: `Bearer ${config.accessToken}` };
|
|
537
|
+
const query = new URLSearchParams();
|
|
538
|
+
for (const [k, v] of Object.entries(clean(data))) query.set(k, typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
539
|
+
let upload;
|
|
540
|
+
if (file) {
|
|
541
|
+
upload = new FormData();
|
|
542
|
+
for (const [k, v] of query) upload.set(k, v);
|
|
543
|
+
upload.set("source", new Blob([Uint8Array.from(atob(file.base64), (ch) => ch.charCodeAt(0))], { type: "video/mp4" }), file.name);
|
|
544
|
+
}
|
|
545
|
+
let response;
|
|
546
|
+
try {
|
|
547
|
+
response = await fetch(`${base}/${path}${method === "GET" ? "?" + query : ""}`, { method, headers: method === "GET" || file ? headers : { ...headers, "content-type": "application/x-www-form-urlencoded" }, ...method === "POST" ? { body: upload ?? query } : {}, signal: AbortSignal.timeout(45e3), redirect: "error" });
|
|
548
|
+
} catch {
|
|
549
|
+
throw Object.assign(new Error(method === "POST" ? "Meta did not confirm the mutation. Check the account before retrying; a duplicate may otherwise be created." : "Meta could not be reached."), { outcome: method === "POST" ? "unknown" : "not_applied" });
|
|
550
|
+
}
|
|
551
|
+
let dataOut;
|
|
552
|
+
try {
|
|
553
|
+
dataOut = await response.json();
|
|
554
|
+
} catch {
|
|
555
|
+
throw Object.assign(new Error("Meta returned an unreadable response. Check the object before retrying."), { outcome: method === "POST" ? "unknown" : "not_applied" });
|
|
556
|
+
}
|
|
557
|
+
if (!response.ok || dataOut.error) {
|
|
558
|
+
const e = obj(dataOut.error);
|
|
559
|
+
throw Object.assign(new Error(String(e.error_user_msg || e.message || `Meta HTTP ${response.status}`).replaceAll(config.accessToken, "[redacted]").slice(0, 700)), { code: e.code, subcode: e.error_subcode, outcome: "not_applied" });
|
|
560
|
+
}
|
|
561
|
+
return dataOut;
|
|
562
|
+
}
|
|
563
|
+
async function owned(nodeId, account2, fields = "id,account_id") {
|
|
564
|
+
const value = await graph(nodeId, "GET", { fields });
|
|
565
|
+
if (String(value.account_id) !== act(account2)) throw new Error("The target does not belong to the selected ad account. Nothing was changed.");
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
async function account(account2, includeBusiness = false) {
|
|
569
|
+
return graph(`act_${act(account2)}`, "GET", { fields: includeBusiness ? "id,currency,business{id}" : "id,currency" });
|
|
570
|
+
}
|
|
571
|
+
async function catalog(catalogId, adAccountId) {
|
|
572
|
+
const [a, p] = await Promise.all([account(adAccountId, true), graph(catalogId, "GET", { fields: "id,business{id}" })]);
|
|
573
|
+
if (!a.business?.id || String(a.business.id) !== String(p.business?.id)) throw new Error("Catalog writes require a catalog owned by the same Business as this selected ad account. Shared catalogs are not writable through this tool.");
|
|
574
|
+
}
|
|
575
|
+
if (readOnly) {
|
|
576
|
+
const read = (n, d, shape, fn) => {
|
|
577
|
+
const schema = z3.object({ adAccountId: accountId, ...shape }).strict();
|
|
578
|
+
c.tool(n, d, schema.shape, async (raw) => {
|
|
579
|
+
try {
|
|
580
|
+
return out(await fn(schema.parse(raw)));
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return out({ error: e.message }, true);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
};
|
|
586
|
+
read("meta_get_entity_configuration", "Read a specific entity in its ad account for reconciliation. Read-only; never creates, changes or retries the entity. Missing or normalized fields must be inspected before claiming an exact match.", {
|
|
587
|
+
entityId: id.describe("Exact entity ID saved in the acknowledged creation receipt."),
|
|
588
|
+
fields: z3.array(z3.enum(["name", "status", "objective", "daily_budget", "lifetime_budget", "special_ad_categories", "special_ad_category_country", "bid_strategy", "campaign_id", "optimization_goal", "billing_event", "targeting", "promoted_object", "bid_amount", "bid_constraints", "start_time", "end_time", "attribution_spec", "destination_type", "is_dynamic_creative", "dsa_beneficiary", "dsa_payor", "adset_id", "creative", "tracking_specs", "conversion_domain", "object_story_spec", "asset_feed_spec", "object_story_id", "instagram_actor_id", "instagram_user_id", "url_tags", "degrees_of_freedom_spec", "is_adset_budget_sharing_enabled"])).min(1).max(40).describe("Exact fields to re-read from the reviewed native creation payload. IDs and account ownership are always checked.")
|
|
589
|
+
}, async (a) => ({ entity: await owned(a.entityId, a.adAccountId, ["id", "account_id", ...a.fields].join(",")) }));
|
|
590
|
+
read("meta_get_uploaded_video", "Check video processing and membership in the selected account library. If the first page misses the ID, use the video title to narrow the account-scoped lookup. A direct video read alone never proves membership or readiness. Bounded pagination; not_found is not success.", { videoId: id.describe("Exact uploaded video ID returned by Meta in this account.") }, async (a) => {
|
|
591
|
+
let after, title;
|
|
592
|
+
for (let page = 0; page < 6; page++) {
|
|
593
|
+
const result = await graph(`act_${act(a.adAccountId)}/advideos`, "GET", clean({ fields: "id,status,picture", limit: 100, after, title }));
|
|
594
|
+
const video = Array.isArray(result.data) ? result.data.find((v) => String(v.id) === a.videoId) : void 0;
|
|
595
|
+
if (video) return { videoId: a.videoId, state: video.status?.video_status ?? "unknown", ready: video.status?.video_status === "ready", thumbnailUrl: typeof video.picture === "string" ? video.picture : void 0 };
|
|
596
|
+
if (page === 0) {
|
|
597
|
+
try {
|
|
598
|
+
const node = await graph(a.videoId, "GET", { fields: "id,title" });
|
|
599
|
+
if (String(node.id) === a.videoId && typeof node.title === "string" && node.title.trim()) {
|
|
600
|
+
title = node.title;
|
|
601
|
+
after = void 0;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
} catch {
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const next = result.paging?.cursors?.after;
|
|
608
|
+
if (!result.paging?.next || !next || next === after) break;
|
|
609
|
+
after = next;
|
|
610
|
+
}
|
|
611
|
+
return { videoId: a.videoId, state: "not_found_in_recent_library", ready: false };
|
|
612
|
+
});
|
|
613
|
+
read("meta_get_adset_configuration", "Read an existing ad set, its parent campaign budget/objective and account currency before preparing an edit. Returns native settings; no mutation.", { adSetId: id }, async (a) => {
|
|
614
|
+
const adset = await owned(a.adSetId, a.adAccountId, "id,name,account_id,campaign_id,status,targeting,promoted_object,optimization_goal,billing_event,bid_strategy,bid_amount,bid_constraints,daily_budget,lifetime_budget,start_time,end_time,attribution_spec,destination_type,is_dynamic_creative,dsa_beneficiary,dsa_payor");
|
|
615
|
+
const [info, campaign] = await Promise.all([account(a.adAccountId), owned(adset.campaign_id, a.adAccountId, "id,name,account_id,objective,status,daily_budget,lifetime_budget,special_ad_categories")]);
|
|
616
|
+
return {
|
|
617
|
+
account: info,
|
|
618
|
+
campaign,
|
|
619
|
+
adset,
|
|
620
|
+
budgetLevel: Number(campaign.daily_budget) > 0 || Number(campaign.lifetime_budget) > 0 ? "campaign" : "adset",
|
|
621
|
+
note: "Native money fields are minor units. Targeting updates replace the complete spec. Duplicate an existing ad set to preserve complex settings."
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
read("meta_get_catalog_batch_status", "Read the completion and per-item errors for a catalog batch handle. Acceptance of a batch is not proof that every product succeeded.", { catalogId: id, handle: z3.string().min(1).max(1e3) }, async (a) => {
|
|
625
|
+
await catalog(a.catalogId, a.adAccountId);
|
|
626
|
+
return graph(`${a.catalogId}/check_batch_request_status`, "GET", { handle: a.handle, load_ids_of_invalid_requests: true });
|
|
627
|
+
});
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
function register(n, description, shape, build) {
|
|
631
|
+
const described = Object.fromEntries(Object.entries({ adAccountId: accountId, ...shape, ...mode }).map(([key, value]) => [key, value.description ? value : value.describe(parameterDescriptions[key] || key)]));
|
|
632
|
+
const schema = z3.object(described).strict();
|
|
633
|
+
c.tool(n, description + " Local preview by default. No automatic retry. Account ownership is checked before Meta validation or mutation.", schema.shape, async (raw) => {
|
|
634
|
+
try {
|
|
635
|
+
const a = schema.parse(raw);
|
|
636
|
+
if (a.confirm && a.validateOnly) throw new Error("confirm and validateOnly cannot both be true.");
|
|
637
|
+
const p = await build(a);
|
|
638
|
+
safeJson(p.payload);
|
|
639
|
+
if (a.validateOnly && !p.validate) throw new Error("This endpoint does not expose validate_only. Use the local preview, then confirm.");
|
|
640
|
+
if (!a.confirm && !a.validateOnly) return out({ applied: false, action: n, adAccountId: `act_${act(a.adAccountId)}`, payload: p.redact ?? p.payload, validation: { local: true, meta: false, ownershipChecked: false }, nextAction: "Review these exact parameters. Use validateOnly where supported, or repeat with confirm:true to apply." });
|
|
641
|
+
await p.scope?.();
|
|
642
|
+
const result = await graph(p.path, "POST", { ...p.payload, ...a.validateOnly ? { execution_options: ["validate_only"] } : {} }, p.file);
|
|
643
|
+
if (a.validateOnly) return out({ applied: false, validatedByMeta: true, action: n, result });
|
|
644
|
+
let verification;
|
|
645
|
+
if (p.readback) {
|
|
646
|
+
try {
|
|
647
|
+
verification = await graph(result[p.idKey || "id"] || p.path, "GET", { fields: p.readback });
|
|
648
|
+
} catch {
|
|
649
|
+
verification = { confirmed: false, message: "Mutation returned success, but readback failed. Read the returned ID; do not recreate it." };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (["meta_create_campaign", "meta_create_adset", "meta_create_ad"].includes(n) && obj(verification).confirmed !== false) {
|
|
653
|
+
verification = { ...obj(verification), confirmed: obj(verification).status === "PAUSED" && String(obj(verification).account_id) === act(a.adAccountId) };
|
|
654
|
+
}
|
|
655
|
+
const differences = [];
|
|
656
|
+
if (n === "meta_create_adcreative" && obj(verification).confirmed !== false) {
|
|
657
|
+
const media = mediaDifferences(a.spec.object_story_spec, obj(verification).object_story_spec, "object_story_spec");
|
|
658
|
+
media.push(...mediaDifferences(a.spec.asset_feed_spec, obj(verification).asset_feed_spec, "asset_feed_spec"));
|
|
659
|
+
differences.push(...media);
|
|
660
|
+
verification = { ...obj(verification), confirmed: media.length === 0 && String(obj(verification).account_id) === act(a.adAccountId), ...media.length ? { message: "Meta returned different or missing media. For collections, check the linked Instant Experience cover. Inspect this existing creative before continuing; do not automatically recreate it." } : {} };
|
|
661
|
+
}
|
|
662
|
+
if (n === "meta_create_adcreative" && typeof obj(verification).name === "string" && obj(verification).name !== a.name) {
|
|
663
|
+
differences.push({
|
|
664
|
+
field: "name",
|
|
665
|
+
requested: a.name,
|
|
666
|
+
returned: obj(verification).name,
|
|
667
|
+
note: "The submitted name and Meta readback differ. The cause is not established by this response; do not recreate the creative to force a match."
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
return out({ applied: true, action: n, ...p.async ? { processing: "accepted", message: "Accepted for asynchronous processing; this is not confirmation that every item or video is ready." } : {}, result, verification, ...differences.length ? { differences } : {} });
|
|
671
|
+
} catch (error) {
|
|
672
|
+
const e = error;
|
|
673
|
+
return out({ error: e.message, code: e.code, subcode: e.subcode, outcome: e.outcome || "not_applied", retrySafe: false, ...e.subcode === 1341012 ? { nextAction: "Meta refused access to the Page/profile used by this creative. Verify the selected Page, its asset assignments and the token permissions. Repeating the same request will not repair this access." } : {} }, true);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const checkCurrency = async (a) => {
|
|
678
|
+
const info = await account(a.adAccountId);
|
|
679
|
+
if (a.currency && info.currency !== a.currency) throw new Error(`Account currency is ${info.currency}, not ${a.currency}. Nothing was changed.`);
|
|
680
|
+
};
|
|
681
|
+
const budgets = { currency: money.currency, dailyBudget: money.dailyBudget.describe("Daily budget in major account-currency units. Mutually exclusive with lifetimeBudget."), lifetimeBudget: money.lifetimeBudget.describe("Total lifetime budget in major account-currency units. Requires bounded ad set schedules; mutually exclusive with dailyBudget.") };
|
|
682
|
+
const budgetPayload = (a, required = false) => {
|
|
683
|
+
if (has(a.dailyBudget) && has(a.lifetimeBudget)) throw new Error("Choose dailyBudget OR lifetimeBudget, not both.");
|
|
684
|
+
if (required && !has(a.dailyBudget) && !has(a.lifetimeBudget)) throw new Error("Provide dailyBudget or lifetimeBudget.");
|
|
685
|
+
if ((has(a.dailyBudget) || has(a.lifetimeBudget)) && !a.currency) throw new Error("Supply the account currency for monetary amounts.");
|
|
686
|
+
const p = {};
|
|
687
|
+
for (const [from, to] of [["dailyBudget", "daily_budget"], ["lifetimeBudget", "lifetime_budget"]]) if (has(a[from])) {
|
|
688
|
+
p[to] = toMinorUnits(Number(a[from]), String(a.currency));
|
|
689
|
+
if (p[to] < 1) throw new Error("Budget rounds to zero in the account currency.");
|
|
690
|
+
}
|
|
691
|
+
return p;
|
|
692
|
+
};
|
|
693
|
+
register("meta_create_campaign", "Create a PAUSED Meta campaign with either ad set budgets (omit campaign budget), a daily campaign budget, or a lifetime campaign budget. Supports native validate_only.", {
|
|
694
|
+
name,
|
|
695
|
+
objective: z3.enum(["OUTCOME_TRAFFIC", "OUTCOME_SALES", "OUTCOME_LEADS", "OUTCOME_AWARENESS", "OUTCOME_ENGAGEMENT", "OUTCOME_APP_PROMOTION"]),
|
|
696
|
+
specialAdCategories: z3.array(z3.enum(["NONE", "HOUSING", "EMPLOYMENT", "CREDIT", "ISSUES_ELECTIONS_POLITICS"])).optional().describe("Required special categories; omit for none."),
|
|
697
|
+
adsetBudgetSharing: z3.boolean().default(false).describe("Explicit ad set budget sharing choice; only with ad set budgets."),
|
|
698
|
+
...budgets,
|
|
699
|
+
bidStrategy: adsetConfig.shape.bid_strategy,
|
|
700
|
+
startTime: date.optional(),
|
|
701
|
+
endTime: date.optional()
|
|
702
|
+
}, (a) => {
|
|
703
|
+
const budget = budgetPayload(a);
|
|
704
|
+
if (Object.keys(budget).length && a.adsetBudgetSharing) throw new Error("Ad set budget sharing cannot be combined with a campaign budget.");
|
|
705
|
+
if (a.startTime && a.endTime && Date.parse(a.endTime) <= Date.parse(a.startTime)) throw new Error("endTime must follow startTime.");
|
|
706
|
+
return { path: `act_${act(a.adAccountId)}/campaigns`, payload: clean({ name: a.name, objective: a.objective, special_ad_categories: a.specialAdCategories ?? [], is_adset_budget_sharing_enabled: a.adsetBudgetSharing, status: "PAUSED", ...budget, bid_strategy: a.bidStrategy, start_time: a.startTime, stop_time: a.endTime }), validate: true, scope: () => checkCurrency(a), readback: "id,name,account_id,status,daily_budget,lifetime_budget" };
|
|
707
|
+
});
|
|
708
|
+
const adsetUpdate = (a, payload) => ({ path: a.adSetId, payload, validate: true, readback: "id,account_id,status,targeting,optimization_goal,billing_event,start_time,end_time,daily_budget,lifetime_budget,bid_strategy,bid_amount", scope: async () => {
|
|
709
|
+
await checkCurrency(a);
|
|
710
|
+
const current = await owned(a.adSetId, a.adAccountId, "id,account_id,campaign_id,start_time,end_time,targeting,optimization_goal,billing_event,bid_strategy,bid_amount,bid_constraints,daily_budget,lifetime_budget");
|
|
711
|
+
validateAdsetState({ ...current, ...payload });
|
|
712
|
+
if (payload.daily_budget || payload.lifetime_budget) {
|
|
713
|
+
const parent = await owned(current.campaign_id, a.adAccountId, "id,account_id,daily_budget,lifetime_budget");
|
|
714
|
+
if (Number(parent.daily_budget) > 0 || Number(parent.lifetime_budget) > 0) throw new Error("This campaign owns its budget; change it at campaign level.");
|
|
715
|
+
}
|
|
716
|
+
} });
|
|
717
|
+
register("meta_update_adset_budget", "Change an ad set daily or lifetime budget after checking its parent budget and existing schedule. Status stays unchanged.", { adSetId: id, ...budgets }, (a) => adsetUpdate(a, budgetPayload(a, true)));
|
|
718
|
+
register("meta_update_adset_schedule", "Change start and/or end time using ISO 8601 with an explicit offset. Check the resulting range against existing dates before any mutation.", { adSetId: id, startTime: date.optional(), endTime: date.optional() }, (a) => {
|
|
719
|
+
if (!a.startTime && !a.endTime) throw new Error("Provide startTime or endTime.");
|
|
720
|
+
const p = clean({ start_time: a.startTime, end_time: a.endTime });
|
|
721
|
+
if (a.startTime && a.endTime && Date.parse(a.endTime) <= Date.parse(a.startTime)) throw new Error("endTime must follow startTime.");
|
|
722
|
+
return adsetUpdate(a, p);
|
|
723
|
+
});
|
|
724
|
+
register("meta_update_campaign_budget", "Change an existing campaign-owned budget after verifying currency and ownership. Does not convert an ad-set-budget campaign or change status.", { campaignId: id, ...budgets }, (a) => ({ path: a.campaignId, payload: budgetPayload(a, true), validate: true, readback: "id,account_id,status,daily_budget,lifetime_budget", scope: async () => {
|
|
725
|
+
await checkCurrency(a);
|
|
726
|
+
const parent = await owned(a.campaignId, a.adAccountId, "id,account_id,daily_budget,lifetime_budget");
|
|
727
|
+
if (!(Number(parent.daily_budget) > 0 || Number(parent.lifetime_budget) > 0)) throw new Error("This campaign has no campaign budget; update its ad set budget.");
|
|
728
|
+
} }));
|
|
729
|
+
register("meta_create_adset", "Create a PAUSED Meta ad set with explicit targeting, optimization, placements, attribution, bidding and schedule. Amounts use major account-currency units.", { campaignId: id, name, configuration: adsetConfig, ...money }, (a) => {
|
|
730
|
+
const payload = buildAdsetPayload(a, true);
|
|
731
|
+
return { path: `act_${act(a.adAccountId)}/adsets`, payload, validate: true, readback: "id,name,status,effective_status,account_id,campaign_id,targeting,daily_budget,lifetime_budget", scope: async () => {
|
|
732
|
+
await checkCurrency(a);
|
|
733
|
+
const campaign = await owned(a.campaignId, a.adAccountId, "id,account_id,objective,daily_budget,lifetime_budget,special_ad_categories");
|
|
734
|
+
const cbo = Number(campaign.daily_budget) > 0 || Number(campaign.lifetime_budget) > 0;
|
|
735
|
+
if (cbo && (payload.daily_budget || payload.lifetime_budget)) throw new Error("This campaign owns its budget. Omit the ad set budget.");
|
|
736
|
+
if (!cbo && !payload.daily_budget && !payload.lifetime_budget) throw new Error("This campaign has no campaign budget. Supply an ad set budget.");
|
|
737
|
+
} };
|
|
738
|
+
});
|
|
739
|
+
register("meta_update_adset_configuration", "Update targeting, optimization, placements, attribution, bidding, budget or schedule. Targeting is a COMPLETE replacement, not a partial merge. Status is unchanged.", { adSetId: id, configuration: adsetConfig, ...money }, (a) => {
|
|
740
|
+
const payload = buildAdsetPayload(a, false);
|
|
741
|
+
return adsetUpdate(a, payload);
|
|
742
|
+
});
|
|
743
|
+
register("meta_duplicate_adset", "Duplicate a known-good ad set within the selected account, preserving complex native configuration. Always PAUSED and WITHOUT copying ads. Rename it afterwards with meta_rename_adset.", { adSetId: id, campaignId: id.optional(), startTime: date.optional(), endTime: date.optional() }, (a) => {
|
|
744
|
+
if (a.startTime && a.endTime && Date.parse(a.endTime) <= Date.parse(a.startTime)) throw new Error("endTime must be after startTime.");
|
|
745
|
+
return { path: `${a.adSetId}/copies`, payload: clean({ campaign_id: a.campaignId, start_time: a.startTime, end_time: a.endTime, status_option: "PAUSED", deep_copy: false }), scope: async () => {
|
|
746
|
+
await owned(a.adSetId, a.adAccountId);
|
|
747
|
+
if (a.campaignId) await owned(a.campaignId, a.adAccountId);
|
|
748
|
+
}, idKey: "copied_adset_id", readback: "id,name,status,account_id,campaign_id" };
|
|
749
|
+
});
|
|
750
|
+
register("meta_create_adcreative", "Create an ad creative from an existing post or an explicit object_story_spec and optional asset_feed_spec. Supports image, video, carousel, collection and flexible specs. Does not launch an ad.", { name, spec: creativeSpec }, (a) => {
|
|
751
|
+
const spec = creativeSpec.parse(a.spec);
|
|
752
|
+
if (Number(!!spec.object_story_id) + Number(!!spec.object_story_spec) !== 1) throw new Error("Supply exactly one of object_story_id or object_story_spec.");
|
|
753
|
+
if (spec.object_story_spec && !id.safeParse(spec.object_story_spec.page_id).success) throw new Error("object_story_spec requires an explicit numeric page_id.");
|
|
754
|
+
return { path: `act_${act(a.adAccountId)}/adcreatives`, payload: { name: a.name, ...spec }, validate: true, scope: () => account(a.adAccountId), readback: "id,name,account_id,object_story_id,effective_object_story_id,object_story_spec,asset_feed_spec,product_set_id" };
|
|
755
|
+
});
|
|
756
|
+
register("meta_create_ad", "Create a PAUSED ad using an existing ad set and creative from the selected account.", { adSetId: id, creativeId: id, name, trackingSpecs: z3.array(json).optional() }, (a) => ({ path: `act_${act(a.adAccountId)}/ads`, payload: clean({ name: a.name, adset_id: a.adSetId, creative: { creative_id: a.creativeId }, status: "PAUSED", tracking_specs: a.trackingSpecs }), validate: true, scope: async () => {
|
|
757
|
+
await owned(a.adSetId, a.adAccountId);
|
|
758
|
+
await owned(a.creativeId, a.adAccountId);
|
|
759
|
+
}, readback: "id,name,account_id,status,effective_status,adset_id,creative{id}" }));
|
|
760
|
+
register("meta_update_ad_creative", "Replace an ad\u2019s creative with another existing creative from the same account. The ad status is unchanged.", { adId: id, creativeId: id }, (a) => ({ path: a.adId, payload: { creative: { creative_id: a.creativeId } }, validate: true, scope: async () => {
|
|
761
|
+
await owned(a.adId, a.adAccountId);
|
|
762
|
+
await owned(a.creativeId, a.adAccountId);
|
|
763
|
+
}, readback: "id,account_id,status,creative{id}" }));
|
|
764
|
+
register("meta_upload_ad_image", "Upload an image into the selected ad account using base64 bytes (up to 5 MiB decoded). Preview reports size only, never the file body.", { bytesBase64: z3.string().min(4).max(7e6).regex(/^[A-Za-z0-9+/]+={0,2}$/) }, (a) => {
|
|
765
|
+
let bytes;
|
|
766
|
+
try {
|
|
767
|
+
bytes = atob(a.bytesBase64);
|
|
768
|
+
} catch {
|
|
769
|
+
throw new Error("Invalid base64 image.");
|
|
770
|
+
}
|
|
771
|
+
if (bytes.length > 5 * 1024 * 1024) throw new Error("Image exceeds 5 MiB.");
|
|
772
|
+
return { path: `act_${act(a.adAccountId)}/adimages`, payload: { bytes: a.bytesBase64 }, redact: { decodedBytes: bytes.length }, scope: () => account(a.adAccountId) };
|
|
773
|
+
});
|
|
774
|
+
register("meta_upload_ad_video", "Import a video from a publicly reachable HTTPS URL or base64 MP4 bytes (up to 5 MiB decoded). Meta processes it asynchronously. No campaign is launched.", { fileUrl: z3.string().url().startsWith("https://").optional(), bytesBase64: z3.string().min(4).max(7e6).regex(/^[A-Za-z0-9+/]+={0,2}$/).optional(), title: name.optional() }, (a) => {
|
|
775
|
+
if (Number(!!a.fileUrl) + Number(!!a.bytesBase64) !== 1) throw new Error("Supply exactly one of fileUrl or bytesBase64.");
|
|
776
|
+
let decodedBytes;
|
|
777
|
+
if (a.bytesBase64) {
|
|
778
|
+
try {
|
|
779
|
+
decodedBytes = atob(a.bytesBase64).length;
|
|
780
|
+
} catch {
|
|
781
|
+
throw new Error("Invalid base64 video.");
|
|
782
|
+
}
|
|
783
|
+
if (decodedBytes > 5 * 1024 * 1024) throw new Error("Video exceeds 5 MiB. Use a public media URL for larger files.");
|
|
784
|
+
}
|
|
785
|
+
return { path: `act_${act(a.adAccountId)}/advideos`, payload: clean({ file_url: a.fileUrl, title: a.title }), redact: clean({ fileUrl: a.fileUrl, title: a.title, decodedBytes }), ...a.bytesBase64 ? { file: { base64: a.bytesBase64, name: "video.mp4" } } : {}, scope: () => account(a.adAccountId), async: true };
|
|
786
|
+
});
|
|
787
|
+
register("meta_create_custom_audience", "Create a website, engagement, customer-list container or lookalike audience. Does not upload customer data. Terms and eligibility are enforced by Meta.", { name, subtype: z3.enum(["CUSTOM", "WEBSITE", "ENGAGEMENT", "LOOKALIKE"]), description: z3.string().max(2e3).optional(), rule: json.optional(), retentionDays: z3.number().int().min(1).max(180).optional(), pixelId: id.optional(), originAudienceId: id.optional(), lookalikeSpec: json.optional(), customerFileSource: z3.enum(["USER_PROVIDED_ONLY", "PARTNER_PROVIDED_ONLY", "BOTH_USER_AND_PARTNER_PROVIDED"]).optional() }, (a) => {
|
|
788
|
+
if (a.subtype === "LOOKALIKE" && (!a.originAudienceId || !a.lookalikeSpec)) throw new Error("A lookalike requires originAudienceId and lookalikeSpec.");
|
|
789
|
+
if (a.subtype === "CUSTOM" && !a.customerFileSource) throw new Error("Declare customerFileSource explicitly.");
|
|
790
|
+
if (["WEBSITE", "ENGAGEMENT"].includes(a.subtype) && !a.rule) throw new Error("This audience requires a native rule.");
|
|
791
|
+
return { path: `act_${act(a.adAccountId)}/customaudiences`, payload: clean({ name: a.name, subtype: a.subtype, description: a.description, rule: a.rule, retention_days: a.retentionDays, pixel_id: a.pixelId, origin_audience_id: a.originAudienceId, lookalike_spec: a.lookalikeSpec, customer_file_source: a.customerFileSource }), scope: async () => {
|
|
792
|
+
await account(a.adAccountId);
|
|
793
|
+
if (a.originAudienceId) await owned(a.originAudienceId, a.adAccountId);
|
|
794
|
+
}, readback: "id,name,account_id,subtype" };
|
|
795
|
+
});
|
|
796
|
+
register("meta_update_custom_audience", "Update an existing custom audience name, description, retention or complete rule. Does not change audience membership directly.", { audienceId: id, name: name.optional(), description: z3.string().max(2e3).optional(), retentionDays: z3.number().int().min(1).max(180).optional(), rule: json.optional() }, (a) => {
|
|
797
|
+
const payload = clean({ name: a.name, description: a.description, retention_days: a.retentionDays, rule: a.rule });
|
|
798
|
+
if (!Object.keys(payload).length) throw new Error("No audience change supplied.");
|
|
799
|
+
return { path: a.audienceId, payload, scope: () => owned(a.audienceId, a.adAccountId), readback: "id,name,account_id,subtype" };
|
|
800
|
+
});
|
|
801
|
+
register("meta_create_product_set", "Create a filtered product set in a catalog owned by the same Business as the selected ad account. Never publishes to Shops.", { catalogId: id, name, filter: json }, (a) => ({ path: `${a.catalogId}/product_sets`, payload: { name: a.name, filter: a.filter }, scope: () => catalog(a.catalogId, a.adAccountId), readback: "id,name,filter" }));
|
|
802
|
+
register("meta_update_product_set", "Replace a product set filter or rename it. Catalog ownership and set membership are checked before writing.", { catalogId: id, productSetId: id, name: name.optional(), filter: json.optional() }, (a) => {
|
|
803
|
+
const payload = clean({ name: a.name, filter: a.filter });
|
|
804
|
+
if (!Object.keys(payload).length) throw new Error("No product set change supplied.");
|
|
805
|
+
return { path: a.productSetId, payload, scope: async () => {
|
|
806
|
+
await catalog(a.catalogId, a.adAccountId);
|
|
807
|
+
const set = await graph(a.productSetId, "GET", { fields: "id,product_catalog{id}" });
|
|
808
|
+
if (String(set.product_catalog?.id) !== a.catalogId) throw new Error("Product set does not belong to the declared catalog.");
|
|
809
|
+
}, readback: "id,name,filter" };
|
|
810
|
+
});
|
|
811
|
+
register("meta_batch_catalog_items", "Create or update up to 50 catalog products by retailer_id. Returns asynchronous batch handles; acceptance is not completion. No deletion or automatic upsert.", { catalogId: id, requests: z3.array(z3.object({ method: z3.enum(["CREATE", "UPDATE"]), retailer_id: z3.string().min(1).max(100), data: json }).strict()).min(1).max(50) }, (a) => ({ path: `${a.catalogId}/batch`, payload: { requests: a.requests, allow_upsert: false }, scope: () => catalog(a.catalogId, a.adAccountId), async: true }));
|
|
812
|
+
}
|
|
813
|
+
async function verifyMetaWriteScope(config, a) {
|
|
814
|
+
accountId.parse(a.adAccountId);
|
|
815
|
+
const get = async (node, fields) => {
|
|
816
|
+
const response = await fetch(`https://graph.facebook.com/${config.apiVersion || "v26.0"}/${node}?fields=${encodeURIComponent(fields)}`, { headers: { authorization: `Bearer ${config.accessToken}` }, signal: AbortSignal.timeout(3e4), redirect: "error" });
|
|
817
|
+
const data = await response.json();
|
|
818
|
+
if (!response.ok || data.error) throw new Error("Cannot verify the Meta target scope. Nothing was changed.");
|
|
819
|
+
return data;
|
|
820
|
+
};
|
|
821
|
+
const info = await get(`act_${act(a.adAccountId)}`, "id,currency");
|
|
822
|
+
if (a.currency && info.currency !== a.currency) throw new Error(`Account currency is ${info.currency}, not ${a.currency}. Nothing was changed.`);
|
|
823
|
+
const target = a.adId || a.adSetId || a.campaignId;
|
|
824
|
+
if (target) {
|
|
825
|
+
id.parse(target);
|
|
826
|
+
const entity = await get(target, "id,account_id");
|
|
827
|
+
if (String(entity.account_id) !== act(a.adAccountId)) throw new Error("The target belongs to a different ad account. Nothing was changed.");
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/platforms/meta/reporting-context.ts
|
|
832
|
+
function measurementParams(request2) {
|
|
833
|
+
const mode2 = request2.attributionMode;
|
|
834
|
+
const windows = request2.attributionWindows;
|
|
835
|
+
if (mode2 === "explicit" && (!windows || windows.length === 0))
|
|
836
|
+
throw Error("Explicit attribution requires at least one window.");
|
|
837
|
+
if ((mode2 === "account" || mode2 === "adset") && windows?.length)
|
|
838
|
+
throw Error(
|
|
839
|
+
"Choose native attribution settings or explicit windows, not both."
|
|
840
|
+
);
|
|
841
|
+
return {
|
|
842
|
+
...mode2 === "account" ? { use_account_attribution_setting: "true" } : {},
|
|
843
|
+
...mode2 === "adset" ? { use_unified_attribution_setting: "true" } : {},
|
|
844
|
+
...windows?.length ? { action_attribution_windows: JSON.stringify(windows) } : {},
|
|
845
|
+
...request2.actionReportTime ? { action_report_time: request2.actionReportTime } : {}
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function reportingEvidence(results, requests, joinKeys, rows2) {
|
|
849
|
+
const key = (r) => JSON.stringify(joinKeys.map((k) => r[k] ?? null));
|
|
850
|
+
const indices = results.map(
|
|
851
|
+
(group) => new Map(group.map((r) => [key(r), r]))
|
|
852
|
+
);
|
|
853
|
+
const missingRows = rows2.filter(
|
|
854
|
+
(r) => requests.some((q) => q.fields.includes("spend")) && !Object.hasOwn(r, "spend")
|
|
855
|
+
);
|
|
856
|
+
return {
|
|
857
|
+
source_queries: requests.map((q, i) => ({
|
|
858
|
+
index: i,
|
|
859
|
+
type: q.type,
|
|
860
|
+
fields: q.fields,
|
|
861
|
+
breakdowns: q.breakdowns ?? [],
|
|
862
|
+
row_count: results[i].length,
|
|
863
|
+
measurement_parameters: measurementParams(q),
|
|
864
|
+
pagination_exhausted: true
|
|
865
|
+
})),
|
|
866
|
+
row_provenance: rows2.map((r) => ({
|
|
867
|
+
key: Object.fromEntries(joinKeys.map((k) => [k, r[k] ?? null])),
|
|
868
|
+
exact_source_queries: indices.flatMap(
|
|
869
|
+
(m, i) => m.has(key(r)) ? [i] : []
|
|
870
|
+
),
|
|
871
|
+
missing_requested_fields: [
|
|
872
|
+
...new Set(requests.flatMap((q) => q.fields))
|
|
873
|
+
].filter((f) => !Object.hasOwn(r, f))
|
|
874
|
+
})),
|
|
875
|
+
rows_without_spend: missingRows.length,
|
|
876
|
+
notes: [
|
|
877
|
+
"Rows are merged from separate native queries; a missing field or missing query match is not proof of zero or of delivery outside the reporting period.",
|
|
878
|
+
"Native attribution configuration is not inferred from numerical coincidences in action values. Explicit parameters are listed for each query.",
|
|
879
|
+
"An action object may contain a window column without a value field. Preserve this absence; do not fill value from another window or label separate windows as a deduplicated combined total.",
|
|
880
|
+
"Do not sum different purchase action types, attribution-window columns, or metrics broadcast across breakdown rows.",
|
|
881
|
+
"Use native account_totals when requested for overall figures. Do not mix a subset spend denominator with conversions from another population.",
|
|
882
|
+
"ROAS alone does not establish profitability. Missing counts do not establish future conversion growth."
|
|
883
|
+
]
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/platforms/meta/tools.ts
|
|
888
|
+
import { z as z5 } from "zod";
|
|
889
|
+
|
|
86
890
|
// src/core/errors.ts
|
|
87
891
|
var PlatformApiError = class extends Error {
|
|
88
892
|
constructor(platform, code, message, isRateLimit = false, isAuth = false, isPermission = false, suggestion = "", retryAfter) {
|
|
@@ -96,13 +900,6 @@ var PlatformApiError = class extends Error {
|
|
|
96
900
|
this.retryAfter = retryAfter;
|
|
97
901
|
this.name = "PlatformApiError";
|
|
98
902
|
}
|
|
99
|
-
platform;
|
|
100
|
-
code;
|
|
101
|
-
isRateLimit;
|
|
102
|
-
isAuth;
|
|
103
|
-
isPermission;
|
|
104
|
-
suggestion;
|
|
105
|
-
retryAfter;
|
|
106
903
|
toMcpError() {
|
|
107
904
|
return {
|
|
108
905
|
error: this.message,
|
|
@@ -159,7 +956,6 @@ var RateLimiter = class {
|
|
|
159
956
|
this.platform = platform;
|
|
160
957
|
this.config = PLATFORM_LIMITS[platform];
|
|
161
958
|
}
|
|
162
|
-
platform;
|
|
163
959
|
records = /* @__PURE__ */ new Map();
|
|
164
960
|
config;
|
|
165
961
|
/**
|
|
@@ -168,25 +964,25 @@ var RateLimiter = class {
|
|
|
168
964
|
*/
|
|
169
965
|
async acquire() {
|
|
170
966
|
const key = this.platform;
|
|
171
|
-
let
|
|
172
|
-
if (!
|
|
173
|
-
|
|
174
|
-
this.records.set(key,
|
|
967
|
+
let record2 = this.records.get(key);
|
|
968
|
+
if (!record2) {
|
|
969
|
+
record2 = { timestamps: [] };
|
|
970
|
+
this.records.set(key, record2);
|
|
175
971
|
}
|
|
176
972
|
const now = Date.now();
|
|
177
|
-
|
|
178
|
-
const lastSecond =
|
|
973
|
+
record2.timestamps = record2.timestamps.filter((t) => now - t < 6e4);
|
|
974
|
+
const lastSecond = record2.timestamps.filter((t) => now - t < 1e3);
|
|
179
975
|
if (lastSecond.length >= this.config.maxPerSecond) {
|
|
180
976
|
const waitMs = 1e3 - (now - lastSecond[0]) + 50;
|
|
181
977
|
logger.debug(this.platform, `Rate limit: waiting ${waitMs}ms (per-second limit)`);
|
|
182
978
|
await sleep(waitMs);
|
|
183
979
|
}
|
|
184
|
-
if (
|
|
185
|
-
const waitMs = 6e4 - (now -
|
|
980
|
+
if (record2.timestamps.length >= this.config.maxPerMinute) {
|
|
981
|
+
const waitMs = 6e4 - (now - record2.timestamps[0]) + 100;
|
|
186
982
|
logger.warn(this.platform, `Rate limit: waiting ${waitMs}ms (per-minute limit)`);
|
|
187
983
|
await sleep(waitMs);
|
|
188
984
|
}
|
|
189
|
-
|
|
985
|
+
record2.timestamps.push(Date.now());
|
|
190
986
|
}
|
|
191
987
|
/**
|
|
192
988
|
* Execute a function with automatic rate limiting and retry on 429.
|
|
@@ -231,21 +1027,24 @@ function jitter() {
|
|
|
231
1027
|
var META_GRAPH_API_BASE = "https://graph.facebook.com";
|
|
232
1028
|
var DEFAULT_API_VERSION = "v26.0";
|
|
233
1029
|
var DEFAULT_LIMIT = 500;
|
|
234
|
-
|
|
235
|
-
|
|
1030
|
+
var META_REQUEST_TIMEOUT_MS = 15e3;
|
|
1031
|
+
var MAX_PAGINATION_PAGES = 20;
|
|
1032
|
+
var MAX_PAGINATION_ROWS = 1e4;
|
|
1033
|
+
var MAX_PAGE_ROWS = 1e3;
|
|
1034
|
+
var MAX_SEARCH_ENTITY_ROWS = 500;
|
|
1035
|
+
function trustedMetaGraphUrl(rawUrl, apiVersion, expectedPathname) {
|
|
1036
|
+
let parsed;
|
|
236
1037
|
try {
|
|
237
|
-
|
|
1038
|
+
parsed = new URL(rawUrl);
|
|
238
1039
|
} catch {
|
|
239
|
-
throw new Error("Meta Graph URL
|
|
1040
|
+
throw new Error("Meta Graph URL is invalid.");
|
|
240
1041
|
}
|
|
241
|
-
|
|
242
|
-
|
|
1042
|
+
const versionSegment = parsed.pathname.split("/").filter(Boolean)[0];
|
|
1043
|
+
if (parsed.protocol !== "https:" || parsed.origin !== META_GRAPH_API_BASE || parsed.username || parsed.password || parsed.port || parsed.hash || versionSegment !== apiVersion || expectedPathname !== void 0 && parsed.pathname !== expectedPathname) {
|
|
1044
|
+
throw new Error("Meta Graph URL failed the trusted origin, API-version, and route policy.");
|
|
243
1045
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
throw new Error(`Meta Graph URL must use the configured API version ${apiVersion}.`);
|
|
247
|
-
}
|
|
248
|
-
return url;
|
|
1046
|
+
parsed.searchParams.delete("access_token");
|
|
1047
|
+
return parsed;
|
|
249
1048
|
}
|
|
250
1049
|
var MetaApiException = class extends Error {
|
|
251
1050
|
code;
|
|
@@ -298,33 +1097,33 @@ function normalizeInsightRow(row) {
|
|
|
298
1097
|
if (normalized.ad_format_asset !== void 0) {
|
|
299
1098
|
const value = normalized.ad_format_asset;
|
|
300
1099
|
if (typeof value === "object" && value !== null) {
|
|
301
|
-
const
|
|
302
|
-
if ("name" in
|
|
303
|
-
normalized.ad_format_asset =
|
|
304
|
-
} else if ("id" in
|
|
305
|
-
normalized.ad_format_asset = String(
|
|
1100
|
+
const obj3 = value;
|
|
1101
|
+
if ("name" in obj3 && typeof obj3.name === "string") {
|
|
1102
|
+
normalized.ad_format_asset = obj3.name;
|
|
1103
|
+
} else if ("id" in obj3) {
|
|
1104
|
+
normalized.ad_format_asset = String(obj3.id);
|
|
306
1105
|
}
|
|
307
1106
|
}
|
|
308
1107
|
}
|
|
309
1108
|
if (normalized.media_type !== void 0) {
|
|
310
1109
|
const value = normalized.media_type;
|
|
311
1110
|
if (typeof value === "object" && value !== null) {
|
|
312
|
-
const
|
|
313
|
-
if ("name" in
|
|
314
|
-
normalized.media_type =
|
|
315
|
-
} else if ("value" in
|
|
316
|
-
normalized.media_type =
|
|
1111
|
+
const obj3 = value;
|
|
1112
|
+
if ("name" in obj3 && typeof obj3.name === "string") {
|
|
1113
|
+
normalized.media_type = obj3.name;
|
|
1114
|
+
} else if ("value" in obj3 && typeof obj3.value === "string") {
|
|
1115
|
+
normalized.media_type = obj3.value;
|
|
317
1116
|
}
|
|
318
1117
|
}
|
|
319
1118
|
}
|
|
320
1119
|
if (normalized.media_format !== void 0) {
|
|
321
1120
|
const value = normalized.media_format;
|
|
322
1121
|
if (typeof value === "object" && value !== null) {
|
|
323
|
-
const
|
|
324
|
-
if ("name" in
|
|
325
|
-
normalized.media_format =
|
|
326
|
-
} else if ("value" in
|
|
327
|
-
normalized.media_format =
|
|
1122
|
+
const obj3 = value;
|
|
1123
|
+
if ("name" in obj3 && typeof obj3.name === "string") {
|
|
1124
|
+
normalized.media_format = obj3.name;
|
|
1125
|
+
} else if ("value" in obj3 && typeof obj3.value === "string") {
|
|
1126
|
+
normalized.media_format = obj3.value;
|
|
328
1127
|
}
|
|
329
1128
|
}
|
|
330
1129
|
}
|
|
@@ -362,11 +1161,14 @@ var MetaClient = class {
|
|
|
362
1161
|
* Useful for tools that build URLs directly.
|
|
363
1162
|
*/
|
|
364
1163
|
async fetchUrl(url) {
|
|
1164
|
+
const trustedUrl = trustedMetaGraphUrl(url, this.apiVersion);
|
|
365
1165
|
return this.rateLimiter.execute(async () => {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
1166
|
+
logger.debug("meta", `fetchUrl: ${trustedUrl.origin}${trustedUrl.pathname}`);
|
|
1167
|
+
const response = await fetch(trustedUrl, {
|
|
1168
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1169
|
+
signal: AbortSignal.timeout(META_REQUEST_TIMEOUT_MS),
|
|
1170
|
+
redirect: "error"
|
|
1171
|
+
});
|
|
370
1172
|
if (!response.ok) {
|
|
371
1173
|
const errorData = await response.json().catch(() => ({}));
|
|
372
1174
|
const errorInfo = errorData.error ?? {};
|
|
@@ -400,10 +1202,11 @@ var MetaClient = class {
|
|
|
400
1202
|
});
|
|
401
1203
|
const response = await fetch(url.toString(), {
|
|
402
1204
|
method: "GET",
|
|
403
|
-
redirect: "error",
|
|
404
1205
|
headers: {
|
|
405
1206
|
"Content-Type": "application/json"
|
|
406
|
-
}
|
|
1207
|
+
},
|
|
1208
|
+
signal: AbortSignal.timeout(META_REQUEST_TIMEOUT_MS),
|
|
1209
|
+
redirect: "error"
|
|
407
1210
|
});
|
|
408
1211
|
const data = await response.json();
|
|
409
1212
|
if (data.error) {
|
|
@@ -434,6 +1237,8 @@ var MetaClient = class {
|
|
|
434
1237
|
async getAdAccounts() {
|
|
435
1238
|
const allAccounts = [];
|
|
436
1239
|
let nextUrl;
|
|
1240
|
+
let pageCount = 1;
|
|
1241
|
+
const expectedPathname = `/${this.apiVersion}/me/adaccounts`;
|
|
437
1242
|
const firstResponse = await this.request(
|
|
438
1243
|
"/me/adaccounts",
|
|
439
1244
|
{
|
|
@@ -441,15 +1246,20 @@ var MetaClient = class {
|
|
|
441
1246
|
limit: "100"
|
|
442
1247
|
}
|
|
443
1248
|
);
|
|
444
|
-
allAccounts.push(...firstResponse.data);
|
|
1249
|
+
allAccounts.push(...firstResponse.data.slice(0, MAX_PAGINATION_ROWS));
|
|
445
1250
|
nextUrl = firstResponse.paging?.next;
|
|
446
|
-
while (nextUrl) {
|
|
447
|
-
const paginatedData = await this.fetchPaginatedUrl(nextUrl);
|
|
1251
|
+
while (nextUrl && pageCount < MAX_PAGINATION_PAGES && allAccounts.length < MAX_PAGINATION_ROWS) {
|
|
1252
|
+
const paginatedData = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
|
|
448
1253
|
if (paginatedData.error) {
|
|
449
1254
|
throw new MetaApiException(paginatedData.error);
|
|
450
1255
|
}
|
|
451
|
-
allAccounts.
|
|
1256
|
+
const remainingRows = MAX_PAGINATION_ROWS - allAccounts.length;
|
|
1257
|
+
allAccounts.push(...paginatedData.data.slice(0, remainingRows));
|
|
452
1258
|
nextUrl = paginatedData.paging?.next;
|
|
1259
|
+
pageCount += 1;
|
|
1260
|
+
}
|
|
1261
|
+
if (nextUrl) {
|
|
1262
|
+
throw new Error("Meta ad-account pagination exceeded its page or row safety cap.");
|
|
453
1263
|
}
|
|
454
1264
|
return allAccounts;
|
|
455
1265
|
}
|
|
@@ -457,8 +1267,8 @@ var MetaClient = class {
|
|
|
457
1267
|
* Get a specific ad account.
|
|
458
1268
|
*/
|
|
459
1269
|
async getAdAccount(adAccountId) {
|
|
460
|
-
const
|
|
461
|
-
return this.request(`/${
|
|
1270
|
+
const accountId2 = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
|
|
1271
|
+
return this.request(`/${accountId2}`, {
|
|
462
1272
|
fields: "id,account_id,name,currency,timezone_name,account_status"
|
|
463
1273
|
});
|
|
464
1274
|
}
|
|
@@ -469,13 +1279,15 @@ var MetaClient = class {
|
|
|
469
1279
|
* Fetch insights for a single API request.
|
|
470
1280
|
*/
|
|
471
1281
|
async fetchInsights(adAccountId, apiRequest) {
|
|
472
|
-
const
|
|
1282
|
+
const accountId2 = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
|
|
473
1283
|
const allRows = [];
|
|
474
1284
|
let nextUrl;
|
|
1285
|
+
let pageCount = 1;
|
|
1286
|
+
const expectedPathname = `/${this.apiVersion}/${accountId2}/insights`;
|
|
475
1287
|
const params = {
|
|
476
1288
|
fields: apiRequest.fields.join(","),
|
|
477
1289
|
level: apiRequest.level,
|
|
478
|
-
limit: String(apiRequest.limit || DEFAULT_LIMIT)
|
|
1290
|
+
limit: String(Math.min(Math.max(apiRequest.limit || DEFAULT_LIMIT, 1), MAX_PAGE_ROWS))
|
|
479
1291
|
};
|
|
480
1292
|
if (apiRequest.timeRange) {
|
|
481
1293
|
params.time_range = JSON.stringify({
|
|
@@ -494,35 +1306,35 @@ var MetaClient = class {
|
|
|
494
1306
|
if (apiRequest.actionBreakdowns && apiRequest.actionBreakdowns.length > 0) {
|
|
495
1307
|
params.action_breakdowns = apiRequest.actionBreakdowns.join(",");
|
|
496
1308
|
}
|
|
497
|
-
|
|
498
|
-
if (windows === void 0) {
|
|
499
|
-
params.action_attribution_windows = "1d_click,7d_click,28d_click,1d_view,1d_ev";
|
|
500
|
-
} else if (windows.length > 0) {
|
|
501
|
-
params.action_attribution_windows = windows.join(",");
|
|
502
|
-
}
|
|
1309
|
+
Object.assign(params, measurementParams(apiRequest));
|
|
503
1310
|
if (apiRequest.filtering && apiRequest.filtering.length > 0) {
|
|
504
1311
|
params.filtering = JSON.stringify(apiRequest.filtering);
|
|
505
1312
|
}
|
|
506
1313
|
logger.info("meta", "Fetching insights", {
|
|
507
1314
|
type: apiRequest.type,
|
|
508
|
-
accountId,
|
|
1315
|
+
accountId: accountId2,
|
|
509
1316
|
breakdowns: params.breakdowns || "(none)",
|
|
510
1317
|
action_breakdowns: params.action_breakdowns || "(none)",
|
|
511
1318
|
fieldCount: apiRequest.fields.length
|
|
512
1319
|
});
|
|
513
1320
|
const firstResponse = await this.request(
|
|
514
|
-
`/${
|
|
1321
|
+
`/${accountId2}/insights`,
|
|
515
1322
|
params
|
|
516
1323
|
);
|
|
517
|
-
allRows.push(...firstResponse.data.map(normalizeInsightRow));
|
|
1324
|
+
allRows.push(...firstResponse.data.slice(0, MAX_PAGINATION_ROWS).map(normalizeInsightRow));
|
|
518
1325
|
nextUrl = firstResponse.paging?.next;
|
|
519
|
-
while (nextUrl) {
|
|
520
|
-
const data = await this.fetchPaginatedUrl(nextUrl);
|
|
1326
|
+
while (nextUrl && pageCount < MAX_PAGINATION_PAGES && allRows.length < MAX_PAGINATION_ROWS) {
|
|
1327
|
+
const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
|
|
521
1328
|
if (data.error) {
|
|
522
1329
|
throw new MetaApiException(data.error);
|
|
523
1330
|
}
|
|
524
|
-
allRows.
|
|
1331
|
+
const remainingRows = MAX_PAGINATION_ROWS - allRows.length;
|
|
1332
|
+
allRows.push(...data.data.slice(0, remainingRows).map(normalizeInsightRow));
|
|
525
1333
|
nextUrl = data.paging?.next;
|
|
1334
|
+
pageCount += 1;
|
|
1335
|
+
}
|
|
1336
|
+
if (nextUrl) {
|
|
1337
|
+
throw new Error("Meta insights pagination exceeded its page or row safety cap.");
|
|
526
1338
|
}
|
|
527
1339
|
return allRows;
|
|
528
1340
|
}
|
|
@@ -536,8 +1348,8 @@ var MetaClient = class {
|
|
|
536
1348
|
}
|
|
537
1349
|
const results = [];
|
|
538
1350
|
for (const request2 of plan.requests) {
|
|
539
|
-
const
|
|
540
|
-
results.push(
|
|
1351
|
+
const rows2 = await this.fetchInsights(adAccountId, request2);
|
|
1352
|
+
results.push(rows2);
|
|
541
1353
|
}
|
|
542
1354
|
const mergedData = mergeResults2(
|
|
543
1355
|
results,
|
|
@@ -598,19 +1410,26 @@ var MetaClient = class {
|
|
|
598
1410
|
async getAsyncReportResults(reportRunId) {
|
|
599
1411
|
const allRows = [];
|
|
600
1412
|
let nextUrl;
|
|
1413
|
+
let pageCount = 1;
|
|
1414
|
+
const expectedPathname = `/${this.apiVersion}/${reportRunId}/insights`;
|
|
601
1415
|
const firstResponse = await this.request(
|
|
602
1416
|
`/${reportRunId}/insights`,
|
|
603
1417
|
{ limit: "500" }
|
|
604
1418
|
);
|
|
605
|
-
allRows.push(...firstResponse.data);
|
|
1419
|
+
allRows.push(...firstResponse.data.slice(0, MAX_PAGINATION_ROWS));
|
|
606
1420
|
nextUrl = firstResponse.paging?.next;
|
|
607
|
-
while (nextUrl) {
|
|
608
|
-
const data = await this.fetchPaginatedUrl(nextUrl);
|
|
1421
|
+
while (nextUrl && pageCount < MAX_PAGINATION_PAGES && allRows.length < MAX_PAGINATION_ROWS) {
|
|
1422
|
+
const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
|
|
609
1423
|
if (data.error) {
|
|
610
1424
|
throw new MetaApiException(data.error);
|
|
611
1425
|
}
|
|
612
|
-
allRows.
|
|
1426
|
+
const remainingRows = MAX_PAGINATION_ROWS - allRows.length;
|
|
1427
|
+
allRows.push(...data.data.slice(0, remainingRows));
|
|
613
1428
|
nextUrl = data.paging?.next;
|
|
1429
|
+
pageCount += 1;
|
|
1430
|
+
}
|
|
1431
|
+
if (nextUrl) {
|
|
1432
|
+
throw new Error("Meta async-report pagination exceeded its page or row safety cap.");
|
|
614
1433
|
}
|
|
615
1434
|
return allRows;
|
|
616
1435
|
}
|
|
@@ -646,7 +1465,7 @@ var MetaClient = class {
|
|
|
646
1465
|
* Results are deduplicated by study ID.
|
|
647
1466
|
*/
|
|
648
1467
|
async getAdStudies(adAccountId) {
|
|
649
|
-
const
|
|
1468
|
+
const accountId2 = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
|
|
650
1469
|
const fields = [
|
|
651
1470
|
"id",
|
|
652
1471
|
"name",
|
|
@@ -662,23 +1481,28 @@ var MetaClient = class {
|
|
|
662
1481
|
].join(",");
|
|
663
1482
|
const seenIds = /* @__PURE__ */ new Set();
|
|
664
1483
|
const allStudies = [];
|
|
1484
|
+
let remainingPageBudget = MAX_PAGINATION_PAGES;
|
|
665
1485
|
const fetchFromEdge = async (endpoint) => {
|
|
666
1486
|
try {
|
|
1487
|
+
if (remainingPageBudget < 1 || allStudies.length >= MAX_PAGINATION_ROWS) return;
|
|
1488
|
+
remainingPageBudget -= 1;
|
|
667
1489
|
const firstResponse = await this.request(
|
|
668
1490
|
endpoint,
|
|
669
1491
|
{ fields, limit: "100" }
|
|
670
1492
|
);
|
|
671
|
-
for (const study of firstResponse.data) {
|
|
1493
|
+
for (const study of firstResponse.data.slice(0, MAX_PAGINATION_ROWS - allStudies.length)) {
|
|
672
1494
|
if (!seenIds.has(study.id)) {
|
|
673
1495
|
seenIds.add(study.id);
|
|
674
1496
|
allStudies.push(study);
|
|
675
1497
|
}
|
|
676
1498
|
}
|
|
677
1499
|
let nextUrl = firstResponse.paging?.next;
|
|
678
|
-
|
|
679
|
-
|
|
1500
|
+
const expectedPathname = `/${this.apiVersion}${endpoint}`;
|
|
1501
|
+
while (nextUrl && remainingPageBudget > 0 && allStudies.length < MAX_PAGINATION_ROWS) {
|
|
1502
|
+
remainingPageBudget -= 1;
|
|
1503
|
+
const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
|
|
680
1504
|
if (data.error) break;
|
|
681
|
-
for (const study of data.data) {
|
|
1505
|
+
for (const study of data.data.slice(0, MAX_PAGINATION_ROWS - allStudies.length)) {
|
|
682
1506
|
if (!seenIds.has(study.id)) {
|
|
683
1507
|
seenIds.add(study.id);
|
|
684
1508
|
allStudies.push(study);
|
|
@@ -694,9 +1518,9 @@ var MetaClient = class {
|
|
|
694
1518
|
);
|
|
695
1519
|
}
|
|
696
1520
|
};
|
|
697
|
-
await fetchFromEdge(`/${
|
|
1521
|
+
await fetchFromEdge(`/${accountId2}/ad_studies`);
|
|
698
1522
|
try {
|
|
699
|
-
const accountInfo = await this.request(`/${
|
|
1523
|
+
const accountInfo = await this.request(`/${accountId2}`, { fields: "business{id}" });
|
|
700
1524
|
if (accountInfo.business?.id) {
|
|
701
1525
|
await fetchFromEdge(`/${accountInfo.business.id}/ad_studies`);
|
|
702
1526
|
}
|
|
@@ -778,11 +1602,13 @@ var MetaClient = class {
|
|
|
778
1602
|
async getPages() {
|
|
779
1603
|
const pages = [];
|
|
780
1604
|
let nextUrl = null;
|
|
1605
|
+
let pageCount = 1;
|
|
1606
|
+
const expectedPathname = `/${this.apiVersion}/me/accounts`;
|
|
781
1607
|
const firstPage = await this.request("/me/accounts", {
|
|
782
1608
|
fields: "id,name,picture{url}",
|
|
783
1609
|
limit: "200"
|
|
784
1610
|
});
|
|
785
|
-
for (const p of firstPage.data || []) {
|
|
1611
|
+
for (const p of (firstPage.data || []).slice(0, MAX_PAGINATION_ROWS)) {
|
|
786
1612
|
pages.push({
|
|
787
1613
|
id: p.id,
|
|
788
1614
|
name: p.name,
|
|
@@ -790,10 +1616,10 @@ var MetaClient = class {
|
|
|
790
1616
|
});
|
|
791
1617
|
}
|
|
792
1618
|
nextUrl = firstPage.paging?.next || null;
|
|
793
|
-
while (nextUrl) {
|
|
794
|
-
const data = await this.fetchPaginatedUrl(nextUrl);
|
|
1619
|
+
while (nextUrl && pageCount < MAX_PAGINATION_PAGES && pages.length < MAX_PAGINATION_ROWS) {
|
|
1620
|
+
const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
|
|
795
1621
|
if (data.error) break;
|
|
796
|
-
for (const p of data.data || []) {
|
|
1622
|
+
for (const p of (data.data || []).slice(0, MAX_PAGINATION_ROWS - pages.length)) {
|
|
797
1623
|
pages.push({
|
|
798
1624
|
id: p.id,
|
|
799
1625
|
name: p.name,
|
|
@@ -801,15 +1627,18 @@ var MetaClient = class {
|
|
|
801
1627
|
});
|
|
802
1628
|
}
|
|
803
1629
|
nextUrl = data.paging?.next || null;
|
|
804
|
-
|
|
1630
|
+
pageCount += 1;
|
|
1631
|
+
}
|
|
1632
|
+
if (nextUrl) {
|
|
1633
|
+
throw new Error("Meta managed-page pagination exceeded its page or row safety cap.");
|
|
805
1634
|
}
|
|
806
1635
|
return pages;
|
|
807
1636
|
}
|
|
808
1637
|
/**
|
|
809
1638
|
* Get Instagram accounts associated with an ad account.
|
|
810
1639
|
*/
|
|
811
|
-
async getInstagramAccounts(
|
|
812
|
-
const formattedId =
|
|
1640
|
+
async getInstagramAccounts(accountId2) {
|
|
1641
|
+
const formattedId = accountId2.startsWith("act_") ? accountId2 : `act_${accountId2}`;
|
|
813
1642
|
const accounts = [];
|
|
814
1643
|
try {
|
|
815
1644
|
const result = await this.request(`/${formattedId}/instagram_accounts`, {
|
|
@@ -830,8 +1659,8 @@ var MetaClient = class {
|
|
|
830
1659
|
* Search for campaigns, ad sets, or ads by name using the Management API.
|
|
831
1660
|
* Returns ALL entities regardless of spend/activity (unlike Insights API).
|
|
832
1661
|
*/
|
|
833
|
-
async searchEntities(
|
|
834
|
-
const formattedId =
|
|
1662
|
+
async searchEntities(accountId2, entityType, options = {}) {
|
|
1663
|
+
const formattedId = accountId2.startsWith("act_") ? accountId2 : `act_${accountId2}`;
|
|
835
1664
|
const edge = entityType === "campaign" ? "campaigns" : entityType === "adset" ? "adsets" : "ads";
|
|
836
1665
|
const defaultFields = {
|
|
837
1666
|
campaign: [
|
|
@@ -867,7 +1696,7 @@ var MetaClient = class {
|
|
|
867
1696
|
]
|
|
868
1697
|
};
|
|
869
1698
|
const fields = options.fields?.length ? options.fields : defaultFields[entityType];
|
|
870
|
-
const limit = options.limit
|
|
1699
|
+
const limit = Math.min(Math.max(options.limit ?? 50, 1), MAX_SEARCH_ENTITY_ROWS);
|
|
871
1700
|
const params = {
|
|
872
1701
|
fields: fields.join(","),
|
|
873
1702
|
limit: String(limit)
|
|
@@ -892,14 +1721,20 @@ var MetaClient = class {
|
|
|
892
1721
|
}
|
|
893
1722
|
const results = [];
|
|
894
1723
|
let nextUrl = null;
|
|
1724
|
+
let pageCount = 1;
|
|
1725
|
+
const expectedPathname = `/${this.apiVersion}/${formattedId}/${edge}`;
|
|
895
1726
|
const firstPage = await this.request(`/${formattedId}/${edge}`, params);
|
|
896
1727
|
results.push(...firstPage.data || []);
|
|
897
1728
|
nextUrl = firstPage.paging?.next || null;
|
|
898
|
-
while (nextUrl && results.length < limit) {
|
|
899
|
-
const data = await this.fetchPaginatedUrl(nextUrl);
|
|
1729
|
+
while (nextUrl && results.length < limit && pageCount < MAX_PAGINATION_PAGES) {
|
|
1730
|
+
const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
|
|
900
1731
|
if (data.error) break;
|
|
901
1732
|
results.push(...data.data || []);
|
|
902
1733
|
nextUrl = data.paging?.next || null;
|
|
1734
|
+
pageCount += 1;
|
|
1735
|
+
}
|
|
1736
|
+
if (nextUrl && results.length < limit && pageCount >= MAX_PAGINATION_PAGES) {
|
|
1737
|
+
throw new Error("Meta pagination exceeded the 20-page safety cap.");
|
|
903
1738
|
}
|
|
904
1739
|
return results.slice(0, limit);
|
|
905
1740
|
}
|
|
@@ -949,20 +1784,399 @@ var MetaClient = class {
|
|
|
949
1784
|
* Fetch a full paginated URL (used for "next" page cursors).
|
|
950
1785
|
* Rate-limited via the shared limiter.
|
|
951
1786
|
*/
|
|
952
|
-
async fetchPaginatedUrl(url) {
|
|
1787
|
+
async fetchPaginatedUrl(url, expectedPathname) {
|
|
1788
|
+
const trustedUrl = trustedMetaGraphUrl(url, this.apiVersion, expectedPathname);
|
|
953
1789
|
return this.rateLimiter.execute(async () => {
|
|
954
|
-
const
|
|
955
|
-
const response = await fetch(safeUrl, {
|
|
956
|
-
redirect: "error",
|
|
1790
|
+
const response = await fetch(trustedUrl, {
|
|
957
1791
|
headers: {
|
|
958
1792
|
Authorization: `Bearer ${this.accessToken}`
|
|
959
|
-
}
|
|
1793
|
+
},
|
|
1794
|
+
signal: AbortSignal.timeout(META_REQUEST_TIMEOUT_MS),
|
|
1795
|
+
redirect: "error"
|
|
960
1796
|
});
|
|
961
1797
|
return await response.json();
|
|
962
1798
|
});
|
|
963
1799
|
}
|
|
964
1800
|
};
|
|
965
1801
|
|
|
1802
|
+
// src/platforms/meta/business-assets.ts
|
|
1803
|
+
var record = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
1804
|
+
async function getBusinessAssets(client, args) {
|
|
1805
|
+
const limit = args.limit ?? 100, maxPages = args.maxPages ?? 3;
|
|
1806
|
+
if (args.cursor && (args.businessId || args.adAccountId))
|
|
1807
|
+
throw Error(
|
|
1808
|
+
"Use per-edge cursors for scoped Business discovery; cursor is only for /me/businesses."
|
|
1809
|
+
);
|
|
1810
|
+
const warnings = [], coverage = {}, paging = {};
|
|
1811
|
+
const stores = {
|
|
1812
|
+
businesses: /* @__PURE__ */ new Map(),
|
|
1813
|
+
pages: /* @__PURE__ */ new Map(),
|
|
1814
|
+
instagram_accounts: /* @__PURE__ */ new Map(),
|
|
1815
|
+
pixels: /* @__PURE__ */ new Map(),
|
|
1816
|
+
datasets: /* @__PURE__ */ new Map()
|
|
1817
|
+
};
|
|
1818
|
+
let requests = 0;
|
|
1819
|
+
let account = null;
|
|
1820
|
+
const url = (path, params) => {
|
|
1821
|
+
const u = new URL(
|
|
1822
|
+
`https://graph.facebook.com/${client.apiVersion}/${path}`
|
|
1823
|
+
);
|
|
1824
|
+
for (const [k, v] of Object.entries(params))
|
|
1825
|
+
if (v !== void 0) u.searchParams.set(k, String(v));
|
|
1826
|
+
return u.toString();
|
|
1827
|
+
};
|
|
1828
|
+
const errorInfo = (e) => ({
|
|
1829
|
+
message: e instanceof Error ? e.message : String(e),
|
|
1830
|
+
...e instanceof MetaApiException ? { code: e.code, subcode: e.subcode } : {}
|
|
1831
|
+
});
|
|
1832
|
+
const get = async (path, params) => {
|
|
1833
|
+
requests++;
|
|
1834
|
+
const r = await client.fetchUrl(url(path, params));
|
|
1835
|
+
if (record(r.error)) throw new MetaApiException(r.error);
|
|
1836
|
+
return r;
|
|
1837
|
+
};
|
|
1838
|
+
const add = (kind, items, source, context = {}) => {
|
|
1839
|
+
for (const item of items) {
|
|
1840
|
+
if (!item.id) continue;
|
|
1841
|
+
const id2 = String(item.id), old = stores[kind].get(id2);
|
|
1842
|
+
stores[kind].set(id2, {
|
|
1843
|
+
...old,
|
|
1844
|
+
...item,
|
|
1845
|
+
...context,
|
|
1846
|
+
source,
|
|
1847
|
+
relationships: [
|
|
1848
|
+
...new Map(
|
|
1849
|
+
[...old?.relationships ?? [], { source, ...context }].map((r) => [
|
|
1850
|
+
JSON.stringify(r),
|
|
1851
|
+
r
|
|
1852
|
+
])
|
|
1853
|
+
).values()
|
|
1854
|
+
],
|
|
1855
|
+
sources: [.../* @__PURE__ */ new Set([...old?.sources ?? [], source])]
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
const node = async (path, fields) => {
|
|
1860
|
+
try {
|
|
1861
|
+
const r = await get(path, { fields });
|
|
1862
|
+
coverage[path] = { status: "complete", returnedCount: 1 };
|
|
1863
|
+
return r;
|
|
1864
|
+
} catch (e) {
|
|
1865
|
+
const error = errorInfo(e);
|
|
1866
|
+
coverage[path] = { status: "unavailable", returnedCount: null, error };
|
|
1867
|
+
warnings.push({
|
|
1868
|
+
area: path,
|
|
1869
|
+
...error,
|
|
1870
|
+
suggestion: "Verify access to this exact asset; an unavailable read is not an empty result."
|
|
1871
|
+
});
|
|
1872
|
+
return null;
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
const edge = async (path, fields, fallbackFields) => {
|
|
1876
|
+
const rows2 = [];
|
|
1877
|
+
let after = args.cursors?.[path] ?? (path === "me/businesses" ? args.cursor : void 0);
|
|
1878
|
+
let pages = 0;
|
|
1879
|
+
try {
|
|
1880
|
+
for (; pages < maxPages; ) {
|
|
1881
|
+
let r;
|
|
1882
|
+
try {
|
|
1883
|
+
r = await get(path, { fields, limit, after });
|
|
1884
|
+
} catch (e) {
|
|
1885
|
+
if (!fallbackFields || pages !== 0 || !(e instanceof MetaApiException) || ![10, 100, 200].includes(e.code))
|
|
1886
|
+
throw e;
|
|
1887
|
+
const area = `${path}:instagram_links`;
|
|
1888
|
+
const error = errorInfo(e);
|
|
1889
|
+
coverage[area] = {
|
|
1890
|
+
status: "unavailable",
|
|
1891
|
+
returnedCount: null,
|
|
1892
|
+
error
|
|
1893
|
+
};
|
|
1894
|
+
warnings.push({
|
|
1895
|
+
area,
|
|
1896
|
+
...error,
|
|
1897
|
+
suggestion: "Page identity is retried without Instagram expansions. Missing Instagram links are unavailable, not proof that no account is linked."
|
|
1898
|
+
});
|
|
1899
|
+
fields = fallbackFields;
|
|
1900
|
+
fallbackFields = void 0;
|
|
1901
|
+
r = await get(path, { fields, limit, after });
|
|
1902
|
+
}
|
|
1903
|
+
if (!Array.isArray(r.data))
|
|
1904
|
+
throw Error("Meta did not return a list for this edge.");
|
|
1905
|
+
rows2.push(...r.data.filter(record));
|
|
1906
|
+
pages++;
|
|
1907
|
+
const pg = record(r.paging) ? r.paging : {};
|
|
1908
|
+
const hasMore = typeof pg.next === "string" && pg.next.length > 0;
|
|
1909
|
+
after = record(pg.cursors) && typeof pg.cursors.after === "string" ? pg.cursors.after : void 0;
|
|
1910
|
+
if (hasMore && !after) {
|
|
1911
|
+
const next = new URL(String(pg.next));
|
|
1912
|
+
if (next.origin !== new URL(`https://graph.facebook.com/${client.apiVersion}`).origin || next.pathname !== new URL(url(path, {})).pathname)
|
|
1913
|
+
throw Error("Untrusted pagination route.");
|
|
1914
|
+
after = next.searchParams.get("after") ?? void 0;
|
|
1915
|
+
}
|
|
1916
|
+
if (!hasMore) {
|
|
1917
|
+
coverage[path] = {
|
|
1918
|
+
status: rows2.length ? "complete" : "empty",
|
|
1919
|
+
returnedCount: rows2.length,
|
|
1920
|
+
pagesFetched: pages,
|
|
1921
|
+
hasMore: false
|
|
1922
|
+
};
|
|
1923
|
+
return rows2;
|
|
1924
|
+
}
|
|
1925
|
+
if (!after)
|
|
1926
|
+
throw Error(
|
|
1927
|
+
"Meta indicated more results without a usable edge cursor."
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
coverage[path] = {
|
|
1931
|
+
status: "partial",
|
|
1932
|
+
returnedCount: rows2.length,
|
|
1933
|
+
pagesFetched: pages,
|
|
1934
|
+
hasMore: true
|
|
1935
|
+
};
|
|
1936
|
+
paging[path] = {
|
|
1937
|
+
after,
|
|
1938
|
+
resume: {
|
|
1939
|
+
...args,
|
|
1940
|
+
cursor: void 0,
|
|
1941
|
+
cursors: { ...args.cursors, [path]: after }
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
} catch (e) {
|
|
1945
|
+
const error = errorInfo(e);
|
|
1946
|
+
coverage[path] = {
|
|
1947
|
+
status: rows2.length ? "partial" : "unavailable",
|
|
1948
|
+
returnedCount: rows2.length || null,
|
|
1949
|
+
pagesFetched: pages,
|
|
1950
|
+
hasMore: null,
|
|
1951
|
+
error
|
|
1952
|
+
};
|
|
1953
|
+
warnings.push({
|
|
1954
|
+
area: path,
|
|
1955
|
+
...error,
|
|
1956
|
+
suggestion: "Check this edge and its asset permissions. Missing data is not zero; inspect status and returnedCount for this edge."
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
return rows2;
|
|
1960
|
+
};
|
|
1961
|
+
if (args.adAccountId) {
|
|
1962
|
+
const act2 = args.adAccountId.replace(/^act_/, "");
|
|
1963
|
+
if (!/^\d+$/.test(act2)) throw Error("Invalid ad account ID");
|
|
1964
|
+
account = await node(
|
|
1965
|
+
`act_${act2}`,
|
|
1966
|
+
"id,name,business{id,name,verification_status}"
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
const owner = record(account?.business) ? account.business : null;
|
|
1970
|
+
if (args.businessId && owner && String(owner.id) !== args.businessId)
|
|
1971
|
+
throw Error(
|
|
1972
|
+
"Provided Business does not own the selected ad account. No unrelated Business was queried."
|
|
1973
|
+
);
|
|
1974
|
+
const businessId = args.businessId ?? (owner?.id ? String(owner.id) : void 0);
|
|
1975
|
+
if (businessId) {
|
|
1976
|
+
if (!/^\d+$/.test(businessId)) throw Error("Invalid Business ID");
|
|
1977
|
+
const b = await node(businessId, "id,name,verification_status");
|
|
1978
|
+
if (b) add("businesses", [b], businessId);
|
|
1979
|
+
else if (owner) add("businesses", [owner], "ad_account.business");
|
|
1980
|
+
} else if (!args.adAccountId)
|
|
1981
|
+
add(
|
|
1982
|
+
"businesses",
|
|
1983
|
+
await edge("me/businesses", "id,name,verification_status"),
|
|
1984
|
+
"me/businesses"
|
|
1985
|
+
);
|
|
1986
|
+
else
|
|
1987
|
+
warnings.push({
|
|
1988
|
+
area: "ad_account.business",
|
|
1989
|
+
message: "The selected account did not return its owning Business. Discovery was not broadened to unrelated Businesses.",
|
|
1990
|
+
suggestion: "Verify access or supply the intended businessId explicitly."
|
|
1991
|
+
});
|
|
1992
|
+
if (args.adAccountId && account)
|
|
1993
|
+
add(
|
|
1994
|
+
"pixels",
|
|
1995
|
+
await edge(`${account.id}/adspixels`, "id,name,last_fired_time"),
|
|
1996
|
+
`${account.id}/adspixels`,
|
|
1997
|
+
{ ad_account_id: account.id }
|
|
1998
|
+
);
|
|
1999
|
+
const bs = [...stores.businesses.values()];
|
|
2000
|
+
if (bs.length > 5)
|
|
2001
|
+
warnings.push({
|
|
2002
|
+
area: "business_expansion",
|
|
2003
|
+
message: "Assets are expanded for the first five Businesses only.",
|
|
2004
|
+
suggestion: "Repeat with businessId for any other listed Business."
|
|
2005
|
+
});
|
|
2006
|
+
for (const b of bs.slice(0, 5)) {
|
|
2007
|
+
const id2 = String(b.id);
|
|
2008
|
+
for (const relation of ["owned_pages", "client_pages"]) {
|
|
2009
|
+
const path = `${id2}/${relation}`;
|
|
2010
|
+
const pages = await edge(
|
|
2011
|
+
path,
|
|
2012
|
+
"id,name,category,tasks,instagram_business_account{id,username,name},connected_instagram_account{id,username,name}",
|
|
2013
|
+
"id,name,category,tasks"
|
|
2014
|
+
);
|
|
2015
|
+
add("pages", pages, path, { business_id: id2 });
|
|
2016
|
+
for (const page of pages)
|
|
2017
|
+
for (const field of [
|
|
2018
|
+
"instagram_business_account",
|
|
2019
|
+
"connected_instagram_account"
|
|
2020
|
+
])
|
|
2021
|
+
if (record(page[field]))
|
|
2022
|
+
add("instagram_accounts", [page[field]], `${path}:${field}`, {
|
|
2023
|
+
business_id: id2,
|
|
2024
|
+
page_id: page.id
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
add(
|
|
2028
|
+
"instagram_accounts",
|
|
2029
|
+
await edge(`${id2}/owned_instagram_accounts`, "id,username,name"),
|
|
2030
|
+
`${id2}/owned_instagram_accounts`,
|
|
2031
|
+
{ business_id: id2 }
|
|
2032
|
+
);
|
|
2033
|
+
const igAssets = await edge(
|
|
2034
|
+
`${id2}/client_instagram_assets`,
|
|
2035
|
+
"id,ig_user_id,ig_username"
|
|
2036
|
+
);
|
|
2037
|
+
for (const a of igAssets)
|
|
2038
|
+
if (a.ig_user_id)
|
|
2039
|
+
add(
|
|
2040
|
+
"instagram_accounts",
|
|
2041
|
+
[
|
|
2042
|
+
{
|
|
2043
|
+
id: String(a.ig_user_id),
|
|
2044
|
+
username: a.ig_username,
|
|
2045
|
+
business_asset_id: a.id
|
|
2046
|
+
}
|
|
2047
|
+
],
|
|
2048
|
+
`${id2}/client_instagram_assets`,
|
|
2049
|
+
{ business_id: id2 }
|
|
2050
|
+
);
|
|
2051
|
+
for (const relation of ["owned_pixels", "client_pixels"])
|
|
2052
|
+
add(
|
|
2053
|
+
"pixels",
|
|
2054
|
+
await edge(`${id2}/${relation}`, "id,name,last_fired_time"),
|
|
2055
|
+
`${id2}/${relation}`,
|
|
2056
|
+
{ business_id: id2 }
|
|
2057
|
+
);
|
|
2058
|
+
add(
|
|
2059
|
+
"datasets",
|
|
2060
|
+
await edge(`${id2}/ads_dataset`, "id,name"),
|
|
2061
|
+
`${id2}/ads_dataset`,
|
|
2062
|
+
{ business_id: id2 }
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
const unavailable = Object.entries(coverage).filter(([, c]) => c.status === "unavailable" || c.status === "partial").map(([edge2]) => edge2);
|
|
2066
|
+
const datasetsQueried = Object.entries(coverage).filter(
|
|
2067
|
+
([p]) => p.endsWith("/ads_dataset")
|
|
2068
|
+
);
|
|
2069
|
+
return {
|
|
2070
|
+
scope: {
|
|
2071
|
+
mode: args.adAccountId ? "ad_account" : args.businessId ? "business" : "accessible_businesses",
|
|
2072
|
+
requested_ad_account_id: args.adAccountId ?? null,
|
|
2073
|
+
requested_business_id: args.businessId ?? null
|
|
2074
|
+
},
|
|
2075
|
+
ad_account: account,
|
|
2076
|
+
account_business: owner,
|
|
2077
|
+
...Object.fromEntries(
|
|
2078
|
+
Object.entries(stores).map(([k, v]) => [k, [...v.values()]])
|
|
2079
|
+
),
|
|
2080
|
+
counts: {
|
|
2081
|
+
...Object.fromEntries(
|
|
2082
|
+
Object.entries(stores).map(([k, v]) => [k, v.size])
|
|
2083
|
+
),
|
|
2084
|
+
datasets: !datasetsQueried.length || datasetsQueried.every(([, c]) => c.status === "unavailable") ? null : stores.datasets.size
|
|
2085
|
+
},
|
|
2086
|
+
coverage,
|
|
2087
|
+
paging,
|
|
2088
|
+
warnings,
|
|
2089
|
+
limitations: [
|
|
2090
|
+
...unavailable.map((edge2) => ({ edge: edge2, status: coverage[edge2].status })),
|
|
2091
|
+
...bs.length > 5 ? [{ area: "business_expansion", status: "partial" }] : []
|
|
2092
|
+
],
|
|
2093
|
+
notes: [
|
|
2094
|
+
"Counts describe returned records, not the size of inaccessible inventories. Inspect per-edge coverage.",
|
|
2095
|
+
"Source edges show owned/client/linked relationships; presence in a Business inventory does not prove assignment to the selected ad account.",
|
|
2096
|
+
"Page tasks and linked Instagram references do not prove that every API operation is authorized. Use relationships to retain all observed Business/Page associations for deduplicated assets.",
|
|
2097
|
+
"Dataset discovery uses Business ads_dataset; it is not the removed offline_conversion_data_sets edge."
|
|
2098
|
+
],
|
|
2099
|
+
nextActions: Object.entries(paging).map(([edge2, p]) => ({
|
|
2100
|
+
edge: edge2,
|
|
2101
|
+
tool: "meta_get_business_assets",
|
|
2102
|
+
arguments: p.resume
|
|
2103
|
+
})),
|
|
2104
|
+
debug: { requestCount: requests }
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// src/platforms/meta/organic-insights.ts
|
|
2109
|
+
function instagramInsightMetrics(media) {
|
|
2110
|
+
const type = media.media_type;
|
|
2111
|
+
const product = media.media_product_type;
|
|
2112
|
+
if (product === "STORY") return ["views", "reach"];
|
|
2113
|
+
const feed = ["views", "reach", "likes", "comments", "saved", "shares", "total_interactions"];
|
|
2114
|
+
if (product === "REELS" && type === "VIDEO") {
|
|
2115
|
+
return [...feed, "ig_reels_video_view_total_time", "ig_reels_avg_watch_time"];
|
|
2116
|
+
}
|
|
2117
|
+
if (type === "IMAGE" || type === "CAROUSEL_ALBUM") return feed;
|
|
2118
|
+
return ["reach"];
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// src/platforms/meta/creative-media.ts
|
|
2122
|
+
var obj2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
2123
|
+
var rows = (v) => Array.isArray(v) ? v.map(obj2) : [];
|
|
2124
|
+
var str = (...vs) => vs.find((v) => typeof v === "string" && v.length > 0 && !v.includes("{{"));
|
|
2125
|
+
function metaImageFileUrl(...values) {
|
|
2126
|
+
for (const value of values) {
|
|
2127
|
+
if (typeof value !== "string" || !value || value.includes("{{")) continue;
|
|
2128
|
+
try {
|
|
2129
|
+
const url = new URL(value);
|
|
2130
|
+
if (!/^https?:$/.test(url.protocol)) continue;
|
|
2131
|
+
if (/(^|\.)(facebook\.com|fb\.com)$/.test(url.hostname)) continue;
|
|
2132
|
+
return value;
|
|
2133
|
+
} catch {
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
function metaCreativeMedia(creative) {
|
|
2138
|
+
const story = obj2(creative.object_story_spec), feed = obj2(creative.asset_feed_spec);
|
|
2139
|
+
const link = obj2(story.link_data), video = obj2(story.video_data), photo = obj2(story.photo_data), template = obj2(story.template_data);
|
|
2140
|
+
const catalog = Boolean(creative.product_set_id || link.product_set_id || template.product_set_id || feed.product_set_id || Object.keys(template).length);
|
|
2141
|
+
const media = [];
|
|
2142
|
+
const add = (entry, fallback) => {
|
|
2143
|
+
const video_id = str(entry.video_id);
|
|
2144
|
+
const image_hash = str(entry.image_hash, entry.hash);
|
|
2145
|
+
const image_url = metaImageFileUrl(entry.image_url, entry.picture, entry.url);
|
|
2146
|
+
if (!video_id && !image_hash && !image_url) return;
|
|
2147
|
+
media.push({
|
|
2148
|
+
media_id: `${video_id ? "video" : "image"}:${video_id ?? image_hash ?? image_url}`,
|
|
2149
|
+
video_id,
|
|
2150
|
+
image_hash,
|
|
2151
|
+
image_url,
|
|
2152
|
+
thumbnail_url: metaImageFileUrl(entry.thumbnail_url, image_url, fallback?.thumbnail_url),
|
|
2153
|
+
source: str(entry.source)
|
|
2154
|
+
});
|
|
2155
|
+
};
|
|
2156
|
+
const children = rows(link.child_attachments);
|
|
2157
|
+
if (catalog) {
|
|
2158
|
+
add(video);
|
|
2159
|
+
add(link);
|
|
2160
|
+
add(template);
|
|
2161
|
+
} else if (children.length) {
|
|
2162
|
+
children.forEach((child) => add(child));
|
|
2163
|
+
} else {
|
|
2164
|
+
add(video, creative);
|
|
2165
|
+
add(photo, creative);
|
|
2166
|
+
add(link, creative);
|
|
2167
|
+
}
|
|
2168
|
+
if (!catalog) {
|
|
2169
|
+
rows(feed.images).forEach((image) => add(image));
|
|
2170
|
+
rows(feed.videos).forEach((item) => add(item));
|
|
2171
|
+
if (!media.length) {
|
|
2172
|
+
add(creative);
|
|
2173
|
+
if (!media.length && metaImageFileUrl(creative.thumbnail_url)) media.push({ media_id: String(creative.id ?? "preview"), thumbnail_url: metaImageFileUrl(creative.thumbnail_url) });
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const unique = [...new Map(media.map((item) => [item.media_id, item])).values()];
|
|
2177
|
+
return { format: catalog ? unique.length ? "collection" : "catalog_dpa" : children.length ? "carousel" : unique.length > 1 ? "flexible" : "single", media: unique };
|
|
2178
|
+
}
|
|
2179
|
+
|
|
966
2180
|
// src/platforms/meta/metric-catalog.ts
|
|
967
2181
|
var ALL_BREAKDOWNS = [
|
|
968
2182
|
"age",
|
|
@@ -5808,11 +7022,11 @@ function calculateDerivedMetrics(row) {
|
|
|
5808
7022
|
}
|
|
5809
7023
|
|
|
5810
7024
|
// src/platforms/meta/broad-read.ts
|
|
5811
|
-
import { z as
|
|
5812
|
-
var graphIdSchema =
|
|
5813
|
-
var graphFieldSchema =
|
|
5814
|
-
var graphDimensionSchema =
|
|
5815
|
-
var dateSchema =
|
|
7025
|
+
import { z as z4 } from "zod";
|
|
7026
|
+
var graphIdSchema = z4.string().trim().min(1).max(200).regex(/^[A-Za-z0-9_.:-]+$/, "Use a Graph node ID without slashes or query parameters");
|
|
7027
|
+
var graphFieldSchema = z4.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Graph field name");
|
|
7028
|
+
var graphDimensionSchema = z4.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Insights field or breakdown name");
|
|
7029
|
+
var dateSchema = z4.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
|
|
5816
7030
|
var META_BROAD_READ_EDGES = [
|
|
5817
7031
|
"accounts",
|
|
5818
7032
|
"activities",
|
|
@@ -5862,16 +7076,16 @@ var META_BROAD_READ_EDGES = [
|
|
|
5862
7076
|
"cells",
|
|
5863
7077
|
"objectives"
|
|
5864
7078
|
];
|
|
5865
|
-
var readEdgeSchema =
|
|
5866
|
-
var filterValueSchema =
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
7079
|
+
var readEdgeSchema = z4.enum(META_BROAD_READ_EDGES);
|
|
7080
|
+
var filterValueSchema = z4.union([
|
|
7081
|
+
z4.string().max(2e3),
|
|
7082
|
+
z4.number().finite(),
|
|
7083
|
+
z4.boolean(),
|
|
7084
|
+
z4.array(z4.union([z4.string().max(500), z4.number().finite(), z4.boolean()])).max(500)
|
|
5871
7085
|
]);
|
|
5872
|
-
var graphFilterSchema =
|
|
5873
|
-
field:
|
|
5874
|
-
operator:
|
|
7086
|
+
var graphFilterSchema = z4.object({
|
|
7087
|
+
field: z4.string().trim().min(1).max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*$/, "Invalid filtering field"),
|
|
7088
|
+
operator: z4.enum([
|
|
5875
7089
|
"EQUAL",
|
|
5876
7090
|
"NOT_EQUAL",
|
|
5877
7091
|
"GREATER_THAN",
|
|
@@ -5892,7 +7106,7 @@ var graphFilterSchema = z2.object({
|
|
|
5892
7106
|
]),
|
|
5893
7107
|
value: filterValueSchema
|
|
5894
7108
|
});
|
|
5895
|
-
var datePresetSchema =
|
|
7109
|
+
var datePresetSchema = z4.enum([
|
|
5896
7110
|
"today",
|
|
5897
7111
|
"yesterday",
|
|
5898
7112
|
"this_month",
|
|
@@ -5914,7 +7128,7 @@ var datePresetSchema = z2.enum([
|
|
|
5914
7128
|
"this_week_sun_today",
|
|
5915
7129
|
"this_year"
|
|
5916
7130
|
]);
|
|
5917
|
-
var targetingSearchTypeSchema =
|
|
7131
|
+
var targetingSearchTypeSchema = z4.enum([
|
|
5918
7132
|
"adinterest",
|
|
5919
7133
|
"adbehaviors",
|
|
5920
7134
|
"adinterestsuggestion",
|
|
@@ -5992,8 +7206,8 @@ function registerMetaBroadReadTools(server, client, ok3) {
|
|
|
5992
7206
|
"Read arbitrary flat fields from one Meta Graph node. This GET-only escape hatch covers newly released campaign, ad set, ad, creative, audience, catalog, Page, Instagram, pixel, and other accessible node fields without waiting for a fixed MCP schema update.",
|
|
5993
7207
|
{
|
|
5994
7208
|
nodeId: graphIdSchema.describe("Graph node ID, for example a campaign, ad set, ad, creative, Page, IG account, pixel, audience, or catalog ID"),
|
|
5995
|
-
fields:
|
|
5996
|
-
includeMetadata:
|
|
7209
|
+
fields: z4.array(graphFieldSchema).min(1).max(100).describe("Flat Graph field names; nested field expansion is intentionally disabled"),
|
|
7210
|
+
includeMetadata: z4.boolean().optional().default(false).describe("Ask Graph for field metadata when supported")
|
|
5997
7211
|
},
|
|
5998
7212
|
async ({ nodeId, fields, includeMetadata }) => {
|
|
5999
7213
|
try {
|
|
@@ -6014,15 +7228,15 @@ function registerMetaBroadReadTools(server, client, ok3) {
|
|
|
6014
7228
|
{
|
|
6015
7229
|
parentId: graphIdSchema.describe("Parent Graph node ID, such as act_123, a Business, Page, catalog, campaign, or ad set ID"),
|
|
6016
7230
|
edge: readEdgeSchema,
|
|
6017
|
-
fields:
|
|
6018
|
-
filtering:
|
|
6019
|
-
parameters:
|
|
7231
|
+
fields: z4.array(graphFieldSchema).min(1).max(100).optional(),
|
|
7232
|
+
filtering: z4.array(graphFilterSchema).max(20).optional(),
|
|
7233
|
+
parameters: z4.record(z4.unknown()).optional().describe("Additional documented GET parameters, for example targeting_spec or optimization_goal; auth and HTTP method override parameters are blocked"),
|
|
6020
7234
|
since: dateSchema.optional(),
|
|
6021
7235
|
until: dateSchema.optional(),
|
|
6022
|
-
limit:
|
|
6023
|
-
after:
|
|
6024
|
-
before:
|
|
6025
|
-
includeSummary:
|
|
7236
|
+
limit: z4.number().int().min(1).max(500).optional().default(100),
|
|
7237
|
+
after: z4.string().max(2e3).optional(),
|
|
7238
|
+
before: z4.string().max(2e3).optional(),
|
|
7239
|
+
includeSummary: z4.boolean().optional().default(false)
|
|
6026
7240
|
},
|
|
6027
7241
|
async ({ parentId, edge, fields, filtering, parameters, since, until, limit, after, before, includeSummary }) => {
|
|
6028
7242
|
try {
|
|
@@ -6051,19 +7265,19 @@ function registerMetaBroadReadTools(server, client, ok3) {
|
|
|
6051
7265
|
"Query the Meta Insights edge with validated native field names, breakdowns, action breakdowns, attribution windows, filters, sort, summary, and pagination. This complements meta_get_insights when Meta adds fields before the curated metric catalog is updated.",
|
|
6052
7266
|
{
|
|
6053
7267
|
objectId: graphIdSchema.describe("Ad account (act_...), campaign, ad set, or ad ID whose /insights edge should be queried"),
|
|
6054
|
-
fields:
|
|
6055
|
-
level:
|
|
6056
|
-
breakdowns:
|
|
6057
|
-
actionBreakdowns:
|
|
6058
|
-
actionAttributionWindows:
|
|
7268
|
+
fields: z4.array(graphDimensionSchema).min(1).max(100).describe("Native Meta Insights API fields; calculated aliases are not accepted here"),
|
|
7269
|
+
level: z4.enum(["account", "campaign", "adset", "ad"]).optional(),
|
|
7270
|
+
breakdowns: z4.array(graphDimensionSchema).max(20).optional(),
|
|
7271
|
+
actionBreakdowns: z4.array(graphDimensionSchema).max(20).optional(),
|
|
7272
|
+
actionAttributionWindows: z4.array(z4.enum(["1d_click", "7d_click", "28d_click", "1d_view", "1d_ev", "dda", "skan_click", "skan_view"])).max(8).optional(),
|
|
6059
7273
|
datePreset: datePresetSchema.optional(),
|
|
6060
|
-
timeRange:
|
|
6061
|
-
timeIncrement:
|
|
6062
|
-
filtering:
|
|
6063
|
-
sort:
|
|
6064
|
-
limit:
|
|
6065
|
-
after:
|
|
6066
|
-
includeSummary:
|
|
7274
|
+
timeRange: z4.object({ since: dateSchema, until: dateSchema }).optional(),
|
|
7275
|
+
timeIncrement: z4.union([z4.number().int().min(1).max(90), z4.enum(["monthly", "all_days"])]).optional(),
|
|
7276
|
+
filtering: z4.array(graphFilterSchema).max(20).optional(),
|
|
7277
|
+
sort: z4.string().trim().max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*_(ascending|descending)$/).optional(),
|
|
7278
|
+
limit: z4.number().int().min(1).max(500).optional().default(100),
|
|
7279
|
+
after: z4.string().max(2e3).optional(),
|
|
7280
|
+
includeSummary: z4.boolean().optional().default(false)
|
|
6067
7281
|
},
|
|
6068
7282
|
async ({ objectId, fields, level, breakdowns, actionBreakdowns, actionAttributionWindows, datePreset, timeRange, timeIncrement, filtering, sort, limit, after, includeSummary }) => {
|
|
6069
7283
|
try {
|
|
@@ -6100,10 +7314,10 @@ function registerMetaBroadReadTools(server, client, ok3) {
|
|
|
6100
7314
|
"Search Meta's read-only targeting metadata for interests, validated interests, geographies, locales, countries, cities, regions, markets, or postal codes.",
|
|
6101
7315
|
{
|
|
6102
7316
|
type: targetingSearchTypeSchema,
|
|
6103
|
-
query:
|
|
6104
|
-
countryCode:
|
|
6105
|
-
locationTypes:
|
|
6106
|
-
limit:
|
|
7317
|
+
query: z4.string().trim().min(1).max(200).optional().describe("Search text; required by most interest and geography searches"),
|
|
7318
|
+
countryCode: z4.string().trim().length(2).transform((value) => value.toUpperCase()).optional(),
|
|
7319
|
+
locationTypes: z4.array(z4.enum(["country", "country_group", "region", "city", "zip", "geo_market", "electoral_district"])).max(7).optional(),
|
|
7320
|
+
limit: z4.number().int().min(1).max(1e3).optional().default(100)
|
|
6107
7321
|
},
|
|
6108
7322
|
async ({ type, query, countryCode, locationTypes, limit }) => {
|
|
6109
7323
|
try {
|
|
@@ -6129,7 +7343,7 @@ function registerMetaBroadReadTools(server, client, ok3) {
|
|
|
6129
7343
|
"Get the read-only preview markup for an existing Meta ad in a requested placement format. The response may contain an iframe body but never creates or edits a creative.",
|
|
6130
7344
|
{
|
|
6131
7345
|
adId: graphIdSchema,
|
|
6132
|
-
adFormat:
|
|
7346
|
+
adFormat: z4.string().trim().min(1).max(100).regex(/^[A-Z][A-Z0-9_]*$/).optional().default("DESKTOP_FEED_STANDARD")
|
|
6133
7347
|
},
|
|
6134
7348
|
async ({ adId, adFormat }) => {
|
|
6135
7349
|
try {
|
|
@@ -6143,12 +7357,12 @@ function registerMetaBroadReadTools(server, client, ok3) {
|
|
|
6143
7357
|
}
|
|
6144
7358
|
|
|
6145
7359
|
// src/platforms/meta/tools.ts
|
|
6146
|
-
var adAccountIdSchema =
|
|
6147
|
-
var levelSchema =
|
|
6148
|
-
var statusFilterSchema =
|
|
6149
|
-
var limitSchema =
|
|
6150
|
-
var cursorSchema =
|
|
6151
|
-
var productBreakdownSchema =
|
|
7360
|
+
var adAccountIdSchema = z5.string().describe("Ad account ID (e.g., act_123456789)");
|
|
7361
|
+
var levelSchema = z5.enum(["account", "campaign", "adset", "ad"]);
|
|
7362
|
+
var statusFilterSchema = z5.enum(["ACTIVE", "PAUSED", "DELETED", "ARCHIVED"]).optional().describe("Filter by native effective_status, not configured status. PAUSED excludes IN_PROCESS, CAMPAIGN_PAUSED and ADSET_PAUSED. Omit for post-creation verification and inspect both returned status fields.");
|
|
7363
|
+
var limitSchema = z5.number().int().min(1).max(500).optional().default(100);
|
|
7364
|
+
var cursorSchema = z5.string().optional().describe("Pagination cursor from previous response");
|
|
7365
|
+
var productBreakdownSchema = z5.enum([
|
|
6152
7366
|
"product_id",
|
|
6153
7367
|
"product_brand_breakdown",
|
|
6154
7368
|
"product_category_breakdown",
|
|
@@ -6160,7 +7374,7 @@ var productBreakdownSchema = z3.enum([
|
|
|
6160
7374
|
"product_custom_label_4_breakdown",
|
|
6161
7375
|
"product_group_content_id_breakdown"
|
|
6162
7376
|
]);
|
|
6163
|
-
var datePresetSchema2 =
|
|
7377
|
+
var datePresetSchema2 = z5.enum([
|
|
6164
7378
|
"today",
|
|
6165
7379
|
"yesterday",
|
|
6166
7380
|
"this_month",
|
|
@@ -6182,9 +7396,9 @@ var datePresetSchema2 = z3.enum([
|
|
|
6182
7396
|
"this_week_sun_today",
|
|
6183
7397
|
"this_year"
|
|
6184
7398
|
]).optional();
|
|
6185
|
-
var timeRangeSchema =
|
|
6186
|
-
since:
|
|
6187
|
-
until:
|
|
7399
|
+
var timeRangeSchema = z5.object({
|
|
7400
|
+
since: z5.string().describe("Start date YYYY-MM-DD"),
|
|
7401
|
+
until: z5.string().describe("End date YYYY-MM-DD")
|
|
6188
7402
|
}).optional();
|
|
6189
7403
|
var META_GRAPH_BASE = "https://graph.facebook.com";
|
|
6190
7404
|
var REQUIRED_READ_SCOPES = ["ads_read"];
|
|
@@ -6318,10 +7532,46 @@ async function fetchGraphWithFallback(client, area, primaryUrl, fallbackUrl, war
|
|
|
6318
7532
|
}
|
|
6319
7533
|
}
|
|
6320
7534
|
}
|
|
7535
|
+
function pageClientResolver(client, warnings) {
|
|
7536
|
+
const pageClients = /* @__PURE__ */ new Map();
|
|
7537
|
+
const pageClientFor = async (id2) => {
|
|
7538
|
+
if (!pageClients.has(id2)) {
|
|
7539
|
+
const result = await fetchGraph(
|
|
7540
|
+
client,
|
|
7541
|
+
"organic_page_token",
|
|
7542
|
+
graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: "access_token" }),
|
|
7543
|
+
warnings,
|
|
7544
|
+
"Grant pages_show_list/pages_read_engagement and access to this Page, then reconnect Meta if necessary."
|
|
7545
|
+
);
|
|
7546
|
+
const token = typeof result?.access_token === "string" ? result.access_token : void 0;
|
|
7547
|
+
pageClients.set(id2, token ? new MetaClient(token, client.apiVersion) : null);
|
|
7548
|
+
if (result && !token) warnings.push({ area: "organic_page_token", message: "Meta did not return a Page access token.", suggestion: "Verify this user's Page tasks and reconnect Meta. No user token is substituted for a Page token." });
|
|
7549
|
+
}
|
|
7550
|
+
return pageClients.get(id2) ?? null;
|
|
7551
|
+
};
|
|
7552
|
+
return pageClientFor;
|
|
7553
|
+
}
|
|
6321
7554
|
function dataArray(result) {
|
|
6322
7555
|
const data = result?.data;
|
|
6323
7556
|
return Array.isArray(data) ? data.filter(isRecord) : [];
|
|
6324
7557
|
}
|
|
7558
|
+
function entityListResult(result, statusFilter) {
|
|
7559
|
+
return {
|
|
7560
|
+
...result,
|
|
7561
|
+
coverage: {
|
|
7562
|
+
scope: "returned_page",
|
|
7563
|
+
returned_count: dataArray(result).length,
|
|
7564
|
+
has_more: !!(isRecord(result.paging) && result.paging.next),
|
|
7565
|
+
status_filter_field: statusFilter ? "effective_status" : null,
|
|
7566
|
+
status_filter: statusFilter ?? null
|
|
7567
|
+
},
|
|
7568
|
+
notes: [
|
|
7569
|
+
"Follow paging.cursors.after while paging.next is present. A limited or filtered listing does not prove that an object does not exist.",
|
|
7570
|
+
"status is configured status; effective_status can differ during processing or due to a paused parent. Omit statusFilter when verifying a newly created object.",
|
|
7571
|
+
"An empty listing alone does not establish indexing lag or its cause. Read known campaign IDs with meta_get_campaign_structure."
|
|
7572
|
+
]
|
|
7573
|
+
};
|
|
7574
|
+
}
|
|
6325
7575
|
function pagingInfo(result) {
|
|
6326
7576
|
return isRecord(result?.paging) ? result.paging : void 0;
|
|
6327
7577
|
}
|
|
@@ -6334,11 +7584,41 @@ function pictureUrl(value) {
|
|
|
6334
7584
|
}
|
|
6335
7585
|
return void 0;
|
|
6336
7586
|
}
|
|
7587
|
+
function thumbnailSummary(value) {
|
|
7588
|
+
if (!value || typeof value.uri !== "string") return void 0;
|
|
7589
|
+
return { uri: value.uri, width: value.width, height: value.height };
|
|
7590
|
+
}
|
|
7591
|
+
function normalizeAdVideo(video, includeSource) {
|
|
7592
|
+
const status = isRecord(video.status) ? video.status : void 0;
|
|
7593
|
+
const thumbnailEdge = isRecord(video.thumbnails) ? video.thumbnails.data : void 0;
|
|
7594
|
+
const thumbnails = Array.isArray(thumbnailEdge) ? thumbnailEdge.filter(isRecord) : [];
|
|
7595
|
+
const preferred = thumbnails.find((thumb) => thumb.is_preferred === true) ?? thumbnails[0];
|
|
7596
|
+
let largest;
|
|
7597
|
+
for (const thumb of thumbnails) {
|
|
7598
|
+
if (!largest || (Number(thumb.width) || 0) > (Number(largest.width) || 0)) largest = thumb;
|
|
7599
|
+
}
|
|
7600
|
+
const from = isRecord(video.from) ? video.from : void 0;
|
|
7601
|
+
return {
|
|
7602
|
+
id: video.id,
|
|
7603
|
+
title: video.title,
|
|
7604
|
+
video_status: typeof status?.video_status === "string" ? status.video_status : void 0,
|
|
7605
|
+
duration_seconds: typeof video.length === "number" ? video.length : void 0,
|
|
7606
|
+
owner: from ? { id: from.id, name: from.name } : void 0,
|
|
7607
|
+
created_time: video.created_time,
|
|
7608
|
+
updated_time: video.updated_time,
|
|
7609
|
+
picture: video.picture,
|
|
7610
|
+
permalink_url: video.permalink_url,
|
|
7611
|
+
thumbnail_preferred: thumbnailSummary(preferred),
|
|
7612
|
+
thumbnail_largest: thumbnailSummary(largest),
|
|
7613
|
+
thumbnail_count: thumbnails.length,
|
|
7614
|
+
...includeSource ? { source: video.source } : {}
|
|
7615
|
+
};
|
|
7616
|
+
}
|
|
6337
7617
|
function addById(target, items) {
|
|
6338
7618
|
for (const item of items) {
|
|
6339
|
-
const
|
|
6340
|
-
if (!
|
|
6341
|
-
target.set(
|
|
7619
|
+
const id2 = typeof item.id === "string" ? item.id : void 0;
|
|
7620
|
+
if (!id2) continue;
|
|
7621
|
+
target.set(id2, { ...target.get(id2) ?? {}, ...item });
|
|
6342
7622
|
}
|
|
6343
7623
|
}
|
|
6344
7624
|
function normalizePage(page, source) {
|
|
@@ -6414,6 +7694,10 @@ function normalizeCreativeAsset(source, sourceType) {
|
|
|
6414
7694
|
const photoData = isRecord(spec.photo_data) ? spec.photo_data : void 0;
|
|
6415
7695
|
const templateData = isRecord(spec.template_data) ? spec.template_data : void 0;
|
|
6416
7696
|
return {
|
|
7697
|
+
...(() => {
|
|
7698
|
+
const media = metaCreativeMedia(creative);
|
|
7699
|
+
return { creative_format: media.format, media: media.media };
|
|
7700
|
+
})(),
|
|
6417
7701
|
source_type: sourceType,
|
|
6418
7702
|
ad_id: sourceType === "ad" ? source.id : void 0,
|
|
6419
7703
|
ad_name: sourceType === "ad" ? source.name : void 0,
|
|
@@ -6748,6 +8032,7 @@ function normalizeInstagramMedia(media) {
|
|
|
6748
8032
|
id: media.id,
|
|
6749
8033
|
caption: media.caption,
|
|
6750
8034
|
media_type: media.media_type,
|
|
8035
|
+
media_product_type: media.media_product_type,
|
|
6751
8036
|
media_url: media.media_url,
|
|
6752
8037
|
thumbnail_url: media.thumbnail_url,
|
|
6753
8038
|
permalink: media.permalink,
|
|
@@ -6887,256 +8172,51 @@ function registerMetaTools(server, config) {
|
|
|
6887
8172
|
"meta_get_business_assets",
|
|
6888
8173
|
"Discover accessible Meta Business assets read-only: businesses, pages, Instagram accounts, pixels, and datasets when permissions allow.",
|
|
6889
8174
|
{
|
|
6890
|
-
businessId:
|
|
6891
|
-
adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID to discover
|
|
8175
|
+
businessId: z5.string().optional().describe("Business Manager ID. With adAccountId, discover only its owning Business; otherwise list /me/businesses when this is omitted."),
|
|
8176
|
+
adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID to discover its owner Business and assigned pixels; datasets are read from that Business when permitted."),
|
|
6892
8177
|
limit: limitSchema,
|
|
8178
|
+
cursor: cursorSchema.describe("Legacy cursor for /me/businesses only. Use cursors for asset edges."),
|
|
8179
|
+
cursors: z5.record(z5.string(), z5.string()).optional().describe("Per-edge cursors from paging/nextActions. Never reuse a cursor for another edge."),
|
|
8180
|
+
maxPages: z5.number().int().min(1).max(5).default(3).describe("Maximum pages per edge; remaining pages are reported explicitly.")
|
|
8181
|
+
},
|
|
8182
|
+
async (args) => {
|
|
8183
|
+
try {
|
|
8184
|
+
return ok(await getBusinessAssets(client, args));
|
|
8185
|
+
} catch (e) {
|
|
8186
|
+
return formatMcpToolError(e);
|
|
8187
|
+
}
|
|
8188
|
+
}
|
|
8189
|
+
);
|
|
8190
|
+
server.tool(
|
|
8191
|
+
"meta_get_pages",
|
|
8192
|
+
"List accessible Facebook Pages with id, name, category, tasks, picture, and linked Instagram account references when available.",
|
|
8193
|
+
{
|
|
8194
|
+
businessId: z5.string().optional().describe("Optional Business Manager ID to list owned/client pages. Omit to use /me/accounts."),
|
|
8195
|
+
limit: z5.number().int().min(1).max(200).optional().default(100),
|
|
6893
8196
|
cursor: cursorSchema
|
|
6894
8197
|
},
|
|
6895
|
-
async ({ businessId,
|
|
8198
|
+
async ({ businessId, limit, cursor }) => {
|
|
6896
8199
|
const warnings = [];
|
|
6897
|
-
const businessIds = /* @__PURE__ */ new Set();
|
|
6898
|
-
const businessesById = /* @__PURE__ */ new Map();
|
|
6899
8200
|
const pagesById = /* @__PURE__ */ new Map();
|
|
6900
|
-
const
|
|
6901
|
-
const
|
|
6902
|
-
const
|
|
6903
|
-
const businessFields = "id,name,verification_status,created_time,updated_time";
|
|
6904
|
-
const pageFields = "id,name,category,tasks,picture{url},instagram_business_account{id,username,name,ig_id,profile_picture_url},connected_instagram_account{id,username,name,ig_id,profile_picture_url}";
|
|
6905
|
-
const pageFallbackFields = "id,name,category,tasks,picture{url}";
|
|
6906
|
-
const instagramFields = "id,username,name,ig_id,profile_picture_url";
|
|
6907
|
-
const pixelFields = "id,name,last_fired_time,creation_time,owner_ad_account,business";
|
|
6908
|
-
const pixelFallbackFields = "id,name,last_fired_time,creation_time";
|
|
6909
|
-
const datasetFields = "id,name,description,creation_time,updated_time";
|
|
6910
|
-
const datasetFallbackFields = "id,name";
|
|
8201
|
+
const fields = "id,name,category,tasks,picture{url},instagram_business_account{id,username,name,ig_id,profile_picture_url},connected_instagram_account{id,username,name,ig_id,profile_picture_url}";
|
|
8202
|
+
const fallbackFields = "id,name,category,tasks,picture{url}";
|
|
8203
|
+
const paging = {};
|
|
6911
8204
|
if (businessId) {
|
|
6912
|
-
|
|
6913
|
-
const business = await fetchGraph(
|
|
8205
|
+
const ownedPages = await fetchGraphWithFallback(
|
|
6914
8206
|
client,
|
|
6915
|
-
"
|
|
6916
|
-
graphUrl(client, `/${businessId}`, { fields:
|
|
8207
|
+
"owned_pages",
|
|
8208
|
+
graphUrl(client, `/${businessId}/owned_pages`, { fields, limit, after: cursor }),
|
|
8209
|
+
graphUrl(client, `/${businessId}/owned_pages`, { fields: fallbackFields, limit, after: cursor }),
|
|
6917
8210
|
warnings,
|
|
6918
|
-
"Grant business_management
|
|
8211
|
+
"Grant business_management plus pages_show_list/pages_read_engagement to read Business-owned Pages."
|
|
6919
8212
|
);
|
|
6920
|
-
|
|
6921
|
-
} else {
|
|
6922
|
-
const businesses = await fetchGraph(
|
|
8213
|
+
const clientPages = await fetchGraphWithFallback(
|
|
6923
8214
|
client,
|
|
6924
|
-
"
|
|
6925
|
-
graphUrl(client,
|
|
8215
|
+
"client_pages",
|
|
8216
|
+
graphUrl(client, `/${businessId}/client_pages`, { fields, limit, after: cursor }),
|
|
8217
|
+
graphUrl(client, `/${businessId}/client_pages`, { fields: fallbackFields, limit, after: cursor }),
|
|
6926
8218
|
warnings,
|
|
6927
|
-
"
|
|
6928
|
-
);
|
|
6929
|
-
for (const business of dataArray(businesses)) {
|
|
6930
|
-
const id = typeof business.id === "string" ? business.id : void 0;
|
|
6931
|
-
if (!id) continue;
|
|
6932
|
-
businessIds.add(id);
|
|
6933
|
-
businessesById.set(id, business);
|
|
6934
|
-
}
|
|
6935
|
-
}
|
|
6936
|
-
if (adAccountId) {
|
|
6937
|
-
const accountBusiness = await fetchGraph(
|
|
6938
|
-
client,
|
|
6939
|
-
"ad_account_business",
|
|
6940
|
-
graphUrl(client, `/${formatAdAccountId(adAccountId)}`, {
|
|
6941
|
-
fields: "id,name,business{id,name,verification_status}"
|
|
6942
|
-
}),
|
|
6943
|
-
warnings,
|
|
6944
|
-
"Grant ads_read and business access for the selected ad account."
|
|
6945
|
-
);
|
|
6946
|
-
const business = isRecord(accountBusiness?.business) ? accountBusiness.business : void 0;
|
|
6947
|
-
if (business && typeof business.id === "string") {
|
|
6948
|
-
businessIds.add(business.id);
|
|
6949
|
-
businessesById.set(business.id, business);
|
|
6950
|
-
}
|
|
6951
|
-
const adAccountPixels = await fetchGraphWithFallback(
|
|
6952
|
-
client,
|
|
6953
|
-
"ad_account_pixels",
|
|
6954
|
-
graphUrl(client, `/${formatAdAccountId(adAccountId)}/adspixels`, {
|
|
6955
|
-
fields: pixelFields,
|
|
6956
|
-
limit,
|
|
6957
|
-
after: cursor
|
|
6958
|
-
}),
|
|
6959
|
-
graphUrl(client, `/${formatAdAccountId(adAccountId)}/adspixels`, {
|
|
6960
|
-
fields: pixelFallbackFields,
|
|
6961
|
-
limit,
|
|
6962
|
-
after: cursor
|
|
6963
|
-
}),
|
|
6964
|
-
warnings,
|
|
6965
|
-
"Grant ads_read and pixel access on the ad account to read pixel metadata."
|
|
6966
|
-
);
|
|
6967
|
-
addById(pixelsById, dataArray(adAccountPixels));
|
|
6968
|
-
const offlineDatasets = await fetchGraphWithFallback(
|
|
6969
|
-
client,
|
|
6970
|
-
"ad_account_offline_datasets",
|
|
6971
|
-
graphUrl(client, `/${formatAdAccountId(adAccountId)}/offline_conversion_data_sets`, {
|
|
6972
|
-
fields: datasetFields,
|
|
6973
|
-
limit,
|
|
6974
|
-
after: cursor
|
|
6975
|
-
}),
|
|
6976
|
-
graphUrl(client, `/${formatAdAccountId(adAccountId)}/offline_conversion_data_sets`, {
|
|
6977
|
-
fields: datasetFallbackFields,
|
|
6978
|
-
limit,
|
|
6979
|
-
after: cursor
|
|
6980
|
-
}),
|
|
6981
|
-
warnings,
|
|
6982
|
-
"Offline dataset access may require business-level permissions; provide businessId if available."
|
|
6983
|
-
);
|
|
6984
|
-
addById(datasetsById, dataArray(offlineDatasets));
|
|
6985
|
-
}
|
|
6986
|
-
for (const id of businessIds) {
|
|
6987
|
-
const [
|
|
6988
|
-
ownedPages,
|
|
6989
|
-
clientPages,
|
|
6990
|
-
ownedInstagram,
|
|
6991
|
-
clientInstagram,
|
|
6992
|
-
ownedPixels,
|
|
6993
|
-
clientPixels,
|
|
6994
|
-
ownedDatasets,
|
|
6995
|
-
clientDatasets
|
|
6996
|
-
] = await Promise.all([
|
|
6997
|
-
fetchGraphWithFallback(
|
|
6998
|
-
client,
|
|
6999
|
-
`business:${id}:owned_pages`,
|
|
7000
|
-
graphUrl(client, `/${id}/owned_pages`, { fields: pageFields, limit, after: cursor }),
|
|
7001
|
-
graphUrl(client, `/${id}/owned_pages`, { fields: pageFallbackFields, limit, after: cursor }),
|
|
7002
|
-
warnings,
|
|
7003
|
-
"Grant business_management plus pages_show_list/pages_read_engagement to read owned Pages."
|
|
7004
|
-
),
|
|
7005
|
-
fetchGraphWithFallback(
|
|
7006
|
-
client,
|
|
7007
|
-
`business:${id}:client_pages`,
|
|
7008
|
-
graphUrl(client, `/${id}/client_pages`, { fields: pageFields, limit, after: cursor }),
|
|
7009
|
-
graphUrl(client, `/${id}/client_pages`, { fields: pageFallbackFields, limit, after: cursor }),
|
|
7010
|
-
warnings,
|
|
7011
|
-
"Client Page access may require Business Manager partner permissions."
|
|
7012
|
-
),
|
|
7013
|
-
fetchGraph(
|
|
7014
|
-
client,
|
|
7015
|
-
`business:${id}:owned_instagram_accounts`,
|
|
7016
|
-
graphUrl(client, `/${id}/owned_instagram_accounts`, { fields: instagramFields, limit, after: cursor }),
|
|
7017
|
-
warnings,
|
|
7018
|
-
"Grant business_management and instagram_basic to read owned Instagram accounts."
|
|
7019
|
-
),
|
|
7020
|
-
fetchGraph(
|
|
7021
|
-
client,
|
|
7022
|
-
`business:${id}:client_instagram_accounts`,
|
|
7023
|
-
graphUrl(client, `/${id}/client_instagram_accounts`, { fields: instagramFields, limit, after: cursor }),
|
|
7024
|
-
warnings,
|
|
7025
|
-
"Client Instagram access may require Business Manager partner permissions and instagram_basic."
|
|
7026
|
-
),
|
|
7027
|
-
fetchGraphWithFallback(
|
|
7028
|
-
client,
|
|
7029
|
-
`business:${id}:owned_pixels`,
|
|
7030
|
-
graphUrl(client, `/${id}/owned_pixels`, { fields: pixelFields, limit, after: cursor }),
|
|
7031
|
-
graphUrl(client, `/${id}/owned_pixels`, { fields: pixelFallbackFields, limit, after: cursor }),
|
|
7032
|
-
warnings,
|
|
7033
|
-
"Grant business_management and asset access to read owned pixels."
|
|
7034
|
-
),
|
|
7035
|
-
fetchGraphWithFallback(
|
|
7036
|
-
client,
|
|
7037
|
-
`business:${id}:client_pixels`,
|
|
7038
|
-
graphUrl(client, `/${id}/client_pixels`, { fields: pixelFields, limit, after: cursor }),
|
|
7039
|
-
graphUrl(client, `/${id}/client_pixels`, { fields: pixelFallbackFields, limit, after: cursor }),
|
|
7040
|
-
warnings,
|
|
7041
|
-
"Client pixel access may require Business Manager partner permissions."
|
|
7042
|
-
),
|
|
7043
|
-
fetchGraphWithFallback(
|
|
7044
|
-
client,
|
|
7045
|
-
`business:${id}:owned_data_sets`,
|
|
7046
|
-
graphUrl(client, `/${id}/owned_data_sets`, { fields: datasetFields, limit, after: cursor }),
|
|
7047
|
-
graphUrl(client, `/${id}/owned_data_sets`, { fields: datasetFallbackFields, limit, after: cursor }),
|
|
7048
|
-
warnings,
|
|
7049
|
-
"Dataset edges vary by Meta account setup; try adAccountId fallback or verify business asset permissions."
|
|
7050
|
-
),
|
|
7051
|
-
fetchGraphWithFallback(
|
|
7052
|
-
client,
|
|
7053
|
-
`business:${id}:client_data_sets`,
|
|
7054
|
-
graphUrl(client, `/${id}/client_data_sets`, { fields: datasetFields, limit, after: cursor }),
|
|
7055
|
-
graphUrl(client, `/${id}/client_data_sets`, { fields: datasetFallbackFields, limit, after: cursor }),
|
|
7056
|
-
warnings,
|
|
7057
|
-
"Client dataset access may require partner permissions or may not be available for this business."
|
|
7058
|
-
)
|
|
7059
|
-
]);
|
|
7060
|
-
addById(pagesById, dataArray(ownedPages).map((page) => normalizePage(page, `business:${id}:owned_pages`)));
|
|
7061
|
-
addById(pagesById, dataArray(clientPages).map((page) => normalizePage(page, `business:${id}:client_pages`)));
|
|
7062
|
-
for (const page of [...dataArray(ownedPages), ...dataArray(clientPages)]) {
|
|
7063
|
-
const pageContext = {
|
|
7064
|
-
page_id: page.id,
|
|
7065
|
-
page_name: page.name,
|
|
7066
|
-
business_id: id
|
|
7067
|
-
};
|
|
7068
|
-
if (isRecord(page.instagram_business_account)) {
|
|
7069
|
-
addById(instagramById, [
|
|
7070
|
-
normalizeInstagramAccount(page.instagram_business_account, "page.instagram_business_account", pageContext)
|
|
7071
|
-
]);
|
|
7072
|
-
}
|
|
7073
|
-
if (isRecord(page.connected_instagram_account)) {
|
|
7074
|
-
addById(instagramById, [
|
|
7075
|
-
normalizeInstagramAccount(page.connected_instagram_account, "page.connected_instagram_account", pageContext)
|
|
7076
|
-
]);
|
|
7077
|
-
}
|
|
7078
|
-
}
|
|
7079
|
-
addById(instagramById, dataArray(ownedInstagram).map((account) => normalizeInstagramAccount(account, `business:${id}:owned_instagram_accounts`, { business_id: id })));
|
|
7080
|
-
addById(instagramById, dataArray(clientInstagram).map((account) => normalizeInstagramAccount(account, `business:${id}:client_instagram_accounts`, { business_id: id })));
|
|
7081
|
-
addById(pixelsById, dataArray(ownedPixels));
|
|
7082
|
-
addById(pixelsById, dataArray(clientPixels));
|
|
7083
|
-
addById(datasetsById, dataArray(ownedDatasets));
|
|
7084
|
-
addById(datasetsById, dataArray(clientDatasets));
|
|
7085
|
-
}
|
|
7086
|
-
if (businessIds.size === 0) {
|
|
7087
|
-
warnings.push({
|
|
7088
|
-
area: "business_assets",
|
|
7089
|
-
message: "No readable Business Manager was discovered from /me/businesses or the provided ad account.",
|
|
7090
|
-
suggestion: "Provide businessId directly, grant business_management, or use adAccountId for account-level pixels/datasets."
|
|
7091
|
-
});
|
|
7092
|
-
}
|
|
7093
|
-
return ok({
|
|
7094
|
-
businesses: [...businessesById.values()],
|
|
7095
|
-
pages: [...pagesById.values()],
|
|
7096
|
-
instagram_accounts: [...instagramById.values()],
|
|
7097
|
-
pixels: [...pixelsById.values()],
|
|
7098
|
-
datasets: [...datasetsById.values()],
|
|
7099
|
-
counts: {
|
|
7100
|
-
businesses: businessesById.size,
|
|
7101
|
-
pages: pagesById.size,
|
|
7102
|
-
instagram_accounts: instagramById.size,
|
|
7103
|
-
pixels: pixelsById.size,
|
|
7104
|
-
datasets: datasetsById.size
|
|
7105
|
-
},
|
|
7106
|
-
warnings
|
|
7107
|
-
});
|
|
7108
|
-
}
|
|
7109
|
-
);
|
|
7110
|
-
server.tool(
|
|
7111
|
-
"meta_get_pages",
|
|
7112
|
-
"List accessible Facebook Pages with id, name, category, tasks, picture, and linked Instagram account references when available.",
|
|
7113
|
-
{
|
|
7114
|
-
businessId: z3.string().optional().describe("Optional Business Manager ID to list owned/client pages. Omit to use /me/accounts."),
|
|
7115
|
-
limit: z3.number().int().min(1).max(200).optional().default(100),
|
|
7116
|
-
cursor: cursorSchema
|
|
7117
|
-
},
|
|
7118
|
-
async ({ businessId, limit, cursor }) => {
|
|
7119
|
-
const warnings = [];
|
|
7120
|
-
const pagesById = /* @__PURE__ */ new Map();
|
|
7121
|
-
const fields = "id,name,category,tasks,picture{url},instagram_business_account{id,username,name,ig_id,profile_picture_url},connected_instagram_account{id,username,name,ig_id,profile_picture_url}";
|
|
7122
|
-
const fallbackFields = "id,name,category,tasks,picture{url}";
|
|
7123
|
-
const paging = {};
|
|
7124
|
-
if (businessId) {
|
|
7125
|
-
const ownedPages = await fetchGraphWithFallback(
|
|
7126
|
-
client,
|
|
7127
|
-
"owned_pages",
|
|
7128
|
-
graphUrl(client, `/${businessId}/owned_pages`, { fields, limit, after: cursor }),
|
|
7129
|
-
graphUrl(client, `/${businessId}/owned_pages`, { fields: fallbackFields, limit, after: cursor }),
|
|
7130
|
-
warnings,
|
|
7131
|
-
"Grant business_management plus pages_show_list/pages_read_engagement to read Business-owned Pages."
|
|
7132
|
-
);
|
|
7133
|
-
const clientPages = await fetchGraphWithFallback(
|
|
7134
|
-
client,
|
|
7135
|
-
"client_pages",
|
|
7136
|
-
graphUrl(client, `/${businessId}/client_pages`, { fields, limit, after: cursor }),
|
|
7137
|
-
graphUrl(client, `/${businessId}/client_pages`, { fields: fallbackFields, limit, after: cursor }),
|
|
7138
|
-
warnings,
|
|
7139
|
-
"Client Page access may require Business Manager partner permissions."
|
|
8219
|
+
"Client Page access may require Business Manager partner permissions."
|
|
7140
8220
|
);
|
|
7141
8221
|
addById(pagesById, dataArray(ownedPages).map((page) => normalizePage(page, "owned_pages")));
|
|
7142
8222
|
addById(pagesById, dataArray(clientPages).map((page) => normalizePage(page, "client_pages")));
|
|
@@ -7167,9 +8247,9 @@ function registerMetaTools(server, config) {
|
|
|
7167
8247
|
"List Instagram accounts linked to accessible Pages, Business Manager assets, or an ad account when permissions allow.",
|
|
7168
8248
|
{
|
|
7169
8249
|
adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID for /instagram_accounts."),
|
|
7170
|
-
businessId:
|
|
7171
|
-
pageId:
|
|
7172
|
-
limit:
|
|
8250
|
+
businessId: z5.string().optional().describe("Optional Business Manager ID for owned/client Instagram account edges."),
|
|
8251
|
+
pageId: z5.string().optional().describe("Optional Page ID to read linked instagram_business_account/connected_instagram_account."),
|
|
8252
|
+
limit: z5.number().int().min(1).max(200).optional().default(100),
|
|
7173
8253
|
cursor: cursorSchema
|
|
7174
8254
|
},
|
|
7175
8255
|
async ({ adAccountId, businessId, pageId, limit, cursor }) => {
|
|
@@ -7273,9 +8353,9 @@ function registerMetaTools(server, config) {
|
|
|
7273
8353
|
"List pixels and datasets from an ad account or Business Manager when accessible, returning actionable warnings for permission-limited edges.",
|
|
7274
8354
|
{
|
|
7275
8355
|
adAccountId: adAccountIdSchema.optional().describe("Ad account ID for /adspixels and offline conversion datasets."),
|
|
7276
|
-
businessId:
|
|
7277
|
-
includeDatasets:
|
|
7278
|
-
limit:
|
|
8356
|
+
businessId: z5.string().optional().describe("Business Manager ID for owned/client pixels and datasets."),
|
|
8357
|
+
includeDatasets: z5.boolean().optional().default(true).describe("Also attempt dataset/offline conversion dataset edges."),
|
|
8358
|
+
limit: z5.number().int().min(1).max(200).optional().default(100),
|
|
7279
8359
|
cursor: cursorSchema
|
|
7280
8360
|
},
|
|
7281
8361
|
async ({ adAccountId, businessId, includeDatasets, limit, cursor }) => {
|
|
@@ -7399,9 +8479,9 @@ function registerMetaTools(server, config) {
|
|
|
7399
8479
|
"Read ad account activity logs from /{ad_account_id}/activities with object, event, actor, timestamp, and extra_data fields.",
|
|
7400
8480
|
{
|
|
7401
8481
|
adAccountId: adAccountIdSchema,
|
|
7402
|
-
since:
|
|
7403
|
-
until:
|
|
7404
|
-
limit:
|
|
8482
|
+
since: z5.string().optional().describe("Optional start time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
|
|
8483
|
+
until: z5.string().optional().describe("Optional end time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
|
|
8484
|
+
limit: z5.number().int().min(1).max(500).optional().default(100),
|
|
7405
8485
|
cursor: cursorSchema
|
|
7406
8486
|
},
|
|
7407
8487
|
async ({ adAccountId, since, until, limit, cursor }) => {
|
|
@@ -7440,11 +8520,11 @@ function registerMetaTools(server, config) {
|
|
|
7440
8520
|
"Aggregate read-only delivery diagnostics across campaigns, ad sets, and ads using status/effective_status/issues_info where available plus simple delivery insights.",
|
|
7441
8521
|
{
|
|
7442
8522
|
adAccountId: adAccountIdSchema,
|
|
7443
|
-
level:
|
|
7444
|
-
effectiveStatusFilter:
|
|
8523
|
+
level: z5.enum(["campaign", "adset", "ad", "all"]).optional().default("all"),
|
|
8524
|
+
effectiveStatusFilter: z5.array(z5.string()).optional().describe("Optional effective_status filter values such as ACTIVE, PAUSED, WITH_ISSUES, DISAPPROVED."),
|
|
7445
8525
|
datePreset: datePresetSchema2.describe("Insights date preset for simple delivery metrics. Defaults to last_7d."),
|
|
7446
8526
|
timeRange: timeRangeSchema.describe("Optional custom insights date range."),
|
|
7447
|
-
limit:
|
|
8527
|
+
limit: z5.number().int().min(1).max(200).optional().default(100),
|
|
7448
8528
|
cursor: cursorSchema
|
|
7449
8529
|
},
|
|
7450
8530
|
async ({ adAccountId, level, effectiveStatusFilter, datePreset, timeRange, limit, cursor }) => {
|
|
@@ -7538,43 +8618,38 @@ function registerMetaTools(server, config) {
|
|
|
7538
8618
|
);
|
|
7539
8619
|
server.tool(
|
|
7540
8620
|
"meta_get_creative_assets",
|
|
7541
|
-
"Return
|
|
8621
|
+
"Return ad-linked creative media with actual ad names, collection covers, all carousel/flexible components, resolved image hashes and video URLs. Catalog-only ads are classified separately; use the hosted GetMCPAds service for an interactive visual gallery. Raw creative metadata remains available for diagnostics.",
|
|
7542
8622
|
{
|
|
7543
8623
|
adAccountId: adAccountIdSchema.optional().describe("Ad account ID. Used when adIds/creativeIds are omitted."),
|
|
7544
|
-
adIds:
|
|
7545
|
-
creativeIds:
|
|
8624
|
+
adIds: z5.array(z5.string()).optional().describe("Specific ad IDs to enrich."),
|
|
8625
|
+
creativeIds: z5.array(z5.string()).optional().describe("Specific creative IDs to enrich."),
|
|
7546
8626
|
limit: limitSchema,
|
|
7547
8627
|
cursor: cursorSchema
|
|
7548
8628
|
},
|
|
7549
8629
|
async ({ adAccountId, adIds, creativeIds, limit, cursor }) => {
|
|
7550
8630
|
const warnings = [];
|
|
7551
|
-
const creativeFields = "id,name,body,title,thumbnail_url,image_url,video_id,link_url,call_to_action_type,effective_object_story_id,object_type,object_story_spec,asset_feed_spec";
|
|
7552
|
-
const adFields = `id,name,status,effective_status,campaign_id,adset_id,creative{${creativeFields}}`;
|
|
8631
|
+
const creativeFields = "id,name,body,title,thumbnail_url,image_url,image_hash,video_id,product_set_id,link_url,call_to_action_type,effective_object_story_id,object_type,object_story_spec,asset_feed_spec";
|
|
8632
|
+
const adFields = `id,name,account_id,status,effective_status,campaign_id,adset_id,creative{${creativeFields}}`;
|
|
7553
8633
|
let sourceType = "ad";
|
|
7554
8634
|
let result = null;
|
|
7555
8635
|
const idChunkSize = 50;
|
|
7556
8636
|
if (creativeIds && creativeIds.length > 0) {
|
|
7557
8637
|
sourceType = "creative";
|
|
7558
8638
|
const merged = {};
|
|
7559
|
-
for (
|
|
7560
|
-
const
|
|
7561
|
-
|
|
7562
|
-
client,
|
|
7563
|
-
`creative_ids_${Math.floor(index / idChunkSize) + 1}`,
|
|
7564
|
-
graphUrl(client, "", { ids: chunk.join(","), fields: creativeFields }),
|
|
7565
|
-
warnings,
|
|
7566
|
-
"Verify the creative IDs are readable by this token and include only read-only creative fields."
|
|
7567
|
-
);
|
|
7568
|
-
Object.assign(merged, chunkResult ?? {});
|
|
8639
|
+
for (const id2 of creativeIds.slice(0, 20)) {
|
|
8640
|
+
const creative = await fetchGraph(client, "creative_node", graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: creativeFields }), warnings, "Verify creative access, or use adIds with adAccountId for ad names.");
|
|
8641
|
+
if (creative) merged[id2] = creative;
|
|
7569
8642
|
}
|
|
7570
8643
|
result = merged;
|
|
7571
|
-
if (creativeIds.length >
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
8644
|
+
if (creativeIds.length > 20) warnings.push({ area: "creative_assets", message: "Only 20 creative IDs resolved.", suggestion: "Use batches of 20 creative IDs, or adIds with adAccountId." });
|
|
8645
|
+
} else if (adIds && adIds.length > 0 && !adAccountId) {
|
|
8646
|
+
const merged = {};
|
|
8647
|
+
for (const id2 of adIds.slice(0, 20)) {
|
|
8648
|
+
const ad = await fetchGraph(client, "ad_creative", graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: adFields }), warnings, "Provide the owning adAccountId for efficient targeted ad lookup.");
|
|
8649
|
+
if (ad) merged[id2] = ad;
|
|
7577
8650
|
}
|
|
8651
|
+
if (adIds.length > 20) warnings.push({ area: "creative_assets", message: "Only 20 ad IDs resolved without an account.", suggestion: "Provide adAccountId to resolve the full selection." });
|
|
8652
|
+
result = merged;
|
|
7578
8653
|
} else if (adIds && adIds.length > 0) {
|
|
7579
8654
|
sourceType = "ad";
|
|
7580
8655
|
const merged = {};
|
|
@@ -7583,12 +8658,19 @@ function registerMetaTools(server, config) {
|
|
|
7583
8658
|
const chunkResult = await fetchGraph(
|
|
7584
8659
|
client,
|
|
7585
8660
|
`ad_ids_creatives_${Math.floor(index / idChunkSize) + 1}`,
|
|
7586
|
-
graphUrl(client,
|
|
8661
|
+
graphUrl(client, `/${formatAdAccountId(adAccountId)}/ads`, { filtering: JSON.stringify([{ field: "id", operator: "IN", value: chunk }]), fields: adFields, limit: 500 }),
|
|
7587
8662
|
warnings,
|
|
7588
8663
|
"Verify the ad IDs are readable by this token and belong to accessible ad accounts."
|
|
7589
8664
|
);
|
|
7590
|
-
|
|
8665
|
+
for (const ad of dataArray(chunkResult)) if (ad.id) merged[String(ad.id)] = ad;
|
|
8666
|
+
}
|
|
8667
|
+
const missingIds = adIds.filter((id2) => !merged[id2]);
|
|
8668
|
+
for (const id2 of missingIds.slice(0, 10)) {
|
|
8669
|
+
const ad = await fetchGraph(client, "historical_ad_creative", graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: adFields }), warnings, "Historical ad metadata may no longer be accessible.");
|
|
8670
|
+
if (ad && typeof ad.account_id === "string" && formatAdAccountId(ad.account_id) === formatAdAccountId(adAccountId)) merged[id2] = ad;
|
|
8671
|
+
else if (ad) warnings.push({ area: "creative_assets", message: `Ad ${id2} was excluded because its owning account could not be verified against the requested account.`, suggestion: "Use an ad from the selected account." });
|
|
7591
8672
|
}
|
|
8673
|
+
if (missingIds.length > 10) warnings.push({ area: "creative_assets", message: "Historical ad fallback limited to 10 missing ads.", suggestion: "Narrow the ad selection to resolve additional historical ads." });
|
|
7592
8674
|
result = merged;
|
|
7593
8675
|
if (adIds.length > idChunkSize) {
|
|
7594
8676
|
warnings.push({
|
|
@@ -7618,7 +8700,49 @@ function registerMetaTools(server, config) {
|
|
|
7618
8700
|
});
|
|
7619
8701
|
}
|
|
7620
8702
|
const records = result?.data ? dataArray(result) : Object.values(result ?? {}).filter(isRecord);
|
|
7621
|
-
const assets = records.map((
|
|
8703
|
+
const assets = records.map((record2) => normalizeCreativeAsset(record2, sourceType));
|
|
8704
|
+
const allMedia = assets.flatMap((asset) => Array.isArray(asset.media) ? asset.media : []);
|
|
8705
|
+
const hashes = [...new Set(allMedia.filter((m) => m.image_hash).map((m) => m.image_hash))];
|
|
8706
|
+
if (hashes.length && adAccountId) {
|
|
8707
|
+
for (let offset = 0; offset < hashes.length; offset += 50) {
|
|
8708
|
+
const images = await fetchGraph(client, "creative_image_hashes", graphUrl(client, `/${formatAdAccountId(adAccountId)}/adimages`, {
|
|
8709
|
+
hashes: JSON.stringify(hashes.slice(offset, offset + 50)),
|
|
8710
|
+
fields: "hash,permalink_url,url",
|
|
8711
|
+
limit: 50
|
|
8712
|
+
}), warnings, "Verify ads_read access to the ad account image library.");
|
|
8713
|
+
for (const image of dataArray(images)) for (const item of allMedia) if (item.image_hash === image.hash) item.image_url = metaImageFileUrl(image.url, image.permalink_url, item.image_url);
|
|
8714
|
+
}
|
|
8715
|
+
}
|
|
8716
|
+
const videoIds = [...new Set(allMedia.map((m) => m.video_id).filter((id2) => Boolean(id2)))];
|
|
8717
|
+
const pageClientFor = pageClientResolver(client, warnings);
|
|
8718
|
+
for (const id2 of videoIds.slice(0, 10)) {
|
|
8719
|
+
const video = await fetchGraph(
|
|
8720
|
+
client,
|
|
8721
|
+
"creative_video",
|
|
8722
|
+
graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: "id,picture,source,from" }),
|
|
8723
|
+
warnings,
|
|
8724
|
+
"Verify access to the ad video and its owning Page."
|
|
8725
|
+
) ?? { id: id2 };
|
|
8726
|
+
const owner = assets.find((asset) => asset.media.some((m) => m.video_id === id2));
|
|
8727
|
+
const pageId = firstString(
|
|
8728
|
+
isRecord(video.from) ? video.from.id : void 0,
|
|
8729
|
+
owner?.page_id,
|
|
8730
|
+
typeof owner?.effective_object_story_id === "string" ? owner.effective_object_story_id.split("_")[0] : void 0
|
|
8731
|
+
);
|
|
8732
|
+
if (!video.source && pageId) {
|
|
8733
|
+
const pageClient = await pageClientFor(pageId);
|
|
8734
|
+
if (pageClient) {
|
|
8735
|
+
const resolved = await fetchGraph(pageClient, "creative_page_video", graphUrl(pageClient, `/${encodeURIComponent(id2)}`, { fields: "id,picture,source" }), warnings, "Verify Page content access.");
|
|
8736
|
+
if (resolved) Object.assign(video, resolved);
|
|
8737
|
+
}
|
|
8738
|
+
}
|
|
8739
|
+
for (const item of allMedia) if (item.video_id === id2) {
|
|
8740
|
+
item.thumbnail_url = firstString(video?.picture, item.thumbnail_url);
|
|
8741
|
+
item.source = firstString(video?.source, item.source);
|
|
8742
|
+
}
|
|
8743
|
+
if (!video.source) warnings.push({ area: "creative_video", message: `No playable source returned for video ${id2}. A cover is not evidence of the full video.`, suggestion: "Check the connected profile's access to the owning Page; re-resolve this video before playback or analysis." });
|
|
8744
|
+
}
|
|
8745
|
+
if (videoIds.length > 10) warnings.push({ area: "creative_video", message: "Video URL resolution limited to 10 videos.", suggestion: "Narrow the ad selection or use meta_get_video_sources to resolve additional videos." });
|
|
7622
8746
|
const paging = pagingInfo(result);
|
|
7623
8747
|
if (paging) {
|
|
7624
8748
|
warnings.push({
|
|
@@ -7640,8 +8764,8 @@ function registerMetaTools(server, config) {
|
|
|
7640
8764
|
"Read detailed custom, saved, and lookalike audiences with pagination and rich fields where permissions allow.",
|
|
7641
8765
|
{
|
|
7642
8766
|
adAccountId: adAccountIdSchema.optional().describe("Ad account ID used when listing audiences."),
|
|
7643
|
-
audienceIds:
|
|
7644
|
-
type:
|
|
8767
|
+
audienceIds: z5.array(z5.string()).optional().describe("Specific audience IDs to fetch via batch IDs lookup."),
|
|
8768
|
+
type: z5.enum(["all", "custom", "saved", "lookalike"]).optional().default("all"),
|
|
7645
8769
|
limit: limitSchema,
|
|
7646
8770
|
cursor: cursorSchema
|
|
7647
8771
|
},
|
|
@@ -7776,13 +8900,13 @@ function registerMetaTools(server, config) {
|
|
|
7776
8900
|
"meta_get_catalog_products",
|
|
7777
8901
|
"Read Product Catalogs and Product Items when catalog access is available. Returns join-ready product metadata without creating or updating catalog assets.",
|
|
7778
8902
|
{
|
|
7779
|
-
businessId:
|
|
7780
|
-
catalogId:
|
|
7781
|
-
productIds:
|
|
7782
|
-
search:
|
|
7783
|
-
includeProducts:
|
|
7784
|
-
includeProductSets:
|
|
7785
|
-
limit:
|
|
8903
|
+
businessId: z5.string().optional().describe("Business Manager ID used to discover owned/client product catalogs."),
|
|
8904
|
+
catalogId: z5.string().optional().describe("Product Catalog ID to read directly."),
|
|
8905
|
+
productIds: z5.array(z5.string()).max(100).optional().describe("Specific Product Item IDs to fetch via the batch IDs endpoint."),
|
|
8906
|
+
search: z5.string().optional().describe("Client-side substring filter across product id, retailer_id, name, brand, category, type, and custom labels."),
|
|
8907
|
+
includeProducts: z5.boolean().optional().default(true).describe("Fetch product items for discovered/provided catalogs."),
|
|
8908
|
+
includeProductSets: z5.boolean().optional().default(false).describe("Also attempt product_sets edges for catalog context."),
|
|
8909
|
+
limit: z5.number().int().min(1).max(500).optional().default(100),
|
|
7786
8910
|
cursor: cursorSchema
|
|
7787
8911
|
},
|
|
7788
8912
|
async ({ businessId, catalogId, productIds, search, includeProducts, includeProductSets, limit, cursor }) => {
|
|
@@ -7890,31 +9014,31 @@ function registerMetaTools(server, config) {
|
|
|
7890
9014
|
suggestion: "Use catalogId with the returned catalog-specific cursor for precise product pagination."
|
|
7891
9015
|
});
|
|
7892
9016
|
}
|
|
7893
|
-
for (const
|
|
9017
|
+
for (const id2 of catalogIds) {
|
|
7894
9018
|
const products = await fetchGraphWithFallback(
|
|
7895
9019
|
client,
|
|
7896
|
-
`catalog:${
|
|
7897
|
-
graphUrl(client, `/${
|
|
7898
|
-
graphUrl(client, `/${
|
|
9020
|
+
`catalog:${id2}:products`,
|
|
9021
|
+
graphUrl(client, `/${id2}/products`, { fields: productFields, limit, after: cursor }),
|
|
9022
|
+
graphUrl(client, `/${id2}/products`, { fields: productFallbackFields, limit, after: cursor }),
|
|
7899
9023
|
warnings,
|
|
7900
9024
|
"Product Item fields may require catalog permissions. Minimal join fields are returned when rich product fields fail."
|
|
7901
9025
|
);
|
|
7902
|
-
const normalizedProducts = dataArray(products).map((product) => normalizeProductItem(product, `catalog:${
|
|
9026
|
+
const normalizedProducts = dataArray(products).map((product) => normalizeProductItem(product, `catalog:${id2}:products`)).filter((product) => productSearchMatches(product, search));
|
|
7903
9027
|
addById(productsById, normalizedProducts);
|
|
7904
|
-
paging[`catalog:${
|
|
9028
|
+
paging[`catalog:${id2}:products`] = pagingInfo(products);
|
|
7905
9029
|
}
|
|
7906
9030
|
}
|
|
7907
9031
|
if (includeProductSets) {
|
|
7908
|
-
for (const
|
|
9032
|
+
for (const id2 of catalogIds) {
|
|
7909
9033
|
const productSets = await fetchGraph(
|
|
7910
9034
|
client,
|
|
7911
|
-
`catalog:${
|
|
7912
|
-
graphUrl(client, `/${
|
|
9035
|
+
`catalog:${id2}:product_sets`,
|
|
9036
|
+
graphUrl(client, `/${id2}/product_sets`, { fields: productSetFields, limit, after: cursor }),
|
|
7913
9037
|
warnings,
|
|
7914
9038
|
"Product set reads may require catalog access, and some catalog verticals do not expose product_sets."
|
|
7915
9039
|
);
|
|
7916
9040
|
addById(productSetsById, dataArray(productSets));
|
|
7917
|
-
paging[`catalog:${
|
|
9041
|
+
paging[`catalog:${id2}:product_sets`] = pagingInfo(productSets);
|
|
7918
9042
|
}
|
|
7919
9043
|
}
|
|
7920
9044
|
return ok({
|
|
@@ -7945,14 +9069,14 @@ function registerMetaTools(server, config) {
|
|
|
7945
9069
|
"Query product-breakdown insights and enrich rows with Product Catalog metadata when catalog access is available.",
|
|
7946
9070
|
{
|
|
7947
9071
|
adAccountId: adAccountIdSchema,
|
|
7948
|
-
catalogId:
|
|
9072
|
+
catalogId: z5.string().optional().describe("Product Catalog ID used to load product metadata for joins."),
|
|
7949
9073
|
productBreakdown: productBreakdownSchema.optional().default("product_id"),
|
|
7950
|
-
metrics:
|
|
9074
|
+
metrics: z5.array(z5.string()).min(1).optional().default(["impressions", "clicks", "spend", "actions", "action_values"]),
|
|
7951
9075
|
level: levelSchema.optional().default("ad"),
|
|
7952
9076
|
datePreset: datePresetSchema2.describe("Insights date preset. Defaults to last_30d."),
|
|
7953
9077
|
timeRange: timeRangeSchema.describe("Optional custom insights date range."),
|
|
7954
|
-
limit:
|
|
7955
|
-
catalogProductLimit:
|
|
9078
|
+
limit: z5.number().int().min(1).max(1e3).optional().default(500),
|
|
9079
|
+
catalogProductLimit: z5.number().int().min(1).max(1e3).optional().default(500)
|
|
7956
9080
|
},
|
|
7957
9081
|
async ({ adAccountId, catalogId, productBreakdown, metrics, level, datePreset, timeRange, limit, catalogProductLimit }) => {
|
|
7958
9082
|
const warnings = [];
|
|
@@ -8065,12 +9189,12 @@ function registerMetaTools(server, config) {
|
|
|
8065
9189
|
"Read brand safety, suitability, placement, and context-control signals from ad account/ad set targeting and optional block-list edges.",
|
|
8066
9190
|
{
|
|
8067
9191
|
adAccountId: adAccountIdSchema,
|
|
8068
|
-
businessId:
|
|
8069
|
-
adsetIds:
|
|
8070
|
-
includeAdsets:
|
|
8071
|
-
includeBlockLists:
|
|
8072
|
-
includeRawTargeting:
|
|
8073
|
-
limit:
|
|
9192
|
+
businessId: z5.string().optional().describe("Optional Business Manager ID for block-list discovery."),
|
|
9193
|
+
adsetIds: z5.array(z5.string()).max(100).optional().describe("Specific ad set IDs to inspect. If omitted, reads ad sets from the ad account."),
|
|
9194
|
+
includeAdsets: z5.boolean().optional().default(true),
|
|
9195
|
+
includeBlockLists: z5.boolean().optional().default(true),
|
|
9196
|
+
includeRawTargeting: z5.boolean().optional().default(false),
|
|
9197
|
+
limit: z5.number().int().min(1).max(500).optional().default(100),
|
|
8074
9198
|
cursor: cursorSchema
|
|
8075
9199
|
},
|
|
8076
9200
|
async ({ adAccountId, businessId, adsetIds, includeAdsets, includeBlockLists, includeRawTargeting, limit, cursor }) => {
|
|
@@ -8190,11 +9314,11 @@ function registerMetaTools(server, config) {
|
|
|
8190
9314
|
"meta_interpret_experiment_results",
|
|
8191
9315
|
"Read and interpret A/B test or conversion lift study results with confidence guardrails, cells, objectives, and optional cell entities.",
|
|
8192
9316
|
{
|
|
8193
|
-
studyId:
|
|
9317
|
+
studyId: z5.string().optional().describe("Specific Ad Study ID to interpret."),
|
|
8194
9318
|
adAccountId: adAccountIdSchema.optional().describe("Ad account ID used to discover ad_studies when studyId is omitted."),
|
|
8195
|
-
includeCellEntities:
|
|
8196
|
-
cellEntityType:
|
|
8197
|
-
limit:
|
|
9319
|
+
includeCellEntities: z5.boolean().optional().default(false).describe("Also read campaigns/adsets/adaccounts attached to each study cell."),
|
|
9320
|
+
cellEntityType: z5.enum(["campaigns", "adsets", "adaccounts"]).optional().default("campaigns"),
|
|
9321
|
+
limit: z5.number().int().min(1).max(100).optional().default(50)
|
|
8198
9322
|
},
|
|
8199
9323
|
async ({ studyId, adAccountId, includeCellEntities, cellEntityType, limit }) => {
|
|
8200
9324
|
const warnings = [];
|
|
@@ -8279,21 +9403,21 @@ function registerMetaTools(server, config) {
|
|
|
8279
9403
|
}
|
|
8280
9404
|
const interpretedStudies = [];
|
|
8281
9405
|
for (const study of studiesById.values()) {
|
|
8282
|
-
const
|
|
8283
|
-
if (!
|
|
9406
|
+
const id2 = typeof study.id === "string" ? study.id : void 0;
|
|
9407
|
+
if (!id2) continue;
|
|
8284
9408
|
const [cellsResult, objectivesResult] = await Promise.all([
|
|
8285
9409
|
fetchGraph(
|
|
8286
9410
|
client,
|
|
8287
|
-
`ad_study:${
|
|
8288
|
-
graphUrl(client, `/${
|
|
9411
|
+
`ad_study:${id2}:cells`,
|
|
9412
|
+
graphUrl(client, `/${id2}/cells`, { fields: cellFields, limit }),
|
|
8289
9413
|
warnings,
|
|
8290
9414
|
"Study cells may require experiment access. Without cells, interpretation is limited to objective payloads."
|
|
8291
9415
|
),
|
|
8292
9416
|
fetchGraphWithFallback(
|
|
8293
9417
|
client,
|
|
8294
|
-
`ad_study:${
|
|
8295
|
-
graphUrl(client, `/${
|
|
8296
|
-
graphUrl(client, `/${
|
|
9418
|
+
`ad_study:${id2}:objectives`,
|
|
9419
|
+
graphUrl(client, `/${id2}/objectives`, { fields: objectiveFields, limit }),
|
|
9420
|
+
graphUrl(client, `/${id2}/objectives`, { fields: objectiveFallbackFields, limit }),
|
|
8297
9421
|
warnings,
|
|
8298
9422
|
"Study result payloads can be permission/whitelist dependent; basic objective fields are returned when full results are unavailable."
|
|
8299
9423
|
)
|
|
@@ -8341,26 +9465,27 @@ function registerMetaTools(server, config) {
|
|
|
8341
9465
|
);
|
|
8342
9466
|
server.tool(
|
|
8343
9467
|
"meta_get_organic_content_enrichment",
|
|
8344
|
-
"
|
|
9468
|
+
"Read Facebook Page posts and Instagram media with URLs, native periods and optional insights. These are content insights, not Ads Insights; do not infer an organic-only breakdown.",
|
|
8345
9469
|
{
|
|
8346
|
-
pageId:
|
|
8347
|
-
instagramAccountId:
|
|
8348
|
-
postIds:
|
|
8349
|
-
mediaIds:
|
|
8350
|
-
includePagePosts:
|
|
8351
|
-
includeInstagramMedia:
|
|
8352
|
-
includeInsights:
|
|
8353
|
-
limit:
|
|
9470
|
+
pageId: z5.string().optional().describe("Facebook Page ID for /posts and linked IG discovery."),
|
|
9471
|
+
instagramAccountId: z5.string().optional().describe("Instagram professional account ID for /media."),
|
|
9472
|
+
postIds: z5.array(z5.string()).max(50).optional().describe("Specific Facebook post IDs to enrich."),
|
|
9473
|
+
mediaIds: z5.array(z5.string()).max(50).optional().describe("Specific Instagram media IDs to enrich."),
|
|
9474
|
+
includePagePosts: z5.boolean().optional().default(true),
|
|
9475
|
+
includeInstagramMedia: z5.boolean().optional().default(true),
|
|
9476
|
+
includeInsights: z5.boolean().optional().default(false),
|
|
9477
|
+
limit: z5.number().int().min(1).max(100).optional().default(25),
|
|
8354
9478
|
cursor: cursorSchema,
|
|
8355
|
-
since:
|
|
8356
|
-
until:
|
|
9479
|
+
since: z5.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text."),
|
|
9480
|
+
until: z5.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text.")
|
|
8357
9481
|
},
|
|
8358
9482
|
async ({ pageId, instagramAccountId, postIds, mediaIds, includePagePosts, includeInstagramMedia, includeInsights, limit, cursor, since, until }) => {
|
|
8359
9483
|
const warnings = [];
|
|
8360
9484
|
const paging = {};
|
|
8361
|
-
const pagePostFields =
|
|
8362
|
-
const pagePostFallbackFields = "id,message,created_time,
|
|
8363
|
-
const igMediaFields = "id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,username,like_count,comments_count,children{id,media_type,media_url,thumbnail_url,permalink}";
|
|
9485
|
+
const pagePostFields = "id,message,created_time,permalink_url,full_picture,attachments{media,type,url,target,title,description},shares";
|
|
9486
|
+
const pagePostFallbackFields = "id,message,created_time,permalink_url,full_picture,shares";
|
|
9487
|
+
const igMediaFields = "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,username,like_count,comments_count,children{id,media_type,media_url,thumbnail_url,permalink}";
|
|
9488
|
+
const pageClientFor = pageClientResolver(client, warnings);
|
|
8364
9489
|
const pagePosts = [];
|
|
8365
9490
|
const instagramMedia = [];
|
|
8366
9491
|
let resolvedInstagramAccountId = instagramAccountId;
|
|
@@ -8372,38 +9497,54 @@ function registerMetaTools(server, config) {
|
|
|
8372
9497
|
});
|
|
8373
9498
|
}
|
|
8374
9499
|
if (includePagePosts && postIds && postIds.length > 0) {
|
|
8375
|
-
const
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
9500
|
+
for (const postId of [...new Set(postIds)]) {
|
|
9501
|
+
const ownerId = /^(\d+)_\d+$/.exec(postId)?.[1] ?? pageId;
|
|
9502
|
+
if (!ownerId) {
|
|
9503
|
+
warnings.push({ area: "page_post_ids", message: `Cannot resolve the owning Page for post ${postId}.`, suggestion: "Provide pageId or the full PageID_PostID returned by Meta." });
|
|
9504
|
+
continue;
|
|
9505
|
+
}
|
|
9506
|
+
const pageClient = await pageClientFor(ownerId);
|
|
9507
|
+
if (!pageClient) continue;
|
|
9508
|
+
const post = await fetchGraphWithFallback(
|
|
9509
|
+
pageClient,
|
|
9510
|
+
"page_post_ids",
|
|
9511
|
+
graphUrl(pageClient, `/${encodeURIComponent(postId)}`, { fields: pagePostFields }),
|
|
9512
|
+
graphUrl(pageClient, `/${encodeURIComponent(postId)}`, { fields: pagePostFallbackFields }),
|
|
9513
|
+
warnings,
|
|
9514
|
+
"Post attachments require Page content access."
|
|
9515
|
+
);
|
|
9516
|
+
if (post) pagePosts.push(normalizeOrganicPost(post));
|
|
9517
|
+
}
|
|
8384
9518
|
} else if (includePagePosts && pageId) {
|
|
8385
|
-
const
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
limit,
|
|
8391
|
-
after: cursor,
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
9519
|
+
const pageClient = await pageClientFor(pageId);
|
|
9520
|
+
if (pageClient) {
|
|
9521
|
+
const result = await fetchGraphWithFallback(
|
|
9522
|
+
pageClient,
|
|
9523
|
+
"page_posts_enrichment",
|
|
9524
|
+
graphUrl(pageClient, `/${encodeURIComponent(pageId)}/posts`, { fields: pagePostFields, limit, after: cursor, since, until }),
|
|
9525
|
+
graphUrl(pageClient, `/${encodeURIComponent(pageId)}/posts`, { fields: pagePostFallbackFields, limit, after: cursor, since, until }),
|
|
9526
|
+
warnings,
|
|
9527
|
+
"Post attachments require pages_read_engagement and Page access."
|
|
9528
|
+
);
|
|
9529
|
+
pagePosts.push(...dataArray(result).map(normalizeOrganicPost));
|
|
9530
|
+
paging.page_posts = pagingInfo(result);
|
|
9531
|
+
}
|
|
9532
|
+
}
|
|
9533
|
+
if (includeInsights) {
|
|
9534
|
+
for (const post of pagePosts) {
|
|
9535
|
+
const id2 = typeof post.id === "string" ? post.id : void 0;
|
|
9536
|
+
const ownerId = id2 ? /^(\d+)_\d+$/.exec(id2)?.[1] ?? pageId : void 0;
|
|
9537
|
+
if (!id2 || !ownerId) continue;
|
|
9538
|
+
const pageClient = await pageClientFor(ownerId);
|
|
9539
|
+
if (!pageClient) continue;
|
|
9540
|
+
post.insights = await fetchGraph(
|
|
9541
|
+
pageClient,
|
|
9542
|
+
`page_post:${id2}:insights`,
|
|
9543
|
+
graphUrl(pageClient, `/${encodeURIComponent(id2)}/insights`, { metric: "post_media_view,post_clicks" }),
|
|
9544
|
+
warnings,
|
|
9545
|
+
"Page post insights require read_insights and a Page token. Unavailable metrics are not zero; post metadata is retained."
|
|
9546
|
+
);
|
|
9547
|
+
}
|
|
8407
9548
|
}
|
|
8408
9549
|
if (includeInstagramMedia && !resolvedInstagramAccountId && pageId) {
|
|
8409
9550
|
const page = await fetchGraph(
|
|
@@ -8449,18 +9590,38 @@ function registerMetaTools(server, config) {
|
|
|
8449
9590
|
for (const media of instagramMedia) {
|
|
8450
9591
|
const mediaId = typeof media.id === "string" ? media.id : void 0;
|
|
8451
9592
|
if (!mediaId) continue;
|
|
8452
|
-
const
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
graphUrl(client, `/${mediaId}/insights`, {
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
9593
|
+
const metrics = instagramInsightMetrics(media);
|
|
9594
|
+
media.insights_requested_metrics = metrics;
|
|
9595
|
+
try {
|
|
9596
|
+
media.insights = await client.fetchUrl(graphUrl(client, `/${encodeURIComponent(mediaId)}/insights`, { metric: metrics.join(",") }));
|
|
9597
|
+
} catch (error) {
|
|
9598
|
+
warnings.push(warningFromError(
|
|
9599
|
+
`instagram_media:${mediaId}:insights`,
|
|
9600
|
+
error,
|
|
9601
|
+
"Insight availability depends on media format, age and permissions. Unavailable metrics are not zero; media metadata is retained."
|
|
9602
|
+
));
|
|
9603
|
+
const incompatible = error instanceof MetaApiException && error.code === 100 && /metric.*(incompat|valid|supported|must be)|invalid.*metric/i.test(error.message);
|
|
9604
|
+
media.insights = incompatible && metrics.length > 1 ? await fetchGraph(
|
|
9605
|
+
client,
|
|
9606
|
+
`instagram_media:${mediaId}:reach`,
|
|
9607
|
+
graphUrl(client, `/${encodeURIComponent(mediaId)}/insights`, { metric: "reach" }),
|
|
9608
|
+
warnings,
|
|
9609
|
+
"Only reach was retried after Meta rejected the format-specific metric selection; other metrics remain unavailable."
|
|
9610
|
+
) : null;
|
|
9611
|
+
}
|
|
9612
|
+
media.insights_returned_metrics = dataArray(isRecord(media.insights) ? media.insights : null).map((row) => row.name);
|
|
8462
9613
|
}
|
|
8463
9614
|
}
|
|
9615
|
+
const insightState = (item, requested) => {
|
|
9616
|
+
const result = isRecord(item.insights) ? item.insights : null;
|
|
9617
|
+
const returned = dataArray(result);
|
|
9618
|
+
return { requested: includeInsights, source: "content_insights", delivery_breakdown: "not_established", metrics: Object.fromEntries(requested.map((name2) => {
|
|
9619
|
+
const metric = returned.find((m) => m.name === name2);
|
|
9620
|
+
return [name2, { status: !includeInsights ? "not_requested" : !result ? "read_failed" : !metric ? "not_returned" : "returned", period: metric?.period ?? null }];
|
|
9621
|
+
})) };
|
|
9622
|
+
};
|
|
9623
|
+
for (const post of pagePosts) post.insight_coverage = insightState(post, ["post_media_view", "post_clicks"]);
|
|
9624
|
+
for (const media of instagramMedia) media.insight_coverage = insightState(media, Array.isArray(media.insights_requested_metrics) ? media.insights_requested_metrics : instagramInsightMetrics(media));
|
|
8464
9625
|
return ok({
|
|
8465
9626
|
page_id: pageId,
|
|
8466
9627
|
instagram_account_id: resolvedInstagramAccountId,
|
|
@@ -8471,8 +9632,14 @@ function registerMetaTools(server, config) {
|
|
|
8471
9632
|
instagram_media: instagramMedia.length
|
|
8472
9633
|
},
|
|
8473
9634
|
notes: [
|
|
9635
|
+
"Facebook likes/comments edges are not requested. Missing engagement counts are unavailable, not zero; no commenter profiles are collected.",
|
|
9636
|
+
"Media insights retain Meta\u2019s native periods (usually lifetime). since/until filter the media list, not the measurement period of each media insight.",
|
|
8474
9637
|
"Instagram thumbnail_url is media-type dependent and may only appear for video/Reels media.",
|
|
8475
|
-
"This tool
|
|
9638
|
+
"This tool reads existing Page/Instagram content; it never publishes, edits, hides, or deletes posts.",
|
|
9639
|
+
"Content insights are not Ads Insights. Do not label a metric organic-only unless Meta supplies an explicit organic breakdown or definition.",
|
|
9640
|
+
"Preserve native metric names, titles, descriptions and periods. Unusual titles alone do not establish audience restrictions.",
|
|
9641
|
+
"Native Reel watch time is in milliseconds; do not add an unverified per-view denominator, infer duration from CDN URLs, or infer completion rates or causes of counter discrepancies.",
|
|
9642
|
+
"Keep full returned media URLs as clickable link targets; do not strip required signed query parameters. A media URL is different from a post permalink or an ad landing page."
|
|
8476
9643
|
],
|
|
8477
9644
|
paging,
|
|
8478
9645
|
warnings
|
|
@@ -8514,14 +9681,14 @@ function registerMetaTools(server, config) {
|
|
|
8514
9681
|
limit: limitSchema,
|
|
8515
9682
|
cursor: cursorSchema
|
|
8516
9683
|
},
|
|
8517
|
-
async ({ adAccountId, statusFilter, limit, cursor }) => {
|
|
9684
|
+
async ({ adAccountId, statusFilter, limit = 100, cursor }) => {
|
|
8518
9685
|
try {
|
|
8519
9686
|
const fields = "id,name,status,effective_status,objective,daily_budget,lifetime_budget,budget_remaining,bid_strategy,buying_type,start_time,stop_time";
|
|
8520
9687
|
let url = `https://graph.facebook.com/${client.apiVersion}/${adAccountId}/campaigns?fields=${fields}&limit=${limit}`;
|
|
8521
9688
|
if (statusFilter) url += `&filtering=[{"field":"effective_status","operator":"IN","value":["${statusFilter}"]}]`;
|
|
8522
9689
|
if (cursor) url += `&after=${cursor}`;
|
|
8523
9690
|
const result = await client.fetchUrl(url);
|
|
8524
|
-
return ok(result);
|
|
9691
|
+
return ok(entityListResult(result, statusFilter));
|
|
8525
9692
|
} catch (e) {
|
|
8526
9693
|
return formatMcpToolError(e);
|
|
8527
9694
|
}
|
|
@@ -8532,12 +9699,12 @@ function registerMetaTools(server, config) {
|
|
|
8532
9699
|
"List ad sets for a Meta ad account, optionally filtered by campaign. Returns targeting, budget, optimization, and schedule info.",
|
|
8533
9700
|
{
|
|
8534
9701
|
adAccountId: adAccountIdSchema,
|
|
8535
|
-
campaignId:
|
|
9702
|
+
campaignId: z5.string().optional().describe("Filter by campaign ID"),
|
|
8536
9703
|
statusFilter: statusFilterSchema,
|
|
8537
9704
|
limit: limitSchema,
|
|
8538
9705
|
cursor: cursorSchema
|
|
8539
9706
|
},
|
|
8540
|
-
async ({ adAccountId, campaignId, statusFilter, limit, cursor }) => {
|
|
9707
|
+
async ({ adAccountId, campaignId, statusFilter, limit = 100, cursor }) => {
|
|
8541
9708
|
try {
|
|
8542
9709
|
const fields = "id,name,status,effective_status,campaign_id,daily_budget,lifetime_budget,optimization_goal,billing_event,bid_amount,targeting,start_time,end_time";
|
|
8543
9710
|
const parent = campaignId ?? adAccountId;
|
|
@@ -8545,7 +9712,7 @@ function registerMetaTools(server, config) {
|
|
|
8545
9712
|
if (statusFilter) url += `&filtering=[{"field":"effective_status","operator":"IN","value":["${statusFilter}"]}]`;
|
|
8546
9713
|
if (cursor) url += `&after=${cursor}`;
|
|
8547
9714
|
const result = await client.fetchUrl(url);
|
|
8548
|
-
return ok(result);
|
|
9715
|
+
return ok(entityListResult(result, statusFilter));
|
|
8549
9716
|
} catch (e) {
|
|
8550
9717
|
return formatMcpToolError(e);
|
|
8551
9718
|
}
|
|
@@ -8556,20 +9723,22 @@ function registerMetaTools(server, config) {
|
|
|
8556
9723
|
"List ads for a Meta ad account, optionally filtered by ad set. Returns ad ID, name, status, and creative reference.",
|
|
8557
9724
|
{
|
|
8558
9725
|
adAccountId: adAccountIdSchema,
|
|
8559
|
-
adsetId:
|
|
9726
|
+
adsetId: z5.string().optional().describe("Filter by ad set ID; adSetId is also accepted."),
|
|
9727
|
+
adSetId: z5.string().optional().describe("Alias for adsetId. If both are supplied, they must match."),
|
|
8560
9728
|
statusFilter: statusFilterSchema,
|
|
8561
9729
|
limit: limitSchema,
|
|
8562
9730
|
cursor: cursorSchema
|
|
8563
9731
|
},
|
|
8564
|
-
async ({ adAccountId, adsetId, statusFilter, limit, cursor }) => {
|
|
9732
|
+
async ({ adAccountId, adsetId, adSetId, statusFilter, limit = 100, cursor }) => {
|
|
8565
9733
|
try {
|
|
9734
|
+
if (adsetId && adSetId && adsetId !== adSetId) throw new Error("adsetId and adSetId must match when both are supplied.");
|
|
8566
9735
|
const fields = "id,name,status,effective_status,adset_id,campaign_id,creative{id,name,thumbnail_url,object_story_spec,asset_feed_spec}";
|
|
8567
|
-
const parent = adsetId ?? adAccountId;
|
|
9736
|
+
const parent = adsetId ?? adSetId ?? adAccountId;
|
|
8568
9737
|
let url = `https://graph.facebook.com/${client.apiVersion}/${parent}/ads?fields=${fields}&limit=${limit}`;
|
|
8569
9738
|
if (statusFilter) url += `&filtering=[{"field":"effective_status","operator":"IN","value":["${statusFilter}"]}]`;
|
|
8570
9739
|
if (cursor) url += `&after=${cursor}`;
|
|
8571
9740
|
const result = await client.fetchUrl(url);
|
|
8572
|
-
return ok(result);
|
|
9741
|
+
return ok(entityListResult(result, statusFilter));
|
|
8573
9742
|
} catch (e) {
|
|
8574
9743
|
return formatMcpToolError(e);
|
|
8575
9744
|
}
|
|
@@ -8583,16 +9752,22 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8583
9752
|
{
|
|
8584
9753
|
adAccountId: adAccountIdSchema,
|
|
8585
9754
|
level: levelSchema.describe("Aggregation level: account, campaign, adset, or ad"),
|
|
8586
|
-
metrics:
|
|
8587
|
-
breakdowns:
|
|
9755
|
+
metrics: z5.array(z5.string()).min(1).describe("Metric keys from meta://metrics (e.g., impressions, spend, ctr)"),
|
|
9756
|
+
breakdowns: z5.array(z5.string()).optional().describe("Breakdown keys from meta://breakdowns (e.g., age, gender, country)"),
|
|
8588
9757
|
datePreset: datePresetSchema2.describe("Predefined date range (e.g., last_7d, last_30d)"),
|
|
8589
9758
|
timeRange: timeRangeSchema.describe("Custom date range with since/until in YYYY-MM-DD format"),
|
|
8590
|
-
timeIncrement:
|
|
8591
|
-
|
|
9759
|
+
timeIncrement: z5.union([z5.literal(1), z5.literal(7), z5.literal(14), z5.literal(28), z5.literal(30), z5.string()]).optional().describe("Time granularity: 1 (daily), 7 (weekly), 'monthly', or 'all_days'"),
|
|
9760
|
+
adIds: z5.array(z5.string().regex(/^\d+$/)).min(1).max(100).optional().describe("Restrict ad-level insights to these ad IDs."),
|
|
9761
|
+
attributionMode: z5.enum(["account", "adset", "explicit"]).default("account").describe("Use Meta account settings, ad-set settings, or explicitly supplied windows. No setting is inferred from returned numbers."),
|
|
9762
|
+
attributionWindows: z5.array(z5.enum(["1d_click", "7d_click", "28d_click", "1d_view", "1d_ev"])).min(1).max(5).optional().describe("Only with attributionMode explicit. These windows are sent identically to every split query and account total."),
|
|
9763
|
+
actionReportTime: z5.enum(["impression", "conversion", "mixed"]).default("impression").describe("Date basis for actions, sent explicitly to Meta."),
|
|
9764
|
+
includeAccountTotals: z5.boolean().default(false).describe("Also read native account-level totals with the same dates, metrics and attribution. Requires no adIds or breakdowns."),
|
|
9765
|
+
limit: z5.number().int().min(1).max(5e3).optional().default(500)
|
|
8592
9766
|
},
|
|
8593
|
-
async ({ adAccountId, level, metrics, breakdowns, datePreset, timeRange, timeIncrement, limit }) => {
|
|
9767
|
+
async ({ adAccountId, level, metrics, breakdowns, datePreset, timeRange, timeIncrement, adIds, limit, attributionMode = "account", attributionWindows, actionReportTime = "impression", includeAccountTotals = false }) => {
|
|
8594
9768
|
try {
|
|
8595
9769
|
const startTime = Date.now();
|
|
9770
|
+
if (adIds && level !== "ad") throw new Error("adIds requires level ad.");
|
|
8596
9771
|
const plan = planQueries({
|
|
8597
9772
|
adAccountId,
|
|
8598
9773
|
level,
|
|
@@ -8611,9 +9786,13 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8611
9786
|
suggestion: "Check meta://compatibility for valid metric/breakdown combinations"
|
|
8612
9787
|
});
|
|
8613
9788
|
}
|
|
9789
|
+
if (includeAccountTotals && (adIds?.length || breakdowns?.length)) throw new Error("Native account totals require no ad ID filter or breakdowns; run a separate account-level query instead.");
|
|
9790
|
+
const measurement = { attributionMode, attributionWindows: attributionWindows ? [...new Set(attributionWindows)] : void 0, actionReportTime };
|
|
9791
|
+
const sentMeasurement = measurementParams({ ...plan.requests[0], ...measurement });
|
|
9792
|
+
for (const request2 of plan.requests) Object.assign(request2, measurement);
|
|
8614
9793
|
const allResults = [];
|
|
8615
9794
|
for (const request2 of plan.requests) {
|
|
8616
|
-
const result = await client.fetchInsights(adAccountId, request2);
|
|
9795
|
+
const result = await client.fetchInsights(adAccountId, { ...request2, ...adIds ? { filtering: [{ field: "ad.id", operator: "IN", value: adIds }] } : {} });
|
|
8617
9796
|
allResults.push(result);
|
|
8618
9797
|
}
|
|
8619
9798
|
let data = mergeResults(allResults, plan.joinKeys);
|
|
@@ -8623,11 +9802,32 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8623
9802
|
...calculateDerivedMetrics(row)
|
|
8624
9803
|
}));
|
|
8625
9804
|
}
|
|
9805
|
+
let accountTotals = null;
|
|
9806
|
+
let totalsRequestCount = 0;
|
|
9807
|
+
if (includeAccountTotals) {
|
|
9808
|
+
if (level === "account") accountTotals = data;
|
|
9809
|
+
else {
|
|
9810
|
+
const totalsPlan = planQueries({ adAccountId, level: "account", metrics, breakdowns: [], datePreset: datePreset ?? "last_30d", timeRange, timeIncrement, limit });
|
|
9811
|
+
if (totalsPlan.errors.length) throw new Error("Cannot produce comparable native account totals: " + totalsPlan.errors.join("; "));
|
|
9812
|
+
const results = [];
|
|
9813
|
+
for (const q of totalsPlan.requests) {
|
|
9814
|
+
results.push(await client.fetchInsights(adAccountId, { ...q, ...measurement }));
|
|
9815
|
+
totalsRequestCount++;
|
|
9816
|
+
}
|
|
9817
|
+
accountTotals = mergeResults(results, totalsPlan.joinKeys);
|
|
9818
|
+
if (totalsPlan.calculatedMetrics.length) accountTotals = accountTotals.map((row) => ({ ...row, ...calculateDerivedMetrics(row) }));
|
|
9819
|
+
}
|
|
9820
|
+
}
|
|
9821
|
+
const evidence = reportingEvidence(allResults, plan.requests, plan.joinKeys, data);
|
|
8626
9822
|
return ok({
|
|
8627
9823
|
data,
|
|
9824
|
+
reporting_context: { ad_account_id: adAccountId, level, time_range: timeRange ?? null, date_preset: timeRange ? null : datePreset ?? "last_30d", time_increment: timeIncrement ?? "all_days", attribution: { mode: attributionMode, requested_windows: attributionWindows ?? null, resolved_native_windows: null, parameters_sent: sentMeasurement }, action_report_time: actionReportTime, ad_ids: adIds ?? null, breakdowns: breakdowns ?? [], pagination_exhausted: true },
|
|
9825
|
+
account_totals: accountTotals,
|
|
9826
|
+
evidence,
|
|
9827
|
+
notes: evidence.notes,
|
|
8628
9828
|
rowCount: data.length,
|
|
8629
9829
|
debug: {
|
|
8630
|
-
requestCount: plan.requests.length,
|
|
9830
|
+
requestCount: plan.requests.length + totalsRequestCount,
|
|
8631
9831
|
executionTimeMs: Date.now() - startTime,
|
|
8632
9832
|
warnings: plan.warnings,
|
|
8633
9833
|
calculatedMetrics: plan.calculatedMetrics
|
|
@@ -8643,7 +9843,7 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8643
9843
|
"Get hierarchical campaign structure: campaigns -> ad sets -> ads. Useful for understanding account organization.",
|
|
8644
9844
|
{
|
|
8645
9845
|
adAccountId: adAccountIdSchema,
|
|
8646
|
-
campaignId:
|
|
9846
|
+
campaignId: z5.string().optional().describe("Get structure for a specific campaign only")
|
|
8647
9847
|
},
|
|
8648
9848
|
async ({ adAccountId, campaignId }) => {
|
|
8649
9849
|
try {
|
|
@@ -8674,20 +9874,56 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8674
9874
|
"Get ad creative content: text, images, videos, links, call-to-action. Returns creative details for specified ads.",
|
|
8675
9875
|
{
|
|
8676
9876
|
adAccountId: adAccountIdSchema,
|
|
8677
|
-
adIds:
|
|
9877
|
+
adIds: z5.array(z5.string()).optional().describe("Specific ad IDs to get creatives for"),
|
|
8678
9878
|
limit: limitSchema
|
|
8679
9879
|
},
|
|
8680
9880
|
async ({ adAccountId, adIds, limit }) => {
|
|
8681
9881
|
try {
|
|
8682
9882
|
const fields = "id,name,status,creative{id,name,body,title,image_url,image_hash,video_id,thumbnail_url,link_url,object_story_spec,asset_feed_spec,call_to_action_type}";
|
|
8683
|
-
let url;
|
|
8684
9883
|
if (adIds && adIds.length > 0) {
|
|
8685
|
-
const ids = adIds
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
9884
|
+
const ids = [...new Set(adIds)];
|
|
9885
|
+
if (ids.length > 500 || ids.some((id2) => !/^\d+$/.test(id2))) {
|
|
9886
|
+
throw new Error("Provide at most 500 numeric ad IDs.");
|
|
9887
|
+
}
|
|
9888
|
+
const account = formatAdAccountId(adAccountId);
|
|
9889
|
+
const merged = {};
|
|
9890
|
+
const warnings = [];
|
|
9891
|
+
let requestCount = 0;
|
|
9892
|
+
for (let index = 0; index < ids.length; index += 50) {
|
|
9893
|
+
const chunk = ids.slice(index, index + 50);
|
|
9894
|
+
const result2 = await client.fetchUrl(graphUrl(client, `/${account}/ads`, {
|
|
9895
|
+
fields: `account_id,${fields}`,
|
|
9896
|
+
filtering: JSON.stringify([{ field: "id", operator: "IN", value: chunk }]),
|
|
9897
|
+
limit: 500
|
|
9898
|
+
}));
|
|
9899
|
+
requestCount++;
|
|
9900
|
+
for (const ad of dataArray(result2)) {
|
|
9901
|
+
if (typeof ad.id === "string" && chunk.includes(ad.id) && typeof ad.account_id === "string" && formatAdAccountId(ad.account_id) === account) {
|
|
9902
|
+
merged[ad.id] = ad;
|
|
9903
|
+
}
|
|
9904
|
+
}
|
|
9905
|
+
}
|
|
9906
|
+
const missing = ids.filter((id2) => !merged[id2]);
|
|
9907
|
+
for (const id2 of missing.slice(0, 10)) {
|
|
9908
|
+
requestCount++;
|
|
9909
|
+
const ad = await fetchGraph(
|
|
9910
|
+
client,
|
|
9911
|
+
"historical_ad_creative",
|
|
9912
|
+
graphUrl(client, `/${id2}`, { fields: `account_id,${fields}` }),
|
|
9913
|
+
warnings,
|
|
9914
|
+
"Verify that this historical ad is still accessible in the selected account."
|
|
9915
|
+
);
|
|
9916
|
+
if (ad?.id === id2 && typeof ad.account_id === "string" && formatAdAccountId(ad.account_id) === account) merged[id2] = ad;
|
|
9917
|
+
}
|
|
9918
|
+
const unresolved = ids.filter((id2) => !merged[id2]);
|
|
9919
|
+
if (unresolved.length) warnings.push({
|
|
9920
|
+
area: "creative_lookup",
|
|
9921
|
+
message: `${unresolved.length} requested ads could not be verified in the selected account.`,
|
|
9922
|
+
suggestion: "Narrow the selection; historical fallback is limited to 10 ads. Missing ads are not empty creatives."
|
|
9923
|
+
});
|
|
9924
|
+
return ok({ ...merged, warnings, debug: { requestCount } });
|
|
8689
9925
|
}
|
|
8690
|
-
const result = await client.fetchUrl(
|
|
9926
|
+
const result = await client.fetchUrl(graphUrl(client, `/${formatAdAccountId(adAccountId)}/ads`, { fields, limit }));
|
|
8691
9927
|
return ok(result);
|
|
8692
9928
|
} catch (e) {
|
|
8693
9929
|
return formatMcpToolError(e);
|
|
@@ -8699,7 +9935,7 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8699
9935
|
"List custom, saved, and lookalike audiences for a Meta ad account.",
|
|
8700
9936
|
{
|
|
8701
9937
|
adAccountId: adAccountIdSchema,
|
|
8702
|
-
type:
|
|
9938
|
+
type: z5.enum(["custom", "saved", "lookalike"]).optional().describe("Filter by audience type"),
|
|
8703
9939
|
limit: limitSchema
|
|
8704
9940
|
},
|
|
8705
9941
|
async ({ adAccountId, type, limit }) => {
|
|
@@ -8733,7 +9969,7 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8733
9969
|
server.tool(
|
|
8734
9970
|
"meta_get_study_results",
|
|
8735
9971
|
"Get detailed results for a conversion lift or A/B test study. Returns objectives, cells, and lift results.",
|
|
8736
|
-
{ studyId:
|
|
9972
|
+
{ studyId: z5.string().describe("The Ad Study ID") },
|
|
8737
9973
|
async ({ studyId }) => {
|
|
8738
9974
|
try {
|
|
8739
9975
|
const [cells, objectives] = await Promise.all([
|
|
@@ -8750,8 +9986,8 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8750
9986
|
"meta_validate_query",
|
|
8751
9987
|
"Validate a metric/breakdown combination BEFORE executing. Returns errors and warnings. Use this to check if your query will work.",
|
|
8752
9988
|
{
|
|
8753
|
-
metrics:
|
|
8754
|
-
breakdowns:
|
|
9989
|
+
metrics: z5.array(z5.string()).min(1).describe("Metric keys to validate"),
|
|
9990
|
+
breakdowns: z5.array(z5.string()).optional().describe("Breakdown keys to validate"),
|
|
8755
9991
|
level: levelSchema.optional().describe("Aggregation level")
|
|
8756
9992
|
},
|
|
8757
9993
|
async ({ metrics, breakdowns, level }) => {
|
|
@@ -8765,20 +10001,38 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8765
10001
|
);
|
|
8766
10002
|
server.tool(
|
|
8767
10003
|
"meta_get_page_posts",
|
|
8768
|
-
"
|
|
10004
|
+
"Read published Facebook Page posts using a Page token resolved from the connected user. Returns exact PageID_PostID, message, permalink and media attachments. Use postId for an exact existing-post ad preflight. No Instagram media, comments, likes or insights are requested; this read alone does not establish advertising eligibility.",
|
|
8769
10005
|
{
|
|
8770
|
-
pageId:
|
|
8771
|
-
|
|
10006
|
+
pageId: z5.string().regex(/^\d+$/).describe("Facebook Page ID"),
|
|
10007
|
+
postId: z5.string().regex(/^\d+_\d+$/).optional().describe("Exact PageID_PostID to read instead of the recent-post list; must belong to pageId."),
|
|
10008
|
+
limit: z5.number().int().min(1).max(100).optional().default(25),
|
|
10009
|
+
cursor: cursorSchema
|
|
8772
10010
|
},
|
|
8773
|
-
async ({ pageId, limit }) => {
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
const result = await client.fetchUrl(url);
|
|
8778
|
-
return ok(result);
|
|
8779
|
-
} catch (e) {
|
|
8780
|
-
return formatMcpToolError(e);
|
|
10011
|
+
async ({ pageId, postId, limit, cursor }) => {
|
|
10012
|
+
const warnings = [];
|
|
10013
|
+
if (postId && (!postId.startsWith(`${pageId}_`) || cursor)) {
|
|
10014
|
+
return { ...ok({ error: "postId must belong to pageId and cannot be combined with a list cursor." }), isError: true };
|
|
8781
10015
|
}
|
|
10016
|
+
const pageClient = await pageClientResolver(client, warnings)(pageId);
|
|
10017
|
+
if (!pageClient) return { ...ok({ error: "Could not obtain access to the selected Page. No posts were read.", warnings }), isError: true };
|
|
10018
|
+
const path = postId ? `/${encodeURIComponent(postId)}` : `/${encodeURIComponent(pageId)}/posts`;
|
|
10019
|
+
const params = postId ? {} : { limit, after: cursor };
|
|
10020
|
+
const result = await fetchGraphWithFallback(
|
|
10021
|
+
pageClient,
|
|
10022
|
+
"page_posts",
|
|
10023
|
+
graphUrl(pageClient, path, { ...params, fields: "id,message,created_time,permalink_url,full_picture,attachments{media,type,url,target,title,description},shares" }),
|
|
10024
|
+
graphUrl(pageClient, path, { ...params, fields: "id,message,created_time,permalink_url,full_picture,shares" }),
|
|
10025
|
+
warnings,
|
|
10026
|
+
"Post attachments require pages_read_engagement and access to this Page."
|
|
10027
|
+
);
|
|
10028
|
+
if (!result) return { ...ok({ error: "Could not read the selected Page posts.", warnings }), isError: true };
|
|
10029
|
+
return ok({
|
|
10030
|
+
...postId ? { data: [result] } : result,
|
|
10031
|
+
page_id: pageId,
|
|
10032
|
+
coverage: { scope: postId ? "exact_post" : "page_posts", has_more: !!(isRecord(result.paging) && result.paging.next) },
|
|
10033
|
+
warnings,
|
|
10034
|
+
notes: ["Use the native PageID_PostID to reuse a post; the number in a Reel permalink is not its post ID.", "Advertising eligibility requires separate creative/ad validation by Meta. Missing engagement counts are unavailable, not zero."]
|
|
10035
|
+
});
|
|
8782
10036
|
}
|
|
8783
10037
|
);
|
|
8784
10038
|
server.tool(
|
|
@@ -8799,12 +10053,13 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8799
10053
|
"Search campaigns, ad sets, or ads by name within an ad account. Useful for finding specific entities.",
|
|
8800
10054
|
{
|
|
8801
10055
|
adAccountId: adAccountIdSchema,
|
|
8802
|
-
entityType:
|
|
8803
|
-
nameFilter:
|
|
10056
|
+
entityType: z5.enum(["campaigns", "adsets", "ads"]).describe("Type of entity to search"),
|
|
10057
|
+
nameFilter: z5.string().describe("Name substring to search for"),
|
|
8804
10058
|
statusFilter: statusFilterSchema,
|
|
8805
|
-
limit: limitSchema
|
|
10059
|
+
limit: limitSchema,
|
|
10060
|
+
cursor: cursorSchema
|
|
8806
10061
|
},
|
|
8807
|
-
async ({ adAccountId, entityType, nameFilter, statusFilter, limit }) => {
|
|
10062
|
+
async ({ adAccountId, entityType, nameFilter, statusFilter, limit, cursor }) => {
|
|
8808
10063
|
try {
|
|
8809
10064
|
const fieldsMap = {
|
|
8810
10065
|
campaigns: "id,name,status,effective_status,objective",
|
|
@@ -8813,22 +10068,248 @@ The query planner automatically splits incompatible metric/breakdown combination
|
|
|
8813
10068
|
};
|
|
8814
10069
|
const fields = fieldsMap[entityType];
|
|
8815
10070
|
const filters = [{ field: "name", operator: "CONTAIN", value: nameFilter }];
|
|
8816
|
-
if (statusFilter) {
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
10071
|
+
if (statusFilter) filters.push({ field: "effective_status", operator: "IN", value: [statusFilter] });
|
|
10072
|
+
const url = graphUrl(client, `/${formatAdAccountId(adAccountId)}/${entityType}`, {
|
|
10073
|
+
fields,
|
|
10074
|
+
limit,
|
|
10075
|
+
filtering: JSON.stringify(filters),
|
|
10076
|
+
after: cursor
|
|
10077
|
+
});
|
|
8820
10078
|
const result = await client.fetchUrl(url);
|
|
8821
|
-
return ok(result);
|
|
10079
|
+
return ok(entityListResult(result, statusFilter));
|
|
8822
10080
|
} catch (e) {
|
|
8823
10081
|
return formatMcpToolError(e);
|
|
8824
10082
|
}
|
|
8825
10083
|
}
|
|
8826
10084
|
);
|
|
8827
10085
|
registerMetaBroadReadTools(server, client, ok);
|
|
10086
|
+
server.tool(
|
|
10087
|
+
"meta_list_ad_images",
|
|
10088
|
+
"List the ad account image library (/adimages): hash, name, dimensions, status, a permanent publicly served display URL (permalink_url), short-lived CDN URLs, and optionally the creatives using each image.",
|
|
10089
|
+
{
|
|
10090
|
+
adAccountId: adAccountIdSchema,
|
|
10091
|
+
nameFilter: z5.string().optional().describe("Only images whose file name matches this value"),
|
|
10092
|
+
hashes: z5.array(z5.string()).optional().describe("Only these image hashes (exact match)"),
|
|
10093
|
+
minWidth: z5.number().int().min(1).optional().describe("Only images at least this wide, in pixels"),
|
|
10094
|
+
minHeight: z5.number().int().min(1).optional().describe("Only images at least this tall, in pixels"),
|
|
10095
|
+
includeUsage: z5.boolean().optional().default(false).describe("Also return the creative IDs using each image"),
|
|
10096
|
+
limit: limitSchema,
|
|
10097
|
+
cursor: cursorSchema
|
|
10098
|
+
},
|
|
10099
|
+
async ({ adAccountId, nameFilter, hashes, minWidth, minHeight, includeUsage, limit, cursor }) => {
|
|
10100
|
+
const warnings = [];
|
|
10101
|
+
const baseFields = [
|
|
10102
|
+
"id",
|
|
10103
|
+
"hash",
|
|
10104
|
+
"name",
|
|
10105
|
+
"status",
|
|
10106
|
+
"created_time",
|
|
10107
|
+
"updated_time",
|
|
10108
|
+
"original_width",
|
|
10109
|
+
"original_height",
|
|
10110
|
+
"width",
|
|
10111
|
+
"height",
|
|
10112
|
+
"permalink_url",
|
|
10113
|
+
"url",
|
|
10114
|
+
"url_128"
|
|
10115
|
+
];
|
|
10116
|
+
if (includeUsage) baseFields.push("creatives");
|
|
10117
|
+
const result = await fetchGraph(
|
|
10118
|
+
client,
|
|
10119
|
+
"ad_images",
|
|
10120
|
+
graphUrl(client, `/${formatAdAccountId(adAccountId)}/adimages`, {
|
|
10121
|
+
fields: baseFields.join(","),
|
|
10122
|
+
limit: typeof limit === "number" ? limit : 100,
|
|
10123
|
+
after: cursor,
|
|
10124
|
+
summary: "total_count",
|
|
10125
|
+
name: nameFilter,
|
|
10126
|
+
hashes: Array.isArray(hashes) && hashes.length > 0 ? JSON.stringify(hashes) : void 0,
|
|
10127
|
+
minwidth: minWidth,
|
|
10128
|
+
minheight: minHeight
|
|
10129
|
+
}),
|
|
10130
|
+
warnings,
|
|
10131
|
+
"Verify the token has ads_read on this ad account."
|
|
10132
|
+
);
|
|
10133
|
+
const images = dataArray(result).map((image) => ({
|
|
10134
|
+
...image,
|
|
10135
|
+
creatives: Array.isArray(image.creatives) ? image.creatives : void 0
|
|
10136
|
+
}));
|
|
10137
|
+
const summary = isRecord(result?.summary) ? result.summary : void 0;
|
|
10138
|
+
return ok({
|
|
10139
|
+
images,
|
|
10140
|
+
count: images.length,
|
|
10141
|
+
totalCount: typeof summary?.total_count === "number" ? summary.total_count : void 0,
|
|
10142
|
+
paging: pagingInfo(result),
|
|
10143
|
+
warnings,
|
|
10144
|
+
limitations: [
|
|
10145
|
+
"Use url (or url_128 for a small preview) for image bytes. permalink_url can return Facebook HTML and is not an image file.",
|
|
10146
|
+
"CDN URLs are signed and can expire. Re-list by hash to refresh; do not store the URLs as durable links."
|
|
10147
|
+
],
|
|
10148
|
+
nextActions: pagingInfo(result) ? ["Pass the paging cursor as `cursor` to fetch the next page."] : []
|
|
10149
|
+
});
|
|
10150
|
+
}
|
|
10151
|
+
);
|
|
10152
|
+
server.tool(
|
|
10153
|
+
"meta_list_ad_videos",
|
|
10154
|
+
"List the ad account video library (/advideos): title, duration, processing status, and publicly served thumbnails (preferred and largest sizes). Source file URLs are returned only on request because they expire quickly.",
|
|
10155
|
+
{
|
|
10156
|
+
adAccountId: adAccountIdSchema,
|
|
10157
|
+
titleFilter: z5.string().optional().describe("Only videos whose title contains this value"),
|
|
10158
|
+
minLengthSeconds: z5.number().min(0).optional().describe("Only videos at least this many seconds long"),
|
|
10159
|
+
maxLengthSeconds: z5.number().min(1).optional().describe("Only videos at most this many seconds long"),
|
|
10160
|
+
includeSource: z5.boolean().optional().default(false).describe("Also return the short-lived raw video file URL for each video"),
|
|
10161
|
+
limit: z5.number().int().min(1).max(200).optional().default(25).describe("Videos per page (thumbnail payloads are heavy, keep this modest)"),
|
|
10162
|
+
cursor: cursorSchema
|
|
10163
|
+
},
|
|
10164
|
+
async ({ adAccountId, titleFilter, minLengthSeconds, maxLengthSeconds, includeSource, limit, cursor }) => {
|
|
10165
|
+
const warnings = [];
|
|
10166
|
+
const baseFields = [
|
|
10167
|
+
"id",
|
|
10168
|
+
"title",
|
|
10169
|
+
"status",
|
|
10170
|
+
"created_time",
|
|
10171
|
+
"updated_time",
|
|
10172
|
+
"length",
|
|
10173
|
+
"picture",
|
|
10174
|
+
"permalink_url",
|
|
10175
|
+
"thumbnails{uri,width,height,scale,is_preferred}"
|
|
10176
|
+
];
|
|
10177
|
+
if (includeSource) baseFields.push("source");
|
|
10178
|
+
const result = await fetchGraph(
|
|
10179
|
+
client,
|
|
10180
|
+
"ad_videos",
|
|
10181
|
+
graphUrl(client, `/${formatAdAccountId(adAccountId)}/advideos`, {
|
|
10182
|
+
fields: baseFields.join(","),
|
|
10183
|
+
limit: typeof limit === "number" ? limit : 25,
|
|
10184
|
+
after: cursor,
|
|
10185
|
+
summary: "total_count",
|
|
10186
|
+
title: titleFilter,
|
|
10187
|
+
// Vérifié en live (Graph v26.0) : minlength/maxlength s'expriment en
|
|
10188
|
+
// millisecondes malgré une doc muette; en secondes, maxlength=120
|
|
10189
|
+
// élimine silencieusement toutes les vidéos.
|
|
10190
|
+
minlength: typeof minLengthSeconds === "number" ? Math.floor(minLengthSeconds * 1e3) : void 0,
|
|
10191
|
+
maxlength: typeof maxLengthSeconds === "number" ? Math.ceil(maxLengthSeconds * 1e3) : void 0
|
|
10192
|
+
}),
|
|
10193
|
+
warnings,
|
|
10194
|
+
"Verify the token has ads_read on this ad account."
|
|
10195
|
+
);
|
|
10196
|
+
const videos = dataArray(result).map((video) => normalizeAdVideo(video, Boolean(includeSource)));
|
|
10197
|
+
const summary = isRecord(result?.summary) ? result.summary : void 0;
|
|
10198
|
+
return ok({
|
|
10199
|
+
videos,
|
|
10200
|
+
count: videos.length,
|
|
10201
|
+
totalCount: typeof summary?.total_count === "number" ? summary.total_count : void 0,
|
|
10202
|
+
paging: pagingInfo(result),
|
|
10203
|
+
warnings,
|
|
10204
|
+
limitations: [
|
|
10205
|
+
"Thumbnail and picture URLs are publicly served without authentication but carry CDN signatures that expire after a few weeks: re-list to refresh, do not store them as durable links.",
|
|
10206
|
+
"permalink_url points at facebook.com/watch and requires a logged-in session: unsuitable for hotlinking.",
|
|
10207
|
+
includeSource ? "source URLs are short-lived signed download links: use them immediately." : "Call meta_get_video_sources with specific video IDs when you need downloadable source file URLs."
|
|
10208
|
+
],
|
|
10209
|
+
nextActions: pagingInfo(result) ? ["Pass the paging cursor as `cursor` to fetch the next page."] : []
|
|
10210
|
+
});
|
|
10211
|
+
}
|
|
10212
|
+
);
|
|
10213
|
+
server.tool(
|
|
10214
|
+
"meta_get_video_sources",
|
|
10215
|
+
"Resolve fresh download URLs (source) and thumbnails for specific ad videos. Call this at download time: the returned URLs are signed and expire quickly.",
|
|
10216
|
+
{
|
|
10217
|
+
adAccountId: adAccountIdSchema.describe("Ad account that owns the videos, used for scoping and rate limits"),
|
|
10218
|
+
videoIds: z5.array(z5.string()).min(1).max(10).describe("Video IDs to resolve (at most 10 per call, one Graph request each)"),
|
|
10219
|
+
pageId: z5.string().regex(/^\d+$/).optional().describe("Owning Page ID from the ad creative. Allows Page-token fallback even when the user token cannot read the video node.")
|
|
10220
|
+
},
|
|
10221
|
+
async ({ adAccountId, videoIds, pageId: owningPageId }) => {
|
|
10222
|
+
void adAccountId;
|
|
10223
|
+
const warnings = [];
|
|
10224
|
+
const maxIdsPerCall = 10;
|
|
10225
|
+
const requested = Array.isArray(videoIds) ? [...new Set(videoIds.map(String).filter(Boolean))] : [];
|
|
10226
|
+
const ids = requested.slice(0, maxIdsPerCall);
|
|
10227
|
+
if (ids.length === 0) {
|
|
10228
|
+
return ok({
|
|
10229
|
+
videos: [],
|
|
10230
|
+
count: 0,
|
|
10231
|
+
warnings: [{
|
|
10232
|
+
area: "video_sources",
|
|
10233
|
+
message: "No video IDs were provided.",
|
|
10234
|
+
suggestion: "Pass videoIds from meta_list_ad_videos."
|
|
10235
|
+
}]
|
|
10236
|
+
});
|
|
10237
|
+
}
|
|
10238
|
+
if (requested.length > maxIdsPerCall) {
|
|
10239
|
+
warnings.push({
|
|
10240
|
+
area: "video_sources",
|
|
10241
|
+
message: `Only the first ${maxIdsPerCall} of ${requested.length} video IDs were resolved.`,
|
|
10242
|
+
suggestion: "Call again with the remaining IDs: each video costs one Graph request."
|
|
10243
|
+
});
|
|
10244
|
+
}
|
|
10245
|
+
const videoFields = "id,title,status,length,source,picture,permalink_url,from,thumbnails{uri,width,height,is_preferred}";
|
|
10246
|
+
const fetched = [];
|
|
10247
|
+
for (const videoId of ids) {
|
|
10248
|
+
const result = await fetchGraph(
|
|
10249
|
+
client,
|
|
10250
|
+
`video_${videoId}`,
|
|
10251
|
+
graphUrl(client, `/${encodeURIComponent(videoId)}`, { fields: videoFields }),
|
|
10252
|
+
warnings,
|
|
10253
|
+
"Verify the video ID comes from meta_list_ad_videos and is readable by this token."
|
|
10254
|
+
);
|
|
10255
|
+
if (result) fetched.push(result);
|
|
10256
|
+
else if (owningPageId) fetched.push({ id: videoId });
|
|
10257
|
+
}
|
|
10258
|
+
const pageClients = /* @__PURE__ */ new Map();
|
|
10259
|
+
for (const video of fetched) {
|
|
10260
|
+
if (typeof video.source === "string") continue;
|
|
10261
|
+
const from = isRecord(video.from) ? video.from : void 0;
|
|
10262
|
+
const pageId = typeof from?.id === "string" ? from.id : owningPageId;
|
|
10263
|
+
if (!pageId) continue;
|
|
10264
|
+
let pageClient = pageClients.get(pageId);
|
|
10265
|
+
if (pageClient === void 0) {
|
|
10266
|
+
const tokenResult = await fetchGraph(
|
|
10267
|
+
client,
|
|
10268
|
+
`page_token_${pageId}`,
|
|
10269
|
+
graphUrl(client, `/${pageId}`, { fields: "access_token" }),
|
|
10270
|
+
warnings,
|
|
10271
|
+
"Resolving a Page-hosted video file requires an admin role on the owning Page and the pages_show_list scope."
|
|
10272
|
+
);
|
|
10273
|
+
const pageAccessToken = typeof tokenResult?.access_token === "string" ? tokenResult.access_token : void 0;
|
|
10274
|
+
pageClient = pageAccessToken ? new MetaClient(pageAccessToken, client.apiVersion) : null;
|
|
10275
|
+
pageClients.set(pageId, pageClient);
|
|
10276
|
+
}
|
|
10277
|
+
if (!pageClient) continue;
|
|
10278
|
+
const retried = await fetchGraph(
|
|
10279
|
+
pageClient,
|
|
10280
|
+
`video_${video.id}_via_page`,
|
|
10281
|
+
graphUrl(pageClient, `/${encodeURIComponent(String(video.id))}`, { fields: videoFields }),
|
|
10282
|
+
warnings,
|
|
10283
|
+
"The Page token could not read this video: verify the Page role covers content access."
|
|
10284
|
+
);
|
|
10285
|
+
if (retried && typeof retried.source === "string") Object.assign(video, retried);
|
|
10286
|
+
}
|
|
10287
|
+
const videos = fetched.map((video) => normalizeAdVideo(video, true));
|
|
10288
|
+
const withoutSource = videos.filter((video) => video.source === void 0).length;
|
|
10289
|
+
if (withoutSource > 0) {
|
|
10290
|
+
warnings.push({
|
|
10291
|
+
area: "video_sources",
|
|
10292
|
+
message: `${withoutSource} video(s) returned no source file even via their owning Page.`,
|
|
10293
|
+
suggestion: "Their thumbnails and metadata remain available; the file requires a token holding an admin role on the owning Page."
|
|
10294
|
+
});
|
|
10295
|
+
}
|
|
10296
|
+
return ok({
|
|
10297
|
+
videos,
|
|
10298
|
+
count: videos.length,
|
|
10299
|
+
warnings,
|
|
10300
|
+
limitations: [
|
|
10301
|
+
"source URLs are signed, publicly fetchable download links that expire quickly: use them immediately and re-call this tool for fresh ones.",
|
|
10302
|
+
"Videos owned by a Facebook Page (boosted posts) are resolved through the Page's own token when this connection administers the Page; otherwise thumbnails and metadata are returned without the file."
|
|
10303
|
+
]
|
|
10304
|
+
});
|
|
10305
|
+
}
|
|
10306
|
+
);
|
|
8828
10307
|
}
|
|
8829
10308
|
|
|
8830
10309
|
// src/platforms/meta/resources.ts
|
|
8831
10310
|
var READ_ONLY_TOOLS = [
|
|
10311
|
+
"meta_get_adset_configuration",
|
|
10312
|
+
"meta_get_catalog_batch_status",
|
|
8832
10313
|
"meta_health_check",
|
|
8833
10314
|
"meta_list_ad_accounts",
|
|
8834
10315
|
"meta_get_account_details",
|
|
@@ -8862,7 +10343,12 @@ var READ_ONLY_TOOLS = [
|
|
|
8862
10343
|
"meta_list_edge_raw",
|
|
8863
10344
|
"meta_get_insights_raw",
|
|
8864
10345
|
"meta_search_targeting_options",
|
|
8865
|
-
"meta_get_ad_preview"
|
|
10346
|
+
"meta_get_ad_preview",
|
|
10347
|
+
"meta_list_ad_images",
|
|
10348
|
+
"meta_list_ad_videos",
|
|
10349
|
+
"meta_get_video_sources",
|
|
10350
|
+
"meta_get_entity_configuration",
|
|
10351
|
+
"meta_get_uploaded_video"
|
|
8866
10352
|
];
|
|
8867
10353
|
function jsonResource(uri, data) {
|
|
8868
10354
|
return {
|
|
@@ -9154,11 +10640,11 @@ function registerMetaResources(server, enableWrites = false) {
|
|
|
9154
10640
|
},
|
|
9155
10641
|
{
|
|
9156
10642
|
scope: "catalog_management",
|
|
9157
|
-
reason: "Optional for GET-only Product Catalog/Product Item reads when Meta requires catalog-level access.
|
|
10643
|
+
reason: "Optional for GET-only Product Catalog/Product Item reads when Meta requires catalog-level access. Catalog mutations require explicit write opt-in, the corresponding permission and confirmation."
|
|
9158
10644
|
}
|
|
9159
10645
|
],
|
|
9160
10646
|
write_scopes_to_avoid_for_this_server: [
|
|
9161
|
-
"ads_management",
|
|
10647
|
+
...!enableWrites ? ["ads_management"] : [],
|
|
9162
10648
|
"pages_manage_posts",
|
|
9163
10649
|
"pages_manage_metadata",
|
|
9164
10650
|
"instagram_content_publish"
|
|
@@ -9174,14 +10660,15 @@ function registerMetaResources(server, enableWrites = false) {
|
|
|
9174
10660
|
data_handling_notes: [
|
|
9175
10661
|
"Tool responses redact access_token fields and access_token query parameters.",
|
|
9176
10662
|
"Permission failures are returned as warnings for discovery tools when a partial response is still useful.",
|
|
9177
|
-
"
|
|
10663
|
+
enableWrites ? "Write tools are enabled; each mutation requires confirm:true after preview." : "Write tools are disabled on this instance."
|
|
9178
10664
|
]
|
|
9179
10665
|
}
|
|
9180
10666
|
));
|
|
9181
10667
|
}
|
|
9182
10668
|
|
|
9183
10669
|
// src/platforms/meta/writes.ts
|
|
9184
|
-
import { z as
|
|
10670
|
+
import { z as z6 } from "zod";
|
|
10671
|
+
var ADS_BASE = "https://googleads.googleapis.com/v25";
|
|
9185
10672
|
function ok2(data) {
|
|
9186
10673
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
9187
10674
|
}
|
|
@@ -9196,38 +10683,9 @@ function preview(action, details) {
|
|
|
9196
10683
|
message: "Preview only, nothing was changed. Repeat the same call with confirm: true to apply this change to the live account."
|
|
9197
10684
|
});
|
|
9198
10685
|
}
|
|
9199
|
-
var confirmSchema =
|
|
9200
|
-
|
|
9201
|
-
"
|
|
9202
|
-
"KRW",
|
|
9203
|
-
"CLP",
|
|
9204
|
-
"ISK",
|
|
9205
|
-
"VND",
|
|
9206
|
-
"UGX",
|
|
9207
|
-
"PYG",
|
|
9208
|
-
"RWF",
|
|
9209
|
-
"XOF",
|
|
9210
|
-
"XAF",
|
|
9211
|
-
"XPF",
|
|
9212
|
-
"BIF",
|
|
9213
|
-
"DJF",
|
|
9214
|
-
"GNF",
|
|
9215
|
-
"KMF",
|
|
9216
|
-
"MGA",
|
|
9217
|
-
"VUV"
|
|
9218
|
-
]);
|
|
9219
|
-
function toMinorUnits(amount, currency) {
|
|
9220
|
-
const code = currency.trim().toUpperCase();
|
|
9221
|
-
if (!/^[A-Z]{3}$/.test(code)) {
|
|
9222
|
-
throw new Error(`Expected a three letter currency code, received "${currency}".`);
|
|
9223
|
-
}
|
|
9224
|
-
if (!Number.isFinite(amount) || amount <= 0) {
|
|
9225
|
-
throw new Error(`Expected a positive amount, received "${amount}".`);
|
|
9226
|
-
}
|
|
9227
|
-
return Math.round(amount * (ZERO_DECIMAL.has(code) ? 1 : 100));
|
|
9228
|
-
}
|
|
9229
|
-
async function request(url, init, context) {
|
|
9230
|
-
const response = await fetch(url, init);
|
|
10686
|
+
var confirmSchema = z6.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
|
|
10687
|
+
async function request(url, init, contexte) {
|
|
10688
|
+
const response = await fetch(url, { ...init, redirect: "error" });
|
|
9231
10689
|
const body = await response.text();
|
|
9232
10690
|
let parsed;
|
|
9233
10691
|
try {
|
|
@@ -9236,223 +10694,111 @@ async function request(url, init, context) {
|
|
|
9236
10694
|
parsed = body;
|
|
9237
10695
|
}
|
|
9238
10696
|
if (!response.ok) {
|
|
9239
|
-
|
|
9240
|
-
|
|
10697
|
+
if (url.startsWith(ADS_BASE + "/")) {
|
|
10698
|
+
const error = parsed?.error;
|
|
10699
|
+
const details = error?.details?.flatMap((d) => d.errors ?? []).map((e) => ({ code: e.errorCode, message: e.message, location: e.location }));
|
|
10700
|
+
throw new Error(`${contexte}: ${JSON.stringify(details?.length ? details : error?.message ?? parsed).slice(0, 3e3)}`);
|
|
10701
|
+
}
|
|
10702
|
+
throw new Error(`${contexte} : ${typeof parsed === "string" ? parsed.slice(0, 300) : JSON.stringify(parsed).slice(0, 300)}`);
|
|
9241
10703
|
}
|
|
9242
10704
|
return parsed;
|
|
9243
10705
|
}
|
|
9244
|
-
function registerMetaWrites(
|
|
9245
|
-
|
|
10706
|
+
function registerMetaWrites(c, config) {
|
|
10707
|
+
registerMetaExtendedWrites(c, config);
|
|
10708
|
+
const destination = c;
|
|
10709
|
+
c = { tool(n, d, s, h) {
|
|
10710
|
+
destination.tool(n, d, s, async (raw) => {
|
|
10711
|
+
try {
|
|
10712
|
+
const a = z6.object(s).strict().parse(raw);
|
|
10713
|
+
if (a.confirm) await verifyMetaWriteScope(config, a);
|
|
10714
|
+
return await h(a);
|
|
10715
|
+
} catch (e) {
|
|
10716
|
+
return ko(e.message);
|
|
10717
|
+
}
|
|
10718
|
+
});
|
|
10719
|
+
} };
|
|
10720
|
+
const version = config.apiVersion || "v26.0";
|
|
9246
10721
|
const graph = (path) => `https://graph.facebook.com/${version}/${path}`;
|
|
9247
|
-
const
|
|
9248
|
-
|
|
9249
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
9250
|
-
body: new URLSearchParams({ ...fields, access_token: config.accessToken })
|
|
9251
|
-
});
|
|
9252
|
-
const statusTool = (name, target, param) => server.tool(
|
|
9253
|
-
name,
|
|
10722
|
+
const statusTool = (nom, target, param) => c.tool(
|
|
10723
|
+
nom,
|
|
9254
10724
|
`Pause or reactivate a Meta ${target}. Previews by default: without confirm: true, the tool describes the change without applying it.`,
|
|
9255
10725
|
{
|
|
9256
|
-
[param]:
|
|
9257
|
-
adAccountId:
|
|
9258
|
-
status:
|
|
10726
|
+
[param]: z6.string().describe(`${target} ID.`),
|
|
10727
|
+
adAccountId: z6.string().describe("Owning ad account (act_\u2026 or bare ID)."),
|
|
10728
|
+
status: z6.enum(["ACTIVE", "PAUSED"]).describe("ACTIVE reactivates, PAUSED stops delivery."),
|
|
9259
10729
|
confirm: confirmSchema
|
|
9260
10730
|
},
|
|
9261
10731
|
async (a) => {
|
|
9262
|
-
const
|
|
10732
|
+
const id2 = String(a[param]);
|
|
9263
10733
|
const { status, confirm } = a;
|
|
9264
|
-
if (!confirm) return preview(
|
|
9265
|
-
const result = await request(
|
|
9266
|
-
|
|
10734
|
+
if (!confirm) return preview(nom, { target: id2, newStatus: status });
|
|
10735
|
+
const result = await request(
|
|
10736
|
+
graph(id2),
|
|
10737
|
+
{
|
|
10738
|
+
method: "POST",
|
|
10739
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
10740
|
+
body: new URLSearchParams({ status: String(status), access_token: config.accessToken })
|
|
10741
|
+
},
|
|
10742
|
+
`Meta ${target} status`
|
|
10743
|
+
);
|
|
10744
|
+
return ok2({ applied: true, action: nom, result });
|
|
9267
10745
|
}
|
|
9268
10746
|
);
|
|
9269
10747
|
statusTool("meta_update_campaign_status", "campaign", "campaignId");
|
|
9270
10748
|
statusTool("meta_update_adset_status", "ad set", "adSetId");
|
|
9271
10749
|
statusTool("meta_update_ad_status", "ad", "adId");
|
|
9272
|
-
const renameTool = (
|
|
9273
|
-
|
|
10750
|
+
const renameTool = (nom, target, param) => c.tool(
|
|
10751
|
+
nom,
|
|
9274
10752
|
`Rename a Meta ${target}. The name is the only thing that changes. Previews by default.`,
|
|
9275
10753
|
{
|
|
9276
|
-
[param]:
|
|
9277
|
-
adAccountId:
|
|
9278
|
-
name:
|
|
10754
|
+
[param]: z6.string().describe(`${target} ID.`),
|
|
10755
|
+
adAccountId: z6.string().describe("Owning ad account (act_\u2026 or bare ID)."),
|
|
10756
|
+
name: z6.string().min(1).max(400).describe("New name. This is the only thing the call changes."),
|
|
9279
10757
|
confirm: confirmSchema
|
|
9280
10758
|
},
|
|
9281
10759
|
async (a) => {
|
|
9282
|
-
const
|
|
9283
|
-
const { name:
|
|
9284
|
-
if (!confirm) return preview(
|
|
9285
|
-
const result = await request(
|
|
9286
|
-
|
|
10760
|
+
const id2 = String(a[param]);
|
|
10761
|
+
const { name: name2, confirm } = a;
|
|
10762
|
+
if (!confirm) return preview(nom, { target: id2, newName: name2 });
|
|
10763
|
+
const result = await request(
|
|
10764
|
+
graph(id2),
|
|
10765
|
+
{
|
|
10766
|
+
method: "POST",
|
|
10767
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
10768
|
+
body: new URLSearchParams({ name: String(name2), access_token: config.accessToken })
|
|
10769
|
+
},
|
|
10770
|
+
`Meta ${target} rename`
|
|
10771
|
+
);
|
|
10772
|
+
return ok2({ applied: true, action: nom, result });
|
|
9287
10773
|
}
|
|
9288
10774
|
);
|
|
9289
10775
|
renameTool("meta_rename_campaign", "campaign", "campaignId");
|
|
9290
10776
|
renameTool("meta_rename_adset", "ad set", "adSetId");
|
|
9291
10777
|
renameTool("meta_rename_ad", "ad", "adId");
|
|
9292
|
-
server.tool(
|
|
9293
|
-
"meta_create_campaign",
|
|
9294
|
-
"Create a Meta campaign. It is always created PAUSED and there is no option to create it active. Previews by default.",
|
|
9295
|
-
{
|
|
9296
|
-
adAccountId: z4.string().describe("Ad account, act_\u2026 or bare ID."),
|
|
9297
|
-
name: z4.string().min(1).max(400).describe("Campaign name."),
|
|
9298
|
-
objective: z4.enum([
|
|
9299
|
-
"OUTCOME_TRAFFIC",
|
|
9300
|
-
"OUTCOME_SALES",
|
|
9301
|
-
"OUTCOME_LEADS",
|
|
9302
|
-
"OUTCOME_AWARENESS",
|
|
9303
|
-
"OUTCOME_ENGAGEMENT",
|
|
9304
|
-
"OUTCOME_APP_PROMOTION"
|
|
9305
|
-
]).describe("Campaign objective."),
|
|
9306
|
-
specialAdCategories: z4.array(z4.enum(["NONE", "HOUSING", "EMPLOYMENT", "CREDIT", "ISSUES_ELECTIONS_POLITICS"])).optional().describe("Required by Meta. Defaults to none."),
|
|
9307
|
-
confirm: confirmSchema
|
|
9308
|
-
},
|
|
9309
|
-
async (a) => {
|
|
9310
|
-
const { adAccountId, name, objective, specialAdCategories, confirm } = a;
|
|
9311
|
-
const act = String(adAccountId).startsWith("act_") ? String(adAccountId) : `act_${adAccountId}`;
|
|
9312
|
-
const categories = Array.isArray(specialAdCategories) ? specialAdCategories : [];
|
|
9313
|
-
if (!confirm) {
|
|
9314
|
-
return preview("meta_create_campaign", {
|
|
9315
|
-
adAccount: act,
|
|
9316
|
-
name,
|
|
9317
|
-
objective,
|
|
9318
|
-
specialAdCategories: categories,
|
|
9319
|
-
status: "PAUSED"
|
|
9320
|
-
});
|
|
9321
|
-
}
|
|
9322
|
-
const result = await request(
|
|
9323
|
-
graph(`${act}/campaigns`),
|
|
9324
|
-
form({
|
|
9325
|
-
name: String(name),
|
|
9326
|
-
objective: String(objective),
|
|
9327
|
-
status: "PAUSED",
|
|
9328
|
-
special_ad_categories: JSON.stringify(categories)
|
|
9329
|
-
}),
|
|
9330
|
-
"Meta campaign creation"
|
|
9331
|
-
);
|
|
9332
|
-
return ok2({ applied: true, action: "meta_create_campaign", status: "PAUSED", result });
|
|
9333
|
-
}
|
|
9334
|
-
);
|
|
9335
|
-
server.tool(
|
|
9336
|
-
"meta_update_adset_budget",
|
|
9337
|
-
"Change the daily or lifetime budget of a Meta ad set. Amount in the account currency (12.50 for 12.50 EUR). Meta expects minor units, the conversion is done here. Previews by default.",
|
|
9338
|
-
{
|
|
9339
|
-
adSetId: z4.string().describe("Ad set ID."),
|
|
9340
|
-
adAccountId: z4.string().describe("Owning ad account, with or without the act_ prefix."),
|
|
9341
|
-
currency: z4.string().length(3).describe("Account currency code, for example EUR. Read it with meta_list_ad_accounts."),
|
|
9342
|
-
dailyBudget: z4.number().positive().optional().describe("Daily budget, in the account currency."),
|
|
9343
|
-
lifetimeBudget: z4.number().positive().optional().describe("Lifetime budget, mutually exclusive with the daily budget."),
|
|
9344
|
-
confirm: confirmSchema
|
|
9345
|
-
},
|
|
9346
|
-
async (a) => {
|
|
9347
|
-
const { adSetId, currency, dailyBudget, lifetimeBudget, confirm } = a;
|
|
9348
|
-
if (dailyBudget === void 0 && lifetimeBudget === void 0) {
|
|
9349
|
-
return ko("Provide either dailyBudget or lifetimeBudget.");
|
|
9350
|
-
}
|
|
9351
|
-
if (dailyBudget !== void 0 && lifetimeBudget !== void 0) {
|
|
9352
|
-
return ko("dailyBudget and lifetimeBudget are mutually exclusive; Meta rejects both together.");
|
|
9353
|
-
}
|
|
9354
|
-
const field = dailyBudget !== void 0 ? "daily_budget" : "lifetime_budget";
|
|
9355
|
-
const amount = Number(dailyBudget ?? lifetimeBudget);
|
|
9356
|
-
let minor;
|
|
9357
|
-
try {
|
|
9358
|
-
minor = toMinorUnits(amount, String(currency));
|
|
9359
|
-
} catch (error) {
|
|
9360
|
-
return ko(error instanceof Error ? error.message : String(error));
|
|
9361
|
-
}
|
|
9362
|
-
if (!confirm) {
|
|
9363
|
-
return preview("meta_update_adset_budget", {
|
|
9364
|
-
adSet: adSetId,
|
|
9365
|
-
field,
|
|
9366
|
-
amount,
|
|
9367
|
-
currency,
|
|
9368
|
-
inMinorUnits: minor
|
|
9369
|
-
});
|
|
9370
|
-
}
|
|
9371
|
-
const result = await request(graph(String(adSetId)), form({ [field]: String(minor) }), "Meta ad set budget");
|
|
9372
|
-
return ok2({ applied: true, action: "meta_update_adset_budget", result });
|
|
9373
|
-
}
|
|
9374
|
-
);
|
|
9375
|
-
server.tool(
|
|
9376
|
-
"meta_update_campaign_budget",
|
|
9377
|
-
"Change the budget of a Meta campaign that holds its budget at campaign level (Advantage campaign budget). On a campaign without one, Meta refuses and the ad set budget is the one to change. Amount in the account currency. Previews by default.",
|
|
9378
|
-
{
|
|
9379
|
-
campaignId: z4.string().describe("Campaign ID."),
|
|
9380
|
-
adAccountId: z4.string().describe("Owning ad account (act_\u2026 or bare ID)."),
|
|
9381
|
-
currency: z4.string().length(3).describe("Account currency code, for example EUR. Read it with meta_list_ad_accounts."),
|
|
9382
|
-
dailyBudget: z4.number().positive().optional().describe("New daily budget, in the account currency."),
|
|
9383
|
-
lifetimeBudget: z4.number().positive().optional().describe("New lifetime budget, in the account currency."),
|
|
9384
|
-
confirm: confirmSchema
|
|
9385
|
-
},
|
|
9386
|
-
async (a) => {
|
|
9387
|
-
const { campaignId, currency, dailyBudget, lifetimeBudget, confirm } = a;
|
|
9388
|
-
if (dailyBudget === void 0 && lifetimeBudget === void 0) {
|
|
9389
|
-
return ko("Provide either dailyBudget or lifetimeBudget.");
|
|
9390
|
-
}
|
|
9391
|
-
if (dailyBudget !== void 0 && lifetimeBudget !== void 0) {
|
|
9392
|
-
return ko("Provide only one of dailyBudget or lifetimeBudget; Meta holds one or the other.");
|
|
9393
|
-
}
|
|
9394
|
-
const field = dailyBudget !== void 0 ? "daily_budget" : "lifetime_budget";
|
|
9395
|
-
const amount = Number(dailyBudget ?? lifetimeBudget);
|
|
9396
|
-
let minor;
|
|
9397
|
-
try {
|
|
9398
|
-
minor = toMinorUnits(amount, String(currency));
|
|
9399
|
-
} catch (error) {
|
|
9400
|
-
return ko(error instanceof Error ? error.message : String(error));
|
|
9401
|
-
}
|
|
9402
|
-
if (!confirm) {
|
|
9403
|
-
return preview("meta_update_campaign_budget", {
|
|
9404
|
-
campaign: campaignId,
|
|
9405
|
-
field,
|
|
9406
|
-
amount,
|
|
9407
|
-
currency,
|
|
9408
|
-
inMinorUnits: minor
|
|
9409
|
-
});
|
|
9410
|
-
}
|
|
9411
|
-
const result = await request(graph(String(campaignId)), form({ [field]: String(minor) }), "Meta campaign budget");
|
|
9412
|
-
return ok2({ applied: true, action: "meta_update_campaign_budget", result });
|
|
9413
|
-
}
|
|
9414
|
-
);
|
|
9415
|
-
server.tool(
|
|
9416
|
-
"meta_update_adset_schedule",
|
|
9417
|
-
"Change the start or end time of a Meta ad set. Times are ISO 8601 with an offset, for example 2026-09-01T00:00:00+0200. Previews by default.",
|
|
9418
|
-
{
|
|
9419
|
-
adSetId: z4.string().describe("Ad set ID."),
|
|
9420
|
-
adAccountId: z4.string().describe("Owning ad account (act_\u2026 or bare ID)."),
|
|
9421
|
-
startTime: z4.string().optional().describe("Start time, ISO 8601 with an offset, for example 2026-09-01T00:00:00+0200."),
|
|
9422
|
-
endTime: z4.string().optional().describe("End time, ISO 8601 with an offset, for example 2026-09-30T23:59:59+0200."),
|
|
9423
|
-
confirm: confirmSchema
|
|
9424
|
-
},
|
|
9425
|
-
async (a) => {
|
|
9426
|
-
const { adSetId, startTime, endTime, confirm } = a;
|
|
9427
|
-
if (!startTime && !endTime) return ko("Provide startTime, endTime, or both.");
|
|
9428
|
-
const fields = {};
|
|
9429
|
-
if (startTime) fields.start_time = String(startTime);
|
|
9430
|
-
if (endTime) fields.end_time = String(endTime);
|
|
9431
|
-
if (!confirm) return preview("meta_update_adset_schedule", { adSet: adSetId, startTime, endTime });
|
|
9432
|
-
const result = await request(graph(String(adSetId)), form(fields), "Meta ad set schedule");
|
|
9433
|
-
return ok2({ applied: true, action: "meta_update_adset_schedule", result });
|
|
9434
|
-
}
|
|
9435
|
-
);
|
|
9436
10778
|
}
|
|
9437
10779
|
|
|
9438
10780
|
// src/platforms/meta/index.ts
|
|
9439
10781
|
function registerMeta(server, config) {
|
|
9440
10782
|
registerMetaTools(server, config);
|
|
10783
|
+
registerMetaExtendedWrites(server, config, true);
|
|
9441
10784
|
registerMetaResources(server, config.enableWrites ?? false);
|
|
9442
|
-
logger.info("meta", "Registered
|
|
10785
|
+
logger.info("meta", "Registered 41 read tools and 7 resources");
|
|
9443
10786
|
if (config.enableWrites) {
|
|
9444
10787
|
registerMetaWrites(server, config);
|
|
9445
|
-
logger.info("meta", "Registered
|
|
10788
|
+
logger.info("meta", "Registered 23 write tools (every one previews before it applies)");
|
|
9446
10789
|
}
|
|
9447
10790
|
}
|
|
9448
10791
|
|
|
9449
10792
|
// src/server.ts
|
|
9450
|
-
var PACKAGE_VERSION = "
|
|
10793
|
+
var PACKAGE_VERSION = "2.0.0";
|
|
9451
10794
|
function createServer(config) {
|
|
9452
10795
|
const server = new McpServer(
|
|
9453
10796
|
{
|
|
9454
10797
|
name: "meta-ads-mcp",
|
|
9455
|
-
version: PACKAGE_VERSION
|
|
10798
|
+
version: PACKAGE_VERSION,
|
|
10799
|
+
title: "Meta Ads",
|
|
10800
|
+
websiteUrl: "https://www.getmcpads.com/tools/meta-ads",
|
|
10801
|
+
icons: [{ src: "https://mcp.getmcpads.com/icon.svg", mimeType: "image/svg+xml" }]
|
|
9456
10802
|
},
|
|
9457
10803
|
{
|
|
9458
10804
|
capabilities: {
|
|
@@ -9461,6 +10807,7 @@ function createServer(config) {
|
|
|
9461
10807
|
}
|
|
9462
10808
|
}
|
|
9463
10809
|
);
|
|
10810
|
+
installToolQuality(server);
|
|
9464
10811
|
if (config.meta) {
|
|
9465
10812
|
registerMeta(server, config.meta);
|
|
9466
10813
|
logger.system(
|