@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/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/tools.ts
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 record = this.records.get(key);
172
- if (!record) {
173
- record = { timestamps: [] };
174
- this.records.set(key, record);
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
- record.timestamps = record.timestamps.filter((t) => now - t < 6e4);
178
- const lastSecond = record.timestamps.filter((t) => now - t < 1e3);
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 (record.timestamps.length >= this.config.maxPerMinute) {
185
- const waitMs = 6e4 - (now - record.timestamps[0]) + 100;
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
- record.timestamps.push(Date.now());
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
- function assertSafeMetaGraphUrl(rawUrl, apiVersion) {
235
- let url;
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
- url = new URL(rawUrl);
1038
+ parsed = new URL(rawUrl);
238
1039
  } catch {
239
- throw new Error("Meta Graph URL must be an absolute HTTPS URL.");
1040
+ throw new Error("Meta Graph URL is invalid.");
240
1041
  }
241
- if (url.protocol !== "https:" || url.hostname !== "graph.facebook.com" || url.port !== "" || url.username !== "" || url.password !== "") {
242
- throw new Error("Meta Graph reads are restricted to https://graph.facebook.com.");
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
- const versionPrefix = `/${apiVersion.replace(/^\/+|\/+$/g, "")}/`;
245
- if (!url.pathname.startsWith(versionPrefix) && url.pathname !== versionPrefix.slice(0, -1)) {
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 obj = value;
302
- if ("name" in obj && typeof obj.name === "string") {
303
- normalized.ad_format_asset = obj.name;
304
- } else if ("id" in obj) {
305
- normalized.ad_format_asset = String(obj.id);
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 obj = value;
313
- if ("name" in obj && typeof obj.name === "string") {
314
- normalized.media_type = obj.name;
315
- } else if ("value" in obj && typeof obj.value === "string") {
316
- normalized.media_type = obj.value;
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 obj = value;
324
- if ("name" in obj && typeof obj.name === "string") {
325
- normalized.media_format = obj.name;
326
- } else if ("value" in obj && typeof obj.value === "string") {
327
- normalized.media_format = obj.value;
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
- const safeUrl = assertSafeMetaGraphUrl(url, this.apiVersion);
367
- safeUrl.searchParams.set("access_token", this.accessToken);
368
- logger.debug("meta", `fetchUrl: ${safeUrl.origin}${safeUrl.pathname}`);
369
- const response = await fetch(safeUrl, { redirect: "error" });
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.push(...paginatedData.data);
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 accountId = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
461
- return this.request(`/${accountId}`, {
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 accountId = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
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
- const windows = apiRequest.attributionWindows;
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
- `/${accountId}/insights`,
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.push(...data.data.map(normalizeInsightRow));
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 rows = await this.fetchInsights(adAccountId, request2);
540
- results.push(rows);
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.push(...data.data);
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 accountId = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
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
- while (nextUrl) {
679
- const data = await this.fetchPaginatedUrl(nextUrl);
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(`/${accountId}/ad_studies`);
1521
+ await fetchFromEdge(`/${accountId2}/ad_studies`);
698
1522
  try {
699
- const accountInfo = await this.request(`/${accountId}`, { fields: "business{id}" });
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
- if (pages.length > 1e3) break;
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(accountId) {
812
- const formattedId = accountId.startsWith("act_") ? accountId : `act_${accountId}`;
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(accountId, entityType, options = {}) {
834
- const formattedId = accountId.startsWith("act_") ? accountId : `act_${accountId}`;
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 || 50;
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 safeUrl = assertSafeMetaGraphUrl(url, this.apiVersion);
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 z2 } from "zod";
5812
- var graphIdSchema = z2.string().trim().min(1).max(200).regex(/^[A-Za-z0-9_.:-]+$/, "Use a Graph node ID without slashes or query parameters");
5813
- var graphFieldSchema = z2.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Graph field name");
5814
- var graphDimensionSchema = z2.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Insights field or breakdown name");
5815
- var dateSchema = z2.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
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 = z2.enum(META_BROAD_READ_EDGES);
5866
- var filterValueSchema = z2.union([
5867
- z2.string().max(2e3),
5868
- z2.number().finite(),
5869
- z2.boolean(),
5870
- z2.array(z2.union([z2.string().max(500), z2.number().finite(), z2.boolean()])).max(500)
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 = z2.object({
5873
- field: z2.string().trim().min(1).max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*$/, "Invalid filtering field"),
5874
- operator: z2.enum([
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 = z2.enum([
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 = z2.enum([
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: z2.array(graphFieldSchema).min(1).max(100).describe("Flat Graph field names; nested field expansion is intentionally disabled"),
5996
- includeMetadata: z2.boolean().optional().default(false).describe("Ask Graph for field metadata when supported")
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: z2.array(graphFieldSchema).min(1).max(100).optional(),
6018
- filtering: z2.array(graphFilterSchema).max(20).optional(),
6019
- parameters: z2.record(z2.unknown()).optional().describe("Additional documented GET parameters, for example targeting_spec or optimization_goal; auth and HTTP method override parameters are blocked"),
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: z2.number().int().min(1).max(500).optional().default(100),
6023
- after: z2.string().max(2e3).optional(),
6024
- before: z2.string().max(2e3).optional(),
6025
- includeSummary: z2.boolean().optional().default(false)
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: z2.array(graphDimensionSchema).min(1).max(100).describe("Native Meta Insights API fields; calculated aliases are not accepted here"),
6055
- level: z2.enum(["account", "campaign", "adset", "ad"]).optional(),
6056
- breakdowns: z2.array(graphDimensionSchema).max(20).optional(),
6057
- actionBreakdowns: z2.array(graphDimensionSchema).max(20).optional(),
6058
- actionAttributionWindows: z2.array(z2.enum(["1d_click", "7d_click", "28d_click", "1d_view", "1d_ev", "dda", "skan_click", "skan_view"])).max(8).optional(),
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: z2.object({ since: dateSchema, until: dateSchema }).optional(),
6061
- timeIncrement: z2.union([z2.number().int().min(1).max(90), z2.enum(["monthly", "all_days"])]).optional(),
6062
- filtering: z2.array(graphFilterSchema).max(20).optional(),
6063
- sort: z2.string().trim().max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*_(ascending|descending)$/).optional(),
6064
- limit: z2.number().int().min(1).max(500).optional().default(100),
6065
- after: z2.string().max(2e3).optional(),
6066
- includeSummary: z2.boolean().optional().default(false)
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: z2.string().trim().min(1).max(200).optional().describe("Search text; required by most interest and geography searches"),
6104
- countryCode: z2.string().trim().length(2).transform((value) => value.toUpperCase()).optional(),
6105
- locationTypes: z2.array(z2.enum(["country", "country_group", "region", "city", "zip", "geo_market", "electoral_district"])).max(7).optional(),
6106
- limit: z2.number().int().min(1).max(1e3).optional().default(100)
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: z2.string().trim().min(1).max(100).regex(/^[A-Z][A-Z0-9_]*$/).optional().default("DESKTOP_FEED_STANDARD")
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 = z3.string().describe("Ad account ID (e.g., act_123456789)");
6147
- var levelSchema = z3.enum(["account", "campaign", "adset", "ad"]);
6148
- var statusFilterSchema = z3.enum(["ACTIVE", "PAUSED", "DELETED", "ARCHIVED"]).optional().describe("Filter by entity status");
6149
- var limitSchema = z3.number().int().min(1).max(500).optional().default(100);
6150
- var cursorSchema = z3.string().optional().describe("Pagination cursor from previous response");
6151
- var productBreakdownSchema = z3.enum([
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 = z3.enum([
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 = z3.object({
6186
- since: z3.string().describe("Start date YYYY-MM-DD"),
6187
- until: z3.string().describe("End date YYYY-MM-DD")
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 id = typeof item.id === "string" ? item.id : void 0;
6340
- if (!id) continue;
6341
- target.set(id, { ...target.get(id) ?? {}, ...item });
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: z3.string().optional().describe("Business Manager ID. If omitted, the tool lists /me/businesses and uses ad account business metadata when available."),
6891
- adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID to discover account-level pixels/datasets and related business."),
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, adAccountId, limit, cursor }) => {
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 instagramById = /* @__PURE__ */ new Map();
6901
- const pixelsById = /* @__PURE__ */ new Map();
6902
- const datasetsById = /* @__PURE__ */ new Map();
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
- businessIds.add(businessId);
6913
- const business = await fetchGraph(
8205
+ const ownedPages = await fetchGraphWithFallback(
6914
8206
  client,
6915
- "business",
6916
- graphUrl(client, `/${businessId}`, { fields: businessFields }),
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 or provide a Business ID the token can read."
8211
+ "Grant business_management plus pages_show_list/pages_read_engagement to read Business-owned Pages."
6919
8212
  );
6920
- if (business) businessesById.set(String(business.id ?? businessId), business);
6921
- } else {
6922
- const businesses = await fetchGraph(
8213
+ const clientPages = await fetchGraphWithFallback(
6923
8214
  client,
6924
- "businesses",
6925
- graphUrl(client, "/me/businesses", { fields: businessFields, limit, after: cursor }),
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
- "Grant business_management to list Business Manager assets. You can still provide businessId or adAccountId directly."
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: z3.string().optional().describe("Optional Business Manager ID for owned/client Instagram account edges."),
7171
- pageId: z3.string().optional().describe("Optional Page ID to read linked instagram_business_account/connected_instagram_account."),
7172
- limit: z3.number().int().min(1).max(200).optional().default(100),
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: z3.string().optional().describe("Business Manager ID for owned/client pixels and datasets."),
7277
- includeDatasets: z3.boolean().optional().default(true).describe("Also attempt dataset/offline conversion dataset edges."),
7278
- limit: z3.number().int().min(1).max(200).optional().default(100),
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: z3.string().optional().describe("Optional start time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
7403
- until: z3.string().optional().describe("Optional end time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
7404
- limit: z3.number().int().min(1).max(500).optional().default(100),
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: z3.enum(["campaign", "adset", "ad", "all"]).optional().default("all"),
7444
- effectiveStatusFilter: z3.array(z3.string()).optional().describe("Optional effective_status filter values such as ACTIVE, PAUSED, WITH_ISSUES, DISAPPROVED."),
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: z3.number().int().min(1).max(200).optional().default(100),
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 normalized creative asset metadata for ads or creative IDs: thumbnail, object_story_spec, asset_feed_spec, video/image/link URL, CTA, and page/IG references.",
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: z3.array(z3.string()).optional().describe("Specific ad IDs to enrich."),
7545
- creativeIds: z3.array(z3.string()).optional().describe("Specific creative IDs to enrich."),
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 (let index = 0; index < creativeIds.length; index += idChunkSize) {
7560
- const chunk = creativeIds.slice(index, index + idChunkSize);
7561
- const chunkResult = await fetchGraph(
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 > idChunkSize) {
7572
- warnings.push({
7573
- area: "creative_assets",
7574
- message: `Creative ID enrichment was chunked into batches of ${idChunkSize} to avoid oversized Graph API responses.`,
7575
- suggestion: "Prefer passing targeted creativeIds/adIds from insights for complete creative-to-site coverage."
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, "", { ids: chunk.join(","), fields: adFields }),
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
- Object.assign(merged, chunkResult ?? {});
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((record) => normalizeCreativeAsset(record, sourceType));
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: z3.array(z3.string()).optional().describe("Specific audience IDs to fetch via batch IDs lookup."),
7644
- type: z3.enum(["all", "custom", "saved", "lookalike"]).optional().default("all"),
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: z3.string().optional().describe("Business Manager ID used to discover owned/client product catalogs."),
7780
- catalogId: z3.string().optional().describe("Product Catalog ID to read directly."),
7781
- productIds: z3.array(z3.string()).max(100).optional().describe("Specific Product Item IDs to fetch via the batch IDs endpoint."),
7782
- search: z3.string().optional().describe("Client-side substring filter across product id, retailer_id, name, brand, category, type, and custom labels."),
7783
- includeProducts: z3.boolean().optional().default(true).describe("Fetch product items for discovered/provided catalogs."),
7784
- includeProductSets: z3.boolean().optional().default(false).describe("Also attempt product_sets edges for catalog context."),
7785
- limit: z3.number().int().min(1).max(500).optional().default(100),
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 id of catalogIds) {
9017
+ for (const id2 of catalogIds) {
7894
9018
  const products = await fetchGraphWithFallback(
7895
9019
  client,
7896
- `catalog:${id}:products`,
7897
- graphUrl(client, `/${id}/products`, { fields: productFields, limit, after: cursor }),
7898
- graphUrl(client, `/${id}/products`, { fields: productFallbackFields, limit, after: cursor }),
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:${id}:products`)).filter((product) => productSearchMatches(product, search));
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:${id}:products`] = pagingInfo(products);
9028
+ paging[`catalog:${id2}:products`] = pagingInfo(products);
7905
9029
  }
7906
9030
  }
7907
9031
  if (includeProductSets) {
7908
- for (const id of catalogIds) {
9032
+ for (const id2 of catalogIds) {
7909
9033
  const productSets = await fetchGraph(
7910
9034
  client,
7911
- `catalog:${id}:product_sets`,
7912
- graphUrl(client, `/${id}/product_sets`, { fields: productSetFields, limit, after: cursor }),
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:${id}:product_sets`] = pagingInfo(productSets);
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: z3.string().optional().describe("Product Catalog ID used to load product metadata for joins."),
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: z3.array(z3.string()).min(1).optional().default(["impressions", "clicks", "spend", "actions", "action_values"]),
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: z3.number().int().min(1).max(1e3).optional().default(500),
7955
- catalogProductLimit: z3.number().int().min(1).max(1e3).optional().default(500)
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: z3.string().optional().describe("Optional Business Manager ID for block-list discovery."),
8069
- adsetIds: z3.array(z3.string()).max(100).optional().describe("Specific ad set IDs to inspect. If omitted, reads ad sets from the ad account."),
8070
- includeAdsets: z3.boolean().optional().default(true),
8071
- includeBlockLists: z3.boolean().optional().default(true),
8072
- includeRawTargeting: z3.boolean().optional().default(false),
8073
- limit: z3.number().int().min(1).max(500).optional().default(100),
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: z3.string().optional().describe("Specific Ad Study ID to interpret."),
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: z3.boolean().optional().default(false).describe("Also read campaigns/adsets/adaccounts attached to each study cell."),
8196
- cellEntityType: z3.enum(["campaigns", "adsets", "adaccounts"]).optional().default("campaigns"),
8197
- limit: z3.number().int().min(1).max(100).optional().default(50)
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 id = typeof study.id === "string" ? study.id : void 0;
8283
- if (!id) continue;
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:${id}:cells`,
8288
- graphUrl(client, `/${id}/cells`, { fields: cellFields, limit }),
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:${id}:objectives`,
8295
- graphUrl(client, `/${id}/objectives`, { fields: objectiveFields, limit }),
8296
- graphUrl(client, `/${id}/objectives`, { fields: objectiveFallbackFields, limit }),
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
- "Enrich Facebook Page posts and Instagram organic media with media URLs, permalinks, counts, attachments, and optional read-only insights.",
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: z3.string().optional().describe("Facebook Page ID for /posts and linked IG discovery."),
8347
- instagramAccountId: z3.string().optional().describe("Instagram professional account ID for /media."),
8348
- postIds: z3.array(z3.string()).max(50).optional().describe("Specific Facebook post IDs to enrich."),
8349
- mediaIds: z3.array(z3.string()).max(50).optional().describe("Specific Instagram media IDs to enrich."),
8350
- includePagePosts: z3.boolean().optional().default(true),
8351
- includeInstagramMedia: z3.boolean().optional().default(true),
8352
- includeInsights: z3.boolean().optional().default(false),
8353
- limit: z3.number().int().min(1).max(100).optional().default(25),
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: z3.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text."),
8356
- until: z3.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text.")
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 = includeInsights ? "id,message,created_time,type,permalink_url,full_picture,attachments{media,type,url,target,title,description},shares,likes.summary(true),comments.summary(true),insights.metric(post_impressions,post_engaged_users,post_clicks)" : "id,message,created_time,type,permalink_url,full_picture,attachments{media,type,url,target,title,description},shares,likes.summary(true),comments.summary(true)";
8362
- const pagePostFallbackFields = "id,message,created_time,type,permalink_url,full_picture,shares,likes.summary(true),comments.summary(true)";
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 result = await fetchGraphWithFallback(
8376
- client,
8377
- "page_post_ids",
8378
- graphUrl(client, "", { ids: postIds.join(","), fields: pagePostFields }),
8379
- graphUrl(client, "", { ids: postIds.join(","), fields: pagePostFallbackFields }),
8380
- warnings,
8381
- "Post insights or attachments may require pages_read_engagement or a Page token; minimal post metadata is returned when rich fields fail."
8382
- );
8383
- pagePosts.push(...Object.values(result ?? {}).filter(isRecord).map(normalizeOrganicPost));
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 result = await fetchGraphWithFallback(
8386
- client,
8387
- "page_posts_enrichment",
8388
- graphUrl(client, `/${pageId}/posts`, {
8389
- fields: pagePostFields,
8390
- limit,
8391
- after: cursor,
8392
- since,
8393
- until
8394
- }),
8395
- graphUrl(client, `/${pageId}/posts`, {
8396
- fields: pagePostFallbackFields,
8397
- limit,
8398
- after: cursor,
8399
- since,
8400
- until
8401
- }),
8402
- warnings,
8403
- "Post insights or attachment fields may require pages_read_engagement and Page access. Minimal post fields are returned when rich fields fail."
8404
- );
8405
- pagePosts.push(...dataArray(result).map(normalizeOrganicPost));
8406
- paging.page_posts = pagingInfo(result);
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 insights = await fetchGraph(
8453
- client,
8454
- `instagram_media:${mediaId}:insights`,
8455
- graphUrl(client, `/${mediaId}/insights`, {
8456
- metric: "impressions,reach,engagement,saved,video_views,plays,total_interactions"
8457
- }),
8458
- warnings,
8459
- "IG media insight metrics vary by media type and API version. Basic media metadata remains available when insights fail."
8460
- );
8461
- media.insights = insights;
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 only reads existing organic content; it never publishes, edits, hides, or deletes posts."
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: z3.string().optional().describe("Filter by campaign ID"),
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: z3.string().optional().describe("Filter by ad set ID"),
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: z3.array(z3.string()).min(1).describe("Metric keys from meta://metrics (e.g., impressions, spend, ctr)"),
8587
- breakdowns: z3.array(z3.string()).optional().describe("Breakdown keys from meta://breakdowns (e.g., age, gender, country)"),
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: z3.union([z3.literal(1), z3.literal(7), z3.literal(14), z3.literal(28), z3.literal(30), z3.string()]).optional().describe("Time granularity: 1 (daily), 7 (weekly), 'monthly', or 'all_days'"),
8591
- limit: z3.number().int().min(1).max(5e3).optional().default(500)
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: z3.string().optional().describe("Get structure for a specific campaign only")
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: z3.array(z3.string()).optional().describe("Specific ad IDs to get creatives for"),
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.join(",");
8686
- url = `https://graph.facebook.com/${client.apiVersion}/?ids=${ids}&fields=${fields}`;
8687
- } else {
8688
- url = `https://graph.facebook.com/${client.apiVersion}/${adAccountId}/ads?fields=${fields}&limit=${limit}`;
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(url);
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: z3.enum(["custom", "saved", "lookalike"]).optional().describe("Filter by audience 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: z3.string().describe("The Ad Study ID") },
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: z3.array(z3.string()).min(1).describe("Metric keys to validate"),
8754
- breakdowns: z3.array(z3.string()).optional().describe("Breakdown keys to validate"),
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
- "Get recent posts from a connected Facebook/Instagram page. Requires page access.",
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: z3.string().describe("Facebook Page ID"),
8771
- limit: z3.number().int().min(1).max(100).optional().default(25)
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
- try {
8775
- const fields = "id,message,created_time,type,permalink_url,full_picture,shares,likes.summary(true),comments.summary(true)";
8776
- const url = `https://graph.facebook.com/${client.apiVersion}/${pageId}/posts?fields=${fields}&limit=${limit}`;
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: z3.enum(["campaigns", "adsets", "ads"]).describe("Type of entity to search"),
8803
- nameFilter: z3.string().describe("Name substring to search for"),
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
- filters.push({ field: "effective_status", operator: "IN", value: statusFilter });
8818
- }
8819
- const url = `https://graph.facebook.com/${client.apiVersion}/${adAccountId}/${entityType}?fields=${fields}&limit=${limit}&filtering=${encodeURIComponent(JSON.stringify(filters))}`;
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. This server does not create, update, or delete catalog assets."
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
- "No write or mutation endpoints are registered by this server."
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 z4 } from "zod";
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 = z4.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
9200
- var ZERO_DECIMAL = /* @__PURE__ */ new Set([
9201
- "JPY",
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
- const detail = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
9240
- throw new Error(`${context}: ${detail.slice(0, 300)}`);
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(server, config) {
9245
- const version = config.apiVersion || DEFAULT_API_VERSION;
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 form = (fields) => ({
9248
- method: "POST",
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]: z4.string().describe(`${target} ID.`),
9257
- adAccountId: z4.string().describe("Owning ad account (act_\u2026 or bare ID)."),
9258
- status: z4.enum(["ACTIVE", "PAUSED"]).describe("ACTIVE reactivates, PAUSED stops delivery."),
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 id = String(a[param]);
10732
+ const id2 = String(a[param]);
9263
10733
  const { status, confirm } = a;
9264
- if (!confirm) return preview(name, { target: id, newStatus: status });
9265
- const result = await request(graph(id), form({ status: String(status) }), `Meta ${target} status`);
9266
- return ok2({ applied: true, action: name, result });
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 = (name, target, param) => server.tool(
9273
- name,
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]: z4.string().describe(`${target} ID.`),
9277
- adAccountId: z4.string().describe("Owning ad account (act_\u2026 or bare ID)."),
9278
- name: z4.string().min(1).max(400).describe("New name. This is the only thing the call changes."),
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 id = String(a[param]);
9283
- const { name: newName, confirm } = a;
9284
- if (!confirm) return preview(name, { target: id, newName });
9285
- const result = await request(graph(id), form({ name: String(newName) }), `Meta ${target} rename`);
9286
- return ok2({ applied: true, action: name, result });
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 34 read tools and 7 resources");
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 10 write tools (every one previews before it applies)");
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 = "1.0.2";
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(