@getmcpads/meta-ads-mcp-server 1.0.1 → 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/index.js CHANGED
@@ -1,9 +1,813 @@
1
+ // src/parameter-descriptions.ts
2
+ var PARAMETER_DESCRIPTIONS = {
3
+ "filters": "Filters combined according to this tool's query schema. Use provider field names and the declared operators.",
4
+ "searchType": "Search surface to report, such as web, image, video, news or Discover. Availability depends on the property.",
5
+ "searchTypes": "Search surfaces to compare. Each surface is queried separately; do not sum overlapping surfaces.",
6
+ "datePreset": "Named reporting period. Use the custom date range when you need exact start and end dates.",
7
+ "dateRange": "Explicit reporting start and end dates in YYYY-MM-DD format.",
8
+ "timeRange": "Explicit provider reporting period with since and until dates.",
9
+ "startDate": "First date of the reporting period, in YYYY-MM-DD format.",
10
+ "endDate": "Last date of the reporting period, in YYYY-MM-DD format, on or after startDate.",
11
+ "since": "Beginning of the requested reporting period; use the format accepted by the provider.",
12
+ "until": "End of the requested reporting period; use the format accepted by the provider.",
13
+ "orderBy": "Field used to order report rows.",
14
+ "orderField": "Provider field used to order report rows.",
15
+ "orderDirection": "Sort direction for the selected ordering field.",
16
+ "orderType": "Sort direction for the selected ordering field.",
17
+ "sortBy": "Metric or dimension used to rank the returned results.",
18
+ "sort": "Native provider ordering expressions.",
19
+ "limit": "Maximum number of returned items or rows. The declared bounds and default apply; use pagination for additional results.",
20
+ "topN": "Maximum number of highest-ranked results to return.",
21
+ "pageSize": "Maximum items requested per page. Follow the returned pagination information for remaining results.",
22
+ "page": "Page number to request, starting at 1.",
23
+ "offset": "Zero-based number of rows to skip before returning results.",
24
+ "bookmark": "Opaque Pinterest pagination bookmark returned by the previous response. Omit on the first request.",
25
+ "after": "Opaque cursor for the next page returned by Meta.",
26
+ "before": "Opaque cursor for the previous page returned by Meta.",
27
+ "includeUnchanged": "Include entries with no observed change in the comparison.",
28
+ "minImpressions": "Minimum reported impressions required for a row to enter the analysis.",
29
+ "objectives": "Analysis goals that determine the recommended large-site sampling strategy.",
30
+ "pageLimit": "Maximum number of pages considered for the sampling plan.",
31
+ "maxInspectionUrls": "Maximum URL Inspection candidates included in the plan; inspection calls consume provider quota.",
32
+ "minImpressionsForLowCtr": "Minimum impressions before a page is considered for low-CTR analysis.",
33
+ "staleSitemapDays": "Number of days after which a sitemap's last download is considered stale.",
34
+ "continueOnError": "Continue with other URLs when one inspection fails, returning individual failures.",
35
+ "dimension": "Reporting dimension used to group and compare results.",
36
+ "maxClusters": "Maximum query clusters to return.",
37
+ "topQueriesPerCluster": "Maximum example queries returned for each cluster.",
38
+ "minPages": "Minimum distinct pages ranking for a query before it is considered a cannibalization candidate.",
39
+ "adAccountId": "Owning advertising account ID. Use the exact ID returned by account discovery; do not substitute a campaign or business ID.",
40
+ "advertiserId": "TikTok advertiser account ID as a string.",
41
+ "customerId": "Google Ads customer ID, without hyphens.",
42
+ "propertyId": "Google Analytics property ID; use the property, not the account or measurement ID.",
43
+ "level": "Entity or aggregation level for the requested report.",
44
+ "dataLevel": "TikTok reporting aggregation level. Metrics and dimensions must be compatible with this level.",
45
+ "granularity": "Time bucket used to aggregate reporting rows.",
46
+ "columns": "Native Pinterest reporting column names. Discover supported columns before selecting metrics.",
47
+ "executionMode": "Choose the supported synchronous, asynchronous or automatic report execution strategy.",
48
+ "entityIds": "Exact IDs of the entities to include in the selected reporting level.",
49
+ "targetingTypes": "Native targeting categories used to break down delivery.",
50
+ "clickWindowDays": "Click-through attribution window, in days.",
51
+ "engagementWindowDays": "Engagement-through attribution window, in days.",
52
+ "viewWindowDays": "View-through attribution window, in days.",
53
+ "conversionReportTime": "Whether conversions are assigned to the ad action date or the conversion date.",
54
+ "attributionTypes": "Provider attribution categories included in the report.",
55
+ "reportingTimezone": "Provider reporting timezone selector. Keep it consistent when comparing periods.",
56
+ "campaignIds": "Restrict results to these campaign IDs.",
57
+ "adGroupIds": "Restrict results to these ad group IDs.",
58
+ "adgroupIds": "Restrict TikTok results to these ad group IDs.",
59
+ "adIds": "Restrict results to these ad IDs.",
60
+ "campaignId": "Exact campaign ID in the selected advertising account.",
61
+ "adGroupId": "Exact ad group ID in the selected advertising account.",
62
+ "adSetId": "Exact Meta ad set ID in the selected advertising account.",
63
+ "adId": "Exact ad ID in the selected advertising account.",
64
+ "entityId": "Exact ID of the entity selected by this operation. It must belong to the selected account.",
65
+ "onlyWithPeriodDelivery": "Keep only creatives with observed delivery in the requested date range.",
66
+ "breakdown": "Native reporting breakdown used to group results.",
67
+ "breakdowns": "Native reporting breakdowns. Check compatibility before combining them.",
68
+ "productGroupIds": "Restrict reporting to these product group IDs.",
69
+ "productItemIds": "Restrict reporting to these product item IDs.",
70
+ "includeProductSamples": "Include a limited sample of product records alongside inventory diagnostics.",
71
+ "sampleProductGroups": "Maximum product groups to sample for inventory diagnostics.",
72
+ "reportName": "Provider report name identifying the requested report recipe.",
73
+ "conversionProductAttributionType": "Pinterest product-conversion attribution category.",
74
+ "conversionProductBreakdown": "Pinterest product-conversion grouping level.",
75
+ "productSkuIds": "Restrict product-conversion reporting to these SKU IDs.",
76
+ "request": "Exact request schema name to inspect before composing a write payload.",
77
+ "entity": "Entity collection or level to inspect.",
78
+ "entityType": "Provider entity type to retrieve.",
79
+ "entityStatuses": "Native status values used to filter the entity collection.",
80
+ "statusFilter": "Native status values used to filter the returned entities.",
81
+ "targetingType": "Targeting dictionary to inspect, such as interests, locations or demographics.",
82
+ "interestId": "Native interest ID for the targeting lookup.",
83
+ "mode": "Operation or analysis mode. Only the values declared in this schema are supported.",
84
+ "matchTypes": "Keyword match types included in the lookup.",
85
+ "countryCode": "Country code used to localize keyword or targeting results.",
86
+ "keywords": "Keyword phrases to analyze; use the language and market of the intended audience.",
87
+ "term": "Search term used for the keyword lookup.",
88
+ "terms": "Search terms used for the keyword lookup.",
89
+ "audienceId": "Exact ID of the audience to inspect or modify.",
90
+ "customerListId": "Exact customer-list ID. Reading metadata does not upload customer data.",
91
+ "businessId": "Exact business account ID used for the lookup.",
92
+ "accountType": "Provider account category used to filter results.",
93
+ "ownershipType": "Filter audiences by provider ownership category.",
94
+ "excludeNca": "Whether to exclude Pinterest new-customer-acquisition audiences from this lookup.",
95
+ "audienceInsightType": "Pinterest audience-insights category to request.",
96
+ "surfaces": "Conversion-setup surfaces to inspect, according to the supported values.",
97
+ "includeDeletedTags": "Include deleted conversion tags in diagnostics.",
98
+ "lookbackPeriod": "Provider lookback period used for conversion diagnostics.",
99
+ "sourcePlatform": "Filter conversion data by source platform.",
100
+ "ingestionSource": "Filter conversion events by ingestion source.",
101
+ "catalogId": "Exact product catalog ID accessible to the selected account.",
102
+ "feedId": "Exact catalog feed ID.",
103
+ "processingResultId": "Exact catalog feed processing result ID to inspect.",
104
+ "productGroupId": "Exact product group ID.",
105
+ "productSetId": "Exact Meta product set ID belonging to the selected catalog.",
106
+ "exportType": "Provider export category to request.",
107
+ "action": "Supported operation within this tool's scoped provider surface.",
108
+ "includeDetails": "Include detailed provider records in addition to summary information.",
109
+ "leadFormId": "Exact lead form ID to inspect.",
110
+ "subscriptionId": "Exact subscription ID to inspect.",
111
+ "assetId": "Exact provider asset ID.",
112
+ "memberId": "Exact business member ID used for the read lookup.",
113
+ "partnerId": "Exact business partner ID used for the read lookup.",
114
+ "pinId": "Exact Pinterest pin ID.",
115
+ "pinIds": "Exact Pinterest pin IDs to include.",
116
+ "metrics": "Native or documented calculated metric names to request. Check compatibility with the selected dimensions.",
117
+ "metric": "Metric used for the analysis; use a name from the platform metric catalogue.",
118
+ "boardId": "Exact Pinterest board ID.",
119
+ "searchTerm": "Text used to filter or search the selected collection.",
120
+ "region": "Provider region selector used to localize trends.",
121
+ "productRegion": "Region used for product trend analysis.",
122
+ "trendType": "Provider trend category to return.",
123
+ "interests": "Native interest categories used to filter trends.",
124
+ "genders": "Native gender categories used to filter aggregate trends.",
125
+ "ageBuckets": "Native age groups used to filter aggregate trends.",
126
+ "includeKeywords": "Include related keyword details in the trend response.",
127
+ "includeDemographics": "Include available aggregate demographic breakdowns.",
128
+ "productCategories": "Native product category IDs or values used to filter trends.",
129
+ "verticals": "Native business verticals used to filter trends.",
130
+ "productLookbackDays": "Product-trend lookback window, in days.",
131
+ "productEngagementType": "Engagement signal used for product-trend analysis.",
132
+ "featuredInterest": "Featured interest category for the trends lookup.",
133
+ "reportType": "Native provider report type; determines supported metrics and dimensions.",
134
+ "promotionId": "Exact Pinterest product-group promotion ID.",
135
+ "type": "Provider object or operation type selected from this schema's allowed values.",
136
+ "productBreakdown": "Native product grouping used to join catalog items with reported insights.",
137
+ "catalogProductLimit": "Maximum catalog product records read for the join.",
138
+ "includeAdsets": "Include ad set configuration when checking brand safety.",
139
+ "includeBlockLists": "Include accessible brand-safety block-list metadata.",
140
+ "includeRawTargeting": "Include the native targeting specification for diagnostics.",
141
+ "cellEntityType": "Entity type represented by experiment cells.",
142
+ "includePagePosts": "Read related Facebook Page posts when the token has permission.",
143
+ "includeInstagramMedia": "Read related Instagram media when the token has permission.",
144
+ "includeInsights": "Include available insight metrics; additional provider permissions may be required.",
145
+ "edge": "Allowlisted Meta Graph edge to read on the selected node.",
146
+ "fields": "Native provider fields to return. Select only fields supported by the chosen object or report.",
147
+ "filtering": "Native provider filter expressions; credentials and account overrides are not allowed.",
148
+ "includeSummary": "Request the provider's available summary or total-count metadata.",
149
+ "actionBreakdowns": "Meta action-level breakdowns, subject to Insights compatibility rules.",
150
+ "actionAttributionWindows": "Meta attribution windows used to attribute action metrics.",
151
+ "timeIncrement": "Provider time-bucket size or supported aggregate value.",
152
+ "locationTypes": "Location dictionary categories to include in targeting search.",
153
+ "adFormat": "Native Meta ad preview format; must be compatible with this ad's creative.",
154
+ "handle": "Asynchronous catalog batch handle returned by the submission to inspect.",
155
+ "keepEmptyRows": "Keep provider rows whose selected metrics are all zero, where supported.",
156
+ "sampleLimit": "Maximum records included in the diagnostic sample.",
157
+ "isOpenFunnel": "Allow users to enter at any funnel step instead of requiring the first step.",
158
+ "visualizationType": "Supported funnel visualization mode.",
159
+ "breakdownLimit": "Maximum breakdown values to include in the funnel response.",
160
+ "nextActionLimit": "Maximum next actions returned for each funnel step.",
161
+ "fallbackToStepCounts": "If the advanced funnel is unavailable, return independent step counts with the limitation made explicit.",
162
+ "report": "Native GA4 report request object for this endpoint.",
163
+ "reports": "Native GA4 report request objects to execute in one batch.",
164
+ "dimensions": "Native reporting dimensions. Validate compatibility with the selected metrics.",
165
+ "compatibilityFilter": "Limit compatibility results to the requested GA4 compatibility category.",
166
+ "collection": "Allowlisted administrative resource collection to read.",
167
+ "settings": "Property configuration surfaces to inspect.",
168
+ "includeRecurring": "Include recurring audience-export configurations.",
169
+ "audienceExportName": "Full GA4 audience-export resource name returned by list_audience_exports.",
170
+ "queryLifetime": "Request lifetime reporting instead of the explicit reporting period where TikTok supports it.",
171
+ "declineThresholdPct": "Percentage decline used as a candidate signal for creative fatigue, requiring further evidence.",
172
+ "includeSavedAudiences": "Include accessible saved-audience metadata in the overlap analysis.",
173
+ "overlapThreshold": "Overlap threshold used to flag audience pairs, in the units declared by this schema.",
174
+ "serviceType": "TikTok report service category.",
175
+ "catalog": "Allowlisted targeting dictionary or catalog to inspect.",
176
+ "query": "Search text used to find matching entries in the selected catalogue.",
177
+ "parameters": "Native query parameters for this allowlisted read endpoint. Do not provide credentials or account overrides.",
178
+ "account_id": "Exact account ID selected for this platform in the GetMCPAds workspace.",
179
+ "platform": "Platform key identifying the connected advertising or analytics source.",
180
+ "id": "Exact saved object ID returned by the corresponding create or list tool.",
181
+ "revision": "Last known object revision, used to reject conflicting updates. Read the object before editing.",
182
+ "name": "Human-readable name for this object.",
183
+ "title": "Human-readable title for the resulting object or review.",
184
+ "clientLabel": "Display label identifying the client in the saved view.",
185
+ "config": "Complete saved-view configuration, including source, selected account, period and display options.",
186
+ "subject": "Entity or creative subject whose dated performance will be analyzed.",
187
+ "compare_previous": "Include the immediately preceding period of equal duration for comparison.",
188
+ "profile": "Creative brief profile containing the client's business context and analysis preferences.",
189
+ "primaryMetric": "Metric used to rank or evaluate the review; it must be available for the selected platform.",
190
+ "direction": "Whether higher or lower values of the primary metric represent improvement.",
191
+ "creative_ids": "Exact creative IDs to include in this review.",
192
+ "request_id": "Caller-generated request identifier. Reuse it for a retry of the same operation to avoid duplicate work.",
193
+ "expiresInDays": "Number of days before the public review sharing link expires.",
194
+ "client_id": "Exact saved business-client workspace ID.",
195
+ "review_id": "Exact review ID returned by the review creation or listing tool.",
196
+ "page_id": "Exact page identifier within the landing-page review.",
197
+ "entity_id": "Exact entity identifier in the selected review or platform context.",
198
+ "source_key": "Source identifier returned by the business review; keep the platform/account association intact.",
199
+ "metric_key": "Exact metric key in the review evidence.",
200
+ "follow_up_on": "Follow-up date for the decision, using the format declared in this schema.",
201
+ "hypothesis": "Specific hypothesis to record, supported by the review evidence.",
202
+ "successCriterion": "Measurable condition used to decide whether the recorded action succeeded.",
203
+ "outcome": "Observed outcome to record; distinguish evidence from an untested expectation.",
204
+ "status": "Status selected from the allowed values for this object or operation.",
205
+ "retry_source": "Source identifier to retry after reviewing its reported error.",
206
+ "site_origin": "Website origin to analyze, including https:// and hostname, without credentials or a path.",
207
+ "matchType": "Google Ads keyword match type for the forecast.",
208
+ "negativeKeywords": "Keyword phrases excluded from the keyword forecast.",
209
+ "negativeMatchType": "Match type applied to the negative keywords.",
210
+ "geoTargetIds": "Google Ads geo-target constant IDs for the intended locations.",
211
+ "languageId": "Google Ads language constant ID for the keyword request.",
212
+ "languageIds": "Google Ads language constant IDs for the intended audience.",
213
+ "network": "Google Ads search network selector used for planning.",
214
+ "biddingStrategy": "Supported bidding strategy for this temporary forecast. The request does not create a live campaign.",
215
+ "includeAdultKeywords": "Allow adult keyword ideas where supported by the provider and the selected market.",
216
+ "seedKeywords": "Seed keyword phrases from which Google generates keyword ideas.",
217
+ "includeAverageCpc": "Request average cost-per-click metrics when available.",
218
+ "includeDeviceBreakdown": "Include device-specific planning metrics when supported.",
219
+ "includeKeywordConcepts": "Include Google's keyword concept grouping metadata.",
220
+ "startYearMonth": "First year and month of the historical metrics period.",
221
+ "endYearMonth": "Last year and month of the historical metrics period.",
222
+ "includeDismissed": "Include recommendations already dismissed in the account.",
223
+ "operation": "Exact allowlisted operation to execute. Arbitrary provider operations are not accepted.",
224
+ "category": "Category selected from the provider dictionary or field catalogue.",
225
+ "selectable": "Filter the field catalogue by whether the field can appear in SELECT.",
226
+ "filterable": "Filter the field catalogue by whether the field can appear in WHERE.",
227
+ "sortable": "Filter the field catalogue by whether the field can appear in ORDER BY.",
228
+ "pageToken": "Opaque continuation token returned by the preceding provider response.",
229
+ "locationNames": "Human-readable location names for Google to resolve into geo-target constants.",
230
+ "locale": "Locale used for human-readable lookup results.",
231
+ "resource": "Native Google Ads resource from which the GAQL query selects rows.",
232
+ "cursor": "Opaque pagination cursor returned by the preceding response; omit for the first page.",
233
+ "kind": "Entity or request kind whose schema or data is being requested.",
234
+ "startTime": "Beginning of the reporting period as an ISO 8601 timestamp with an explicit timezone.",
235
+ "endTime": "End of the reporting period as an ISO 8601 timestamp with an explicit timezone, after startTime.",
236
+ "reportDimension": "Native dimension used to break down the Snapchat report."
237
+ };
238
+
239
+ // src/tool-quality.ts
240
+ import { z } from "zod";
241
+ var RESULT_SCHEMA = {
242
+ type: "object",
243
+ properties: {
244
+ result: {
245
+ 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.",
246
+ type: ["object", "array", "string", "number", "boolean", "null"]
247
+ }
248
+ },
249
+ required: ["result"],
250
+ additionalProperties: true
251
+ };
252
+ function toolAnnotations(write) {
253
+ return { readOnlyHint: !write, destructiveHint: write, idempotentHint: !write, openWorldHint: true };
254
+ }
255
+ function structuredResult(value) {
256
+ if (value.isError) return value;
257
+ let result = value.content;
258
+ const first = value.content[0];
259
+ if (value.content.length === 1 && first?.type === "text" && typeof first.text === "string") {
260
+ result = first.text;
261
+ try {
262
+ result = JSON.parse(first.text);
263
+ } catch {
264
+ }
265
+ }
266
+ return { ...value, structuredContent: { ...value.structuredContent, result } };
267
+ }
268
+ var resultShape = { result: z.union([z.object({}).passthrough(), z.array(z.unknown()), z.string(), z.number(), z.boolean(), z.null()]).describe(RESULT_SCHEMA.properties.result.description) };
269
+ function installToolQuality(server) {
270
+ server.tool = ((name2, description, inputSchema, handler) => {
271
+ const write = Object.hasOwn(inputSchema, "confirm");
272
+ inputSchema = Object.fromEntries(Object.entries(inputSchema).map(([key, schema]) => [key, schema.description || PARAMETER_DESCRIPTIONS[key] ? schema.describe(schema.description || PARAMETER_DESCRIPTIONS[key]) : schema]));
273
+ return server.registerTool(name2, {
274
+ title: name2.replace(/_/g, " "),
275
+ description,
276
+ inputSchema,
277
+ outputSchema: resultShape,
278
+ annotations: toolAnnotations(write)
279
+ }, async (...args) => structuredResult(await handler(...args)));
280
+ });
281
+ }
282
+
1
283
  // src/server.ts
2
284
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
285
 
4
- // src/platforms/meta/tools.ts
286
+ // src/platforms/meta/extended-writes.ts
5
287
  import { z as z2 } from "zod";
6
288
 
289
+ // src/core/money.ts
290
+ function guard(amount2, currency2) {
291
+ if (!Number.isFinite(amount2)) throw new Error(`Amount must be a finite number, received ${amount2}.`);
292
+ if (amount2 <= 0) throw new Error(`Amount must be greater than zero, received ${amount2}.`);
293
+ if (amount2 > 1e6) {
294
+ throw new Error(
295
+ `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.`
296
+ );
297
+ }
298
+ return amount2;
299
+ }
300
+ var ZERO_DECIMAL = /* @__PURE__ */ new Set(["JPY", "KRW", "CLP", "ISK", "VND", "UGX", "PYG", "RWF", "XOF", "XAF", "XPF", "BIF", "DJF", "GNF", "KMF", "MGA", "VUV"]);
301
+ function toMinorUnits(amount2, currency2) {
302
+ const code = currency2.trim().toUpperCase();
303
+ if (!/^[A-Z]{3}$/.test(code)) throw new Error(`Expected a three letter currency code, received "${currency2}".`);
304
+ const factor = ZERO_DECIMAL.has(code) ? 1 : 100;
305
+ return Math.round(guard(amount2, code) * factor);
306
+ }
307
+
308
+ // src/platforms/meta/extended-writes.ts
309
+ var parameterDescriptions = {
310
+ adAccountId: "Selected owning ad account ID, with or without act_. Every referenced ad object must belong to it.",
311
+ adSetId: "Numeric ID of the existing ad set in the selected account.",
312
+ campaignId: "Numeric ID of the parent campaign in the selected account. Its budget model and objective determine compatible ad set settings.",
313
+ creativeId: "Numeric ID of an existing ad creative in the selected account; not an image hash or video ID.",
314
+ adId: "Numeric ID of the existing ad to modify in the selected account.",
315
+ name: "Exact business name to give this object. Do not derive it from an uploaded filename.",
316
+ title: "Optional title for the uploaded video.",
317
+ configuration: "Explicit native Meta ad set settings. First read meta_get_adset_configuration for edits. A supplied targeting object replaces the entire targeting spec.",
318
+ dailyBudget: "Daily budget in major account-currency units, e.g. 10 means EUR 10. Cannot be combined with lifetimeBudget or a campaign-owned budget.",
319
+ lifetimeBudget: "Total lifetime budget in major account-currency units. Requires an end time; mutually exclusive with dailyBudget.",
320
+ bidAmount: "Bid amount in major account-currency units. Only use with a compatible capped bidding strategy.",
321
+ 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.",
322
+ trackingSpecs: "Optional native Meta tracking specifications. Do not supply credentials.",
323
+ bytesBase64: "Base64 media bytes without a data-URL prefix. Maximum decoded size 5 MiB; omitted from preview output.",
324
+ fileUrl: "Publicly reachable HTTPS video file URL. Must point to the media file, not a landing page or a logged-in player.",
325
+ subtype: "Audience type. CUSTOM creates an empty customer-list container; WEBSITE and ENGAGEMENT require a rule; LOOKALIKE requires an origin and lookalikeSpec.",
326
+ description: "Description of the audience purpose.",
327
+ rule: "Complete native Meta audience rule. Replaces the existing rule on update.",
328
+ retentionDays: "Audience retention in days, from 1 to 180; Meta enforces eligibility for the selected subtype.",
329
+ pixelId: "Numeric ID of the pixel used by a website audience rule.",
330
+ originAudienceId: "Numeric ID of the source audience in this same ad account.",
331
+ lookalikeSpec: "Native lookalike specification, including explicit geography and size. Subject to Meta eligibility.",
332
+ customerFileSource: "Actual provenance of future customer-list data. Creating the container does not upload customer data.",
333
+ audienceId: "Numeric ID of the existing custom audience in this selected ad account.",
334
+ catalogId: "Numeric catalog ID. Writes require the catalog and selected ad account to have the same owning Business.",
335
+ productSetId: "Numeric product set ID belonging to catalogId.",
336
+ filter: "Complete native product-set filter. Updates replace the existing filter.",
337
+ requests: "One to 50 product CREATE or UPDATE requests with retailer_id and native product data. No DELETE; inspect the asynchronous batch result.",
338
+ startTime: "Optional new start time as ISO 8601 with explicit UTC offset.",
339
+ endTime: "Optional new end time as ISO 8601 with explicit UTC offset, after the start."
340
+ };
341
+ var id = z2.string().regex(/^\d+$/, "Use a numeric Meta ID");
342
+ var accountId = z2.string().regex(/^(act_)?\d+$/);
343
+ var json = z2.record(z2.unknown());
344
+ var name = z2.string().trim().min(1).max(400);
345
+ var date = z2.string().datetime({ offset: true });
346
+ var amount = z2.number().positive().max(1e6);
347
+ var currency = z2.string().regex(/^[A-Z]{3}$/);
348
+ var mode = { confirm: z2.boolean().optional().describe("Apply only when true. Default is a local preview with no API mutation."), validateOnly: z2.boolean().optional().describe("Ask Meta to validate, without creating or changing an object. Mutually exclusive with confirm.") };
349
+ 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() };
350
+ var adsetConfig = z2.object({
351
+ optimization_goal: z2.string().min(1).optional(),
352
+ billing_event: z2.string().min(1).optional(),
353
+ targeting: json.optional().describe("Complete Meta targeting spec: geography, demographics, custom audiences, exclusions, placements and targeting_automation. Updates replace the entire targeting spec."),
354
+ promoted_object: json.optional(),
355
+ destination_type: z2.string().optional(),
356
+ bid_strategy: z2.enum(["LOWEST_COST_WITHOUT_CAP", "LOWEST_COST_WITH_BID_CAP", "COST_CAP", "LOWEST_COST_WITH_MIN_ROAS"]).optional(),
357
+ bid_constraints: json.optional(),
358
+ start_time: date.optional(),
359
+ end_time: date.optional(),
360
+ attribution_spec: z2.array(json).max(10).optional(),
361
+ attribution_count_type: z2.string().optional(),
362
+ dsa_beneficiary: name.optional(),
363
+ dsa_payor: name.optional(),
364
+ is_dynamic_creative: z2.boolean().optional(),
365
+ pacing_type: z2.array(z2.string()).optional(),
366
+ adset_schedule: z2.array(json).optional(),
367
+ frequency_control_specs: z2.array(json).optional(),
368
+ optimization_sub_event: z2.string().optional(),
369
+ is_incremental_attribution_enabled: z2.boolean().optional()
370
+ }).strict();
371
+ var creativeSpec = z2.object({
372
+ object_story_id: z2.string().regex(/^\d+_\d+$/).optional(),
373
+ object_story_spec: json.optional(),
374
+ asset_feed_spec: json.optional(),
375
+ instagram_user_id: id.optional(),
376
+ product_set_id: id.optional(),
377
+ degrees_of_freedom_spec: json.optional(),
378
+ url_tags: z2.string().max(4e3).optional(),
379
+ contextual_multi_ads: json.optional(),
380
+ dynamic_ad_voice: z2.string().optional(),
381
+ image_crops: json.optional(),
382
+ template_url: z2.string().url().optional(),
383
+ template_url_spec: json.optional()
384
+ }).strict();
385
+ var out = (data, isError = false) => ({ ...isError ? { isError: true } : {}, content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
386
+ var act = (v) => v.replace(/^act_/, "");
387
+ var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
388
+ var has = (v) => v !== void 0 && v !== null;
389
+ function clean(v) {
390
+ return Object.fromEntries(Object.entries(v).filter(([, value]) => value !== void 0));
391
+ }
392
+ function mediaDifferences(requested, returned, path = "") {
393
+ if (!requested || typeof requested !== "object") return [];
394
+ return Object.entries(requested).flatMap(([key, value]) => {
395
+ const field = path ? `${path}.${key}` : key, actual = returned?.[key];
396
+ 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 }];
397
+ return mediaDifferences(value, actual, field);
398
+ });
399
+ }
400
+ function safeJson(value, depth = 0) {
401
+ if (depth > 15) throw new Error("Specification is too deeply nested.");
402
+ if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) {
403
+ if (["access_token", "appsecret_proof", "__proto__", "constructor", "prototype"].includes(k)) throw new Error(`Forbidden specification key: ${k}`);
404
+ safeJson(v, depth + 1);
405
+ }
406
+ }
407
+ function validateAdsetState(p) {
408
+ 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.");
409
+ if (Number(p.lifetime_budget) > 0 && !p.end_time) throw new Error("A lifetime budget requires end_time.");
410
+ 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.");
411
+ 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.");
412
+ 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.");
413
+ }
414
+ function buildAdsetPayload(a, creating) {
415
+ const config = adsetConfig.parse(a.configuration ?? {});
416
+ safeJson(config);
417
+ if (has(a.dailyBudget) && has(a.lifetimeBudget)) throw new Error("Choose dailyBudget OR lifetimeBudget, not both.");
418
+ if ([a.dailyBudget, a.lifetimeBudget, a.bidAmount].some(has) && !a.currency) throw new Error("Supply the account currency for monetary amounts.");
419
+ const p = { ...config };
420
+ for (const [from, to] of [["dailyBudget", "daily_budget"], ["lifetimeBudget", "lifetime_budget"], ["bidAmount", "bid_amount"]]) if (has(a[from])) {
421
+ p[to] = toMinorUnits(Number(a[from]), String(a.currency));
422
+ if (p[to] < 1) throw new Error(`${from} rounds to zero in ${a.currency}.`);
423
+ }
424
+ if (creating) {
425
+ p.campaign_id = a.campaignId;
426
+ p.name = a.name;
427
+ p.status = "PAUSED";
428
+ for (const field of ["optimization_goal", "billing_event", "targeting"]) if (!p[field]) throw new Error(`${field} is required to create an ad set.`);
429
+ }
430
+ 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.");
431
+ if (creating && p.lifetime_budget && !p.end_time) throw new Error("A lifetime budget requires end_time.");
432
+ const targeting = obj(p.targeting);
433
+ 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.");
434
+ if (p.targeting) {
435
+ 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+).`);
436
+ 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.");
437
+ const geo = obj(targeting.geo_locations);
438
+ 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.");
439
+ if (!Object.entries(geo).some(([key, v]) => key !== "location_types" && Array.isArray(v) && v.length)) throw new Error("Specify nonempty geo_locations explicitly.");
440
+ 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.");
441
+ const excluded = new Set((targeting.excluded_custom_audiences ?? []).map((v) => v.id));
442
+ if ((targeting.custom_audiences ?? []).some((v) => excluded.has(v.id))) throw new Error("The same custom audience cannot be included and excluded.");
443
+ }
444
+ if (targeting.age_min && targeting.age_max && Number(targeting.age_min) > Number(targeting.age_max)) throw new Error("Targeting age_min exceeds age_max.");
445
+ 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.");
446
+ if (creating && !Object.keys(obj(targeting.geo_locations)).length) throw new Error("Specify geo_locations explicitly; no country is assumed.");
447
+ if (p.bid_strategy === "LOWEST_COST_WITHOUT_CAP" && p.bid_amount) throw new Error("Automatic lowest-cost bidding cannot include a bid cap.");
448
+ if (creating && ["COST_CAP", "LOWEST_COST_WITH_BID_CAP"].includes(p.bid_strategy) && !p.bid_amount) throw new Error("This bidding strategy requires bidAmount.");
449
+ 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.");
450
+ if (creating) validateAdsetState(p);
451
+ if (!creating && !Object.keys(p).length) throw new Error("No ad set change was supplied.");
452
+ return p;
453
+ }
454
+ function registerMetaExtendedWrites(c, config, readOnly = false) {
455
+ const base = `https://graph.facebook.com/${config.apiVersion || "v26.0"}`;
456
+ async function graph(path, method, data = {}, file) {
457
+ const headers = { authorization: `Bearer ${config.accessToken}` };
458
+ const query = new URLSearchParams();
459
+ for (const [k, v] of Object.entries(clean(data))) query.set(k, typeof v === "object" ? JSON.stringify(v) : String(v));
460
+ let upload;
461
+ if (file) {
462
+ upload = new FormData();
463
+ for (const [k, v] of query) upload.set(k, v);
464
+ upload.set("source", new Blob([Uint8Array.from(atob(file.base64), (ch) => ch.charCodeAt(0))], { type: "video/mp4" }), file.name);
465
+ }
466
+ let response;
467
+ try {
468
+ 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" });
469
+ } catch {
470
+ 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" });
471
+ }
472
+ let dataOut;
473
+ try {
474
+ dataOut = await response.json();
475
+ } catch {
476
+ throw Object.assign(new Error("Meta returned an unreadable response. Check the object before retrying."), { outcome: method === "POST" ? "unknown" : "not_applied" });
477
+ }
478
+ if (!response.ok || dataOut.error) {
479
+ const e = obj(dataOut.error);
480
+ 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" });
481
+ }
482
+ return dataOut;
483
+ }
484
+ async function owned(nodeId, account2, fields = "id,account_id") {
485
+ const value = await graph(nodeId, "GET", { fields });
486
+ if (String(value.account_id) !== act(account2)) throw new Error("The target does not belong to the selected ad account. Nothing was changed.");
487
+ return value;
488
+ }
489
+ async function account(account2, includeBusiness = false) {
490
+ return graph(`act_${act(account2)}`, "GET", { fields: includeBusiness ? "id,currency,business{id}" : "id,currency" });
491
+ }
492
+ async function catalog(catalogId, adAccountId) {
493
+ const [a, p] = await Promise.all([account(adAccountId, true), graph(catalogId, "GET", { fields: "id,business{id}" })]);
494
+ 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.");
495
+ }
496
+ if (readOnly) {
497
+ const read = (n, d, shape, fn) => {
498
+ const schema = z2.object({ adAccountId: accountId, ...shape }).strict();
499
+ c.tool(n, d, schema.shape, async (raw) => {
500
+ try {
501
+ return out(await fn(schema.parse(raw)));
502
+ } catch (e) {
503
+ return out({ error: e.message }, true);
504
+ }
505
+ });
506
+ };
507
+ 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.", {
508
+ entityId: id.describe("Exact entity ID saved in the acknowledged creation receipt."),
509
+ fields: z2.array(z2.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.")
510
+ }, async (a) => ({ entity: await owned(a.entityId, a.adAccountId, ["id", "account_id", ...a.fields].join(",")) }));
511
+ 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) => {
512
+ let after, title;
513
+ for (let page = 0; page < 6; page++) {
514
+ const result = await graph(`act_${act(a.adAccountId)}/advideos`, "GET", clean({ fields: "id,status,picture", limit: 100, after, title }));
515
+ const video = Array.isArray(result.data) ? result.data.find((v) => String(v.id) === a.videoId) : void 0;
516
+ 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 };
517
+ if (page === 0) {
518
+ try {
519
+ const node = await graph(a.videoId, "GET", { fields: "id,title" });
520
+ if (String(node.id) === a.videoId && typeof node.title === "string" && node.title.trim()) {
521
+ title = node.title;
522
+ after = void 0;
523
+ continue;
524
+ }
525
+ } catch {
526
+ }
527
+ }
528
+ const next = result.paging?.cursors?.after;
529
+ if (!result.paging?.next || !next || next === after) break;
530
+ after = next;
531
+ }
532
+ return { videoId: a.videoId, state: "not_found_in_recent_library", ready: false };
533
+ });
534
+ 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) => {
535
+ 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");
536
+ 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")]);
537
+ return {
538
+ account: info,
539
+ campaign,
540
+ adset,
541
+ budgetLevel: Number(campaign.daily_budget) > 0 || Number(campaign.lifetime_budget) > 0 ? "campaign" : "adset",
542
+ note: "Native money fields are minor units. Targeting updates replace the complete spec. Duplicate an existing ad set to preserve complex settings."
543
+ };
544
+ });
545
+ 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: z2.string().min(1).max(1e3) }, async (a) => {
546
+ await catalog(a.catalogId, a.adAccountId);
547
+ return graph(`${a.catalogId}/check_batch_request_status`, "GET", { handle: a.handle, load_ids_of_invalid_requests: true });
548
+ });
549
+ return;
550
+ }
551
+ function register(n, description, shape, build) {
552
+ const described = Object.fromEntries(Object.entries({ adAccountId: accountId, ...shape, ...mode }).map(([key, value]) => [key, value.description ? value : value.describe(parameterDescriptions[key] || key)]));
553
+ const schema = z2.object(described).strict();
554
+ c.tool(n, description + " Local preview by default. No automatic retry. Account ownership is checked before Meta validation or mutation.", schema.shape, async (raw) => {
555
+ try {
556
+ const a = schema.parse(raw);
557
+ if (a.confirm && a.validateOnly) throw new Error("confirm and validateOnly cannot both be true.");
558
+ const p = await build(a);
559
+ safeJson(p.payload);
560
+ if (a.validateOnly && !p.validate) throw new Error("This endpoint does not expose validate_only. Use the local preview, then confirm.");
561
+ 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." });
562
+ await p.scope?.();
563
+ const result = await graph(p.path, "POST", { ...p.payload, ...a.validateOnly ? { execution_options: ["validate_only"] } : {} }, p.file);
564
+ if (a.validateOnly) return out({ applied: false, validatedByMeta: true, action: n, result });
565
+ let verification;
566
+ if (p.readback) {
567
+ try {
568
+ verification = await graph(result[p.idKey || "id"] || p.path, "GET", { fields: p.readback });
569
+ } catch {
570
+ verification = { confirmed: false, message: "Mutation returned success, but readback failed. Read the returned ID; do not recreate it." };
571
+ }
572
+ }
573
+ if (["meta_create_campaign", "meta_create_adset", "meta_create_ad"].includes(n) && obj(verification).confirmed !== false) {
574
+ verification = { ...obj(verification), confirmed: obj(verification).status === "PAUSED" && String(obj(verification).account_id) === act(a.adAccountId) };
575
+ }
576
+ const differences = [];
577
+ if (n === "meta_create_adcreative" && obj(verification).confirmed !== false) {
578
+ const media = mediaDifferences(a.spec.object_story_spec, obj(verification).object_story_spec, "object_story_spec");
579
+ media.push(...mediaDifferences(a.spec.asset_feed_spec, obj(verification).asset_feed_spec, "asset_feed_spec"));
580
+ differences.push(...media);
581
+ 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." } : {} };
582
+ }
583
+ if (n === "meta_create_adcreative" && typeof obj(verification).name === "string" && obj(verification).name !== a.name) {
584
+ differences.push({
585
+ field: "name",
586
+ requested: a.name,
587
+ returned: obj(verification).name,
588
+ 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."
589
+ });
590
+ }
591
+ 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 } : {} });
592
+ } catch (error) {
593
+ const e = error;
594
+ 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);
595
+ }
596
+ });
597
+ }
598
+ const checkCurrency = async (a) => {
599
+ const info = await account(a.adAccountId);
600
+ if (a.currency && info.currency !== a.currency) throw new Error(`Account currency is ${info.currency}, not ${a.currency}. Nothing was changed.`);
601
+ };
602
+ 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.") };
603
+ const budgetPayload = (a, required = false) => {
604
+ if (has(a.dailyBudget) && has(a.lifetimeBudget)) throw new Error("Choose dailyBudget OR lifetimeBudget, not both.");
605
+ if (required && !has(a.dailyBudget) && !has(a.lifetimeBudget)) throw new Error("Provide dailyBudget or lifetimeBudget.");
606
+ if ((has(a.dailyBudget) || has(a.lifetimeBudget)) && !a.currency) throw new Error("Supply the account currency for monetary amounts.");
607
+ const p = {};
608
+ for (const [from, to] of [["dailyBudget", "daily_budget"], ["lifetimeBudget", "lifetime_budget"]]) if (has(a[from])) {
609
+ p[to] = toMinorUnits(Number(a[from]), String(a.currency));
610
+ if (p[to] < 1) throw new Error("Budget rounds to zero in the account currency.");
611
+ }
612
+ return p;
613
+ };
614
+ 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.", {
615
+ name,
616
+ objective: z2.enum(["OUTCOME_TRAFFIC", "OUTCOME_SALES", "OUTCOME_LEADS", "OUTCOME_AWARENESS", "OUTCOME_ENGAGEMENT", "OUTCOME_APP_PROMOTION"]),
617
+ specialAdCategories: z2.array(z2.enum(["NONE", "HOUSING", "EMPLOYMENT", "CREDIT", "ISSUES_ELECTIONS_POLITICS"])).optional().describe("Required special categories; omit for none."),
618
+ adsetBudgetSharing: z2.boolean().default(false).describe("Explicit ad set budget sharing choice; only with ad set budgets."),
619
+ ...budgets,
620
+ bidStrategy: adsetConfig.shape.bid_strategy,
621
+ startTime: date.optional(),
622
+ endTime: date.optional()
623
+ }, (a) => {
624
+ const budget = budgetPayload(a);
625
+ if (Object.keys(budget).length && a.adsetBudgetSharing) throw new Error("Ad set budget sharing cannot be combined with a campaign budget.");
626
+ if (a.startTime && a.endTime && Date.parse(a.endTime) <= Date.parse(a.startTime)) throw new Error("endTime must follow startTime.");
627
+ 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" };
628
+ });
629
+ 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 () => {
630
+ await checkCurrency(a);
631
+ 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");
632
+ validateAdsetState({ ...current, ...payload });
633
+ if (payload.daily_budget || payload.lifetime_budget) {
634
+ const parent = await owned(current.campaign_id, a.adAccountId, "id,account_id,daily_budget,lifetime_budget");
635
+ if (Number(parent.daily_budget) > 0 || Number(parent.lifetime_budget) > 0) throw new Error("This campaign owns its budget; change it at campaign level.");
636
+ }
637
+ } });
638
+ 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)));
639
+ 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) => {
640
+ if (!a.startTime && !a.endTime) throw new Error("Provide startTime or endTime.");
641
+ const p = clean({ start_time: a.startTime, end_time: a.endTime });
642
+ if (a.startTime && a.endTime && Date.parse(a.endTime) <= Date.parse(a.startTime)) throw new Error("endTime must follow startTime.");
643
+ return adsetUpdate(a, p);
644
+ });
645
+ 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 () => {
646
+ await checkCurrency(a);
647
+ const parent = await owned(a.campaignId, a.adAccountId, "id,account_id,daily_budget,lifetime_budget");
648
+ 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.");
649
+ } }));
650
+ 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) => {
651
+ const payload = buildAdsetPayload(a, true);
652
+ 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 () => {
653
+ await checkCurrency(a);
654
+ const campaign = await owned(a.campaignId, a.adAccountId, "id,account_id,objective,daily_budget,lifetime_budget,special_ad_categories");
655
+ const cbo = Number(campaign.daily_budget) > 0 || Number(campaign.lifetime_budget) > 0;
656
+ if (cbo && (payload.daily_budget || payload.lifetime_budget)) throw new Error("This campaign owns its budget. Omit the ad set budget.");
657
+ if (!cbo && !payload.daily_budget && !payload.lifetime_budget) throw new Error("This campaign has no campaign budget. Supply an ad set budget.");
658
+ } };
659
+ });
660
+ 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) => {
661
+ const payload = buildAdsetPayload(a, false);
662
+ return adsetUpdate(a, payload);
663
+ });
664
+ 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) => {
665
+ if (a.startTime && a.endTime && Date.parse(a.endTime) <= Date.parse(a.startTime)) throw new Error("endTime must be after startTime.");
666
+ 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 () => {
667
+ await owned(a.adSetId, a.adAccountId);
668
+ if (a.campaignId) await owned(a.campaignId, a.adAccountId);
669
+ }, idKey: "copied_adset_id", readback: "id,name,status,account_id,campaign_id" };
670
+ });
671
+ 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) => {
672
+ const spec = creativeSpec.parse(a.spec);
673
+ 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.");
674
+ 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.");
675
+ 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" };
676
+ });
677
+ 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: z2.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 () => {
678
+ await owned(a.adSetId, a.adAccountId);
679
+ await owned(a.creativeId, a.adAccountId);
680
+ }, readback: "id,name,account_id,status,effective_status,adset_id,creative{id}" }));
681
+ 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 () => {
682
+ await owned(a.adId, a.adAccountId);
683
+ await owned(a.creativeId, a.adAccountId);
684
+ }, readback: "id,account_id,status,creative{id}" }));
685
+ 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: z2.string().min(4).max(7e6).regex(/^[A-Za-z0-9+/]+={0,2}$/) }, (a) => {
686
+ let bytes;
687
+ try {
688
+ bytes = atob(a.bytesBase64);
689
+ } catch {
690
+ throw new Error("Invalid base64 image.");
691
+ }
692
+ if (bytes.length > 5 * 1024 * 1024) throw new Error("Image exceeds 5 MiB.");
693
+ return { path: `act_${act(a.adAccountId)}/adimages`, payload: { bytes: a.bytesBase64 }, redact: { decodedBytes: bytes.length }, scope: () => account(a.adAccountId) };
694
+ });
695
+ 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: z2.string().url().startsWith("https://").optional(), bytesBase64: z2.string().min(4).max(7e6).regex(/^[A-Za-z0-9+/]+={0,2}$/).optional(), title: name.optional() }, (a) => {
696
+ if (Number(!!a.fileUrl) + Number(!!a.bytesBase64) !== 1) throw new Error("Supply exactly one of fileUrl or bytesBase64.");
697
+ let decodedBytes;
698
+ if (a.bytesBase64) {
699
+ try {
700
+ decodedBytes = atob(a.bytesBase64).length;
701
+ } catch {
702
+ throw new Error("Invalid base64 video.");
703
+ }
704
+ if (decodedBytes > 5 * 1024 * 1024) throw new Error("Video exceeds 5 MiB. Use a public media URL for larger files.");
705
+ }
706
+ 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 };
707
+ });
708
+ 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: z2.enum(["CUSTOM", "WEBSITE", "ENGAGEMENT", "LOOKALIKE"]), description: z2.string().max(2e3).optional(), rule: json.optional(), retentionDays: z2.number().int().min(1).max(180).optional(), pixelId: id.optional(), originAudienceId: id.optional(), lookalikeSpec: json.optional(), customerFileSource: z2.enum(["USER_PROVIDED_ONLY", "PARTNER_PROVIDED_ONLY", "BOTH_USER_AND_PARTNER_PROVIDED"]).optional() }, (a) => {
709
+ if (a.subtype === "LOOKALIKE" && (!a.originAudienceId || !a.lookalikeSpec)) throw new Error("A lookalike requires originAudienceId and lookalikeSpec.");
710
+ if (a.subtype === "CUSTOM" && !a.customerFileSource) throw new Error("Declare customerFileSource explicitly.");
711
+ if (["WEBSITE", "ENGAGEMENT"].includes(a.subtype) && !a.rule) throw new Error("This audience requires a native rule.");
712
+ 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 () => {
713
+ await account(a.adAccountId);
714
+ if (a.originAudienceId) await owned(a.originAudienceId, a.adAccountId);
715
+ }, readback: "id,name,account_id,subtype" };
716
+ });
717
+ 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: z2.string().max(2e3).optional(), retentionDays: z2.number().int().min(1).max(180).optional(), rule: json.optional() }, (a) => {
718
+ const payload = clean({ name: a.name, description: a.description, retention_days: a.retentionDays, rule: a.rule });
719
+ if (!Object.keys(payload).length) throw new Error("No audience change supplied.");
720
+ return { path: a.audienceId, payload, scope: () => owned(a.audienceId, a.adAccountId), readback: "id,name,account_id,subtype" };
721
+ });
722
+ 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" }));
723
+ 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) => {
724
+ const payload = clean({ name: a.name, filter: a.filter });
725
+ if (!Object.keys(payload).length) throw new Error("No product set change supplied.");
726
+ return { path: a.productSetId, payload, scope: async () => {
727
+ await catalog(a.catalogId, a.adAccountId);
728
+ const set = await graph(a.productSetId, "GET", { fields: "id,product_catalog{id}" });
729
+ if (String(set.product_catalog?.id) !== a.catalogId) throw new Error("Product set does not belong to the declared catalog.");
730
+ }, readback: "id,name,filter" };
731
+ });
732
+ 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: z2.array(z2.object({ method: z2.enum(["CREATE", "UPDATE"]), retailer_id: z2.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 }));
733
+ }
734
+ async function verifyMetaWriteScope(config, a) {
735
+ accountId.parse(a.adAccountId);
736
+ const get = async (node, fields) => {
737
+ 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" });
738
+ const data = await response.json();
739
+ if (!response.ok || data.error) throw new Error("Cannot verify the Meta target scope. Nothing was changed.");
740
+ return data;
741
+ };
742
+ const info = await get(`act_${act(a.adAccountId)}`, "id,currency");
743
+ if (a.currency && info.currency !== a.currency) throw new Error(`Account currency is ${info.currency}, not ${a.currency}. Nothing was changed.`);
744
+ const target = a.adId || a.adSetId || a.campaignId;
745
+ if (target) {
746
+ id.parse(target);
747
+ const entity = await get(target, "id,account_id");
748
+ if (String(entity.account_id) !== act(a.adAccountId)) throw new Error("The target belongs to a different ad account. Nothing was changed.");
749
+ }
750
+ }
751
+
752
+ // src/platforms/meta/reporting-context.ts
753
+ function measurementParams(request2) {
754
+ const mode2 = request2.attributionMode;
755
+ const windows = request2.attributionWindows;
756
+ if (mode2 === "explicit" && (!windows || windows.length === 0))
757
+ throw Error("Explicit attribution requires at least one window.");
758
+ if ((mode2 === "account" || mode2 === "adset") && windows?.length)
759
+ throw Error(
760
+ "Choose native attribution settings or explicit windows, not both."
761
+ );
762
+ return {
763
+ ...mode2 === "account" ? { use_account_attribution_setting: "true" } : {},
764
+ ...mode2 === "adset" ? { use_unified_attribution_setting: "true" } : {},
765
+ ...windows?.length ? { action_attribution_windows: JSON.stringify(windows) } : {},
766
+ ...request2.actionReportTime ? { action_report_time: request2.actionReportTime } : {}
767
+ };
768
+ }
769
+ function reportingEvidence(results, requests, joinKeys, rows2) {
770
+ const key = (r) => JSON.stringify(joinKeys.map((k) => r[k] ?? null));
771
+ const indices = results.map(
772
+ (group) => new Map(group.map((r) => [key(r), r]))
773
+ );
774
+ const missingRows = rows2.filter(
775
+ (r) => requests.some((q) => q.fields.includes("spend")) && !Object.hasOwn(r, "spend")
776
+ );
777
+ return {
778
+ source_queries: requests.map((q, i) => ({
779
+ index: i,
780
+ type: q.type,
781
+ fields: q.fields,
782
+ breakdowns: q.breakdowns ?? [],
783
+ row_count: results[i].length,
784
+ measurement_parameters: measurementParams(q),
785
+ pagination_exhausted: true
786
+ })),
787
+ row_provenance: rows2.map((r) => ({
788
+ key: Object.fromEntries(joinKeys.map((k) => [k, r[k] ?? null])),
789
+ exact_source_queries: indices.flatMap(
790
+ (m, i) => m.has(key(r)) ? [i] : []
791
+ ),
792
+ missing_requested_fields: [
793
+ ...new Set(requests.flatMap((q) => q.fields))
794
+ ].filter((f) => !Object.hasOwn(r, f))
795
+ })),
796
+ rows_without_spend: missingRows.length,
797
+ notes: [
798
+ "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.",
799
+ "Native attribution configuration is not inferred from numerical coincidences in action values. Explicit parameters are listed for each query.",
800
+ "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.",
801
+ "Do not sum different purchase action types, attribution-window columns, or metrics broadcast across breakdown rows.",
802
+ "Use native account_totals when requested for overall figures. Do not mix a subset spend denominator with conversions from another population.",
803
+ "ROAS alone does not establish profitability. Missing counts do not establish future conversion growth."
804
+ ]
805
+ };
806
+ }
807
+
808
+ // src/platforms/meta/tools.ts
809
+ import { z as z4 } from "zod";
810
+
7
811
  // src/core/logger.ts
8
812
  var LEVEL_ORDER = {
9
813
  debug: 0,
@@ -50,13 +854,6 @@ var PlatformApiError = class extends Error {
50
854
  this.retryAfter = retryAfter;
51
855
  this.name = "PlatformApiError";
52
856
  }
53
- platform;
54
- code;
55
- isRateLimit;
56
- isAuth;
57
- isPermission;
58
- suggestion;
59
- retryAfter;
60
857
  toMcpError() {
61
858
  return {
62
859
  error: this.message,
@@ -113,7 +910,6 @@ var RateLimiter = class {
113
910
  this.platform = platform;
114
911
  this.config = PLATFORM_LIMITS[platform];
115
912
  }
116
- platform;
117
913
  records = /* @__PURE__ */ new Map();
118
914
  config;
119
915
  /**
@@ -122,25 +918,25 @@ var RateLimiter = class {
122
918
  */
123
919
  async acquire() {
124
920
  const key = this.platform;
125
- let record = this.records.get(key);
126
- if (!record) {
127
- record = { timestamps: [] };
128
- this.records.set(key, record);
921
+ let record2 = this.records.get(key);
922
+ if (!record2) {
923
+ record2 = { timestamps: [] };
924
+ this.records.set(key, record2);
129
925
  }
130
926
  const now = Date.now();
131
- record.timestamps = record.timestamps.filter((t) => now - t < 6e4);
132
- const lastSecond = record.timestamps.filter((t) => now - t < 1e3);
927
+ record2.timestamps = record2.timestamps.filter((t) => now - t < 6e4);
928
+ const lastSecond = record2.timestamps.filter((t) => now - t < 1e3);
133
929
  if (lastSecond.length >= this.config.maxPerSecond) {
134
930
  const waitMs = 1e3 - (now - lastSecond[0]) + 50;
135
931
  logger.debug(this.platform, `Rate limit: waiting ${waitMs}ms (per-second limit)`);
136
932
  await sleep(waitMs);
137
933
  }
138
- if (record.timestamps.length >= this.config.maxPerMinute) {
139
- const waitMs = 6e4 - (now - record.timestamps[0]) + 100;
934
+ if (record2.timestamps.length >= this.config.maxPerMinute) {
935
+ const waitMs = 6e4 - (now - record2.timestamps[0]) + 100;
140
936
  logger.warn(this.platform, `Rate limit: waiting ${waitMs}ms (per-minute limit)`);
141
937
  await sleep(waitMs);
142
938
  }
143
- record.timestamps.push(Date.now());
939
+ record2.timestamps.push(Date.now());
144
940
  }
145
941
  /**
146
942
  * Execute a function with automatic rate limiting and retry on 429.
@@ -185,21 +981,24 @@ function jitter() {
185
981
  var META_GRAPH_API_BASE = "https://graph.facebook.com";
186
982
  var DEFAULT_API_VERSION = "v26.0";
187
983
  var DEFAULT_LIMIT = 500;
188
- function assertSafeMetaGraphUrl(rawUrl, apiVersion) {
189
- let url;
984
+ var META_REQUEST_TIMEOUT_MS = 15e3;
985
+ var MAX_PAGINATION_PAGES = 20;
986
+ var MAX_PAGINATION_ROWS = 1e4;
987
+ var MAX_PAGE_ROWS = 1e3;
988
+ var MAX_SEARCH_ENTITY_ROWS = 500;
989
+ function trustedMetaGraphUrl(rawUrl, apiVersion, expectedPathname) {
990
+ let parsed;
190
991
  try {
191
- url = new URL(rawUrl);
992
+ parsed = new URL(rawUrl);
192
993
  } catch {
193
- throw new Error("Meta Graph URL must be an absolute HTTPS URL.");
194
- }
195
- if (url.protocol !== "https:" || url.hostname !== "graph.facebook.com" || url.port !== "" || url.username !== "" || url.password !== "") {
196
- throw new Error("Meta Graph reads are restricted to https://graph.facebook.com.");
994
+ throw new Error("Meta Graph URL is invalid.");
197
995
  }
198
- const versionPrefix = `/${apiVersion.replace(/^\/+|\/+$/g, "")}/`;
199
- if (!url.pathname.startsWith(versionPrefix) && url.pathname !== versionPrefix.slice(0, -1)) {
200
- throw new Error(`Meta Graph URL must use the configured API version ${apiVersion}.`);
996
+ const versionSegment = parsed.pathname.split("/").filter(Boolean)[0];
997
+ 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) {
998
+ throw new Error("Meta Graph URL failed the trusted origin, API-version, and route policy.");
201
999
  }
202
- return url;
1000
+ parsed.searchParams.delete("access_token");
1001
+ return parsed;
203
1002
  }
204
1003
  var MetaApiException = class extends Error {
205
1004
  code;
@@ -252,33 +1051,33 @@ function normalizeInsightRow(row) {
252
1051
  if (normalized.ad_format_asset !== void 0) {
253
1052
  const value = normalized.ad_format_asset;
254
1053
  if (typeof value === "object" && value !== null) {
255
- const obj = value;
256
- if ("name" in obj && typeof obj.name === "string") {
257
- normalized.ad_format_asset = obj.name;
258
- } else if ("id" in obj) {
259
- normalized.ad_format_asset = String(obj.id);
1054
+ const obj3 = value;
1055
+ if ("name" in obj3 && typeof obj3.name === "string") {
1056
+ normalized.ad_format_asset = obj3.name;
1057
+ } else if ("id" in obj3) {
1058
+ normalized.ad_format_asset = String(obj3.id);
260
1059
  }
261
1060
  }
262
1061
  }
263
1062
  if (normalized.media_type !== void 0) {
264
1063
  const value = normalized.media_type;
265
1064
  if (typeof value === "object" && value !== null) {
266
- const obj = value;
267
- if ("name" in obj && typeof obj.name === "string") {
268
- normalized.media_type = obj.name;
269
- } else if ("value" in obj && typeof obj.value === "string") {
270
- normalized.media_type = obj.value;
1065
+ const obj3 = value;
1066
+ if ("name" in obj3 && typeof obj3.name === "string") {
1067
+ normalized.media_type = obj3.name;
1068
+ } else if ("value" in obj3 && typeof obj3.value === "string") {
1069
+ normalized.media_type = obj3.value;
271
1070
  }
272
1071
  }
273
1072
  }
274
1073
  if (normalized.media_format !== void 0) {
275
1074
  const value = normalized.media_format;
276
1075
  if (typeof value === "object" && value !== null) {
277
- const obj = value;
278
- if ("name" in obj && typeof obj.name === "string") {
279
- normalized.media_format = obj.name;
280
- } else if ("value" in obj && typeof obj.value === "string") {
281
- normalized.media_format = obj.value;
1076
+ const obj3 = value;
1077
+ if ("name" in obj3 && typeof obj3.name === "string") {
1078
+ normalized.media_format = obj3.name;
1079
+ } else if ("value" in obj3 && typeof obj3.value === "string") {
1080
+ normalized.media_format = obj3.value;
282
1081
  }
283
1082
  }
284
1083
  }
@@ -316,11 +1115,14 @@ var MetaClient = class {
316
1115
  * Useful for tools that build URLs directly.
317
1116
  */
318
1117
  async fetchUrl(url) {
1118
+ const trustedUrl = trustedMetaGraphUrl(url, this.apiVersion);
319
1119
  return this.rateLimiter.execute(async () => {
320
- const safeUrl = assertSafeMetaGraphUrl(url, this.apiVersion);
321
- safeUrl.searchParams.set("access_token", this.accessToken);
322
- logger.debug("meta", `fetchUrl: ${safeUrl.origin}${safeUrl.pathname}`);
323
- const response = await fetch(safeUrl, { redirect: "error" });
1120
+ logger.debug("meta", `fetchUrl: ${trustedUrl.origin}${trustedUrl.pathname}`);
1121
+ const response = await fetch(trustedUrl, {
1122
+ headers: { Authorization: `Bearer ${this.accessToken}` },
1123
+ signal: AbortSignal.timeout(META_REQUEST_TIMEOUT_MS),
1124
+ redirect: "error"
1125
+ });
324
1126
  if (!response.ok) {
325
1127
  const errorData = await response.json().catch(() => ({}));
326
1128
  const errorInfo = errorData.error ?? {};
@@ -354,10 +1156,11 @@ var MetaClient = class {
354
1156
  });
355
1157
  const response = await fetch(url.toString(), {
356
1158
  method: "GET",
357
- redirect: "error",
358
1159
  headers: {
359
1160
  "Content-Type": "application/json"
360
- }
1161
+ },
1162
+ signal: AbortSignal.timeout(META_REQUEST_TIMEOUT_MS),
1163
+ redirect: "error"
361
1164
  });
362
1165
  const data = await response.json();
363
1166
  if (data.error) {
@@ -388,6 +1191,8 @@ var MetaClient = class {
388
1191
  async getAdAccounts() {
389
1192
  const allAccounts = [];
390
1193
  let nextUrl;
1194
+ let pageCount = 1;
1195
+ const expectedPathname = `/${this.apiVersion}/me/adaccounts`;
391
1196
  const firstResponse = await this.request(
392
1197
  "/me/adaccounts",
393
1198
  {
@@ -395,15 +1200,20 @@ var MetaClient = class {
395
1200
  limit: "100"
396
1201
  }
397
1202
  );
398
- allAccounts.push(...firstResponse.data);
1203
+ allAccounts.push(...firstResponse.data.slice(0, MAX_PAGINATION_ROWS));
399
1204
  nextUrl = firstResponse.paging?.next;
400
- while (nextUrl) {
401
- const paginatedData = await this.fetchPaginatedUrl(nextUrl);
1205
+ while (nextUrl && pageCount < MAX_PAGINATION_PAGES && allAccounts.length < MAX_PAGINATION_ROWS) {
1206
+ const paginatedData = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
402
1207
  if (paginatedData.error) {
403
1208
  throw new MetaApiException(paginatedData.error);
404
1209
  }
405
- allAccounts.push(...paginatedData.data);
1210
+ const remainingRows = MAX_PAGINATION_ROWS - allAccounts.length;
1211
+ allAccounts.push(...paginatedData.data.slice(0, remainingRows));
406
1212
  nextUrl = paginatedData.paging?.next;
1213
+ pageCount += 1;
1214
+ }
1215
+ if (nextUrl) {
1216
+ throw new Error("Meta ad-account pagination exceeded its page or row safety cap.");
407
1217
  }
408
1218
  return allAccounts;
409
1219
  }
@@ -411,8 +1221,8 @@ var MetaClient = class {
411
1221
  * Get a specific ad account.
412
1222
  */
413
1223
  async getAdAccount(adAccountId) {
414
- const accountId = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
415
- return this.request(`/${accountId}`, {
1224
+ const accountId2 = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
1225
+ return this.request(`/${accountId2}`, {
416
1226
  fields: "id,account_id,name,currency,timezone_name,account_status"
417
1227
  });
418
1228
  }
@@ -423,13 +1233,15 @@ var MetaClient = class {
423
1233
  * Fetch insights for a single API request.
424
1234
  */
425
1235
  async fetchInsights(adAccountId, apiRequest) {
426
- const accountId = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
1236
+ const accountId2 = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
427
1237
  const allRows = [];
428
1238
  let nextUrl;
1239
+ let pageCount = 1;
1240
+ const expectedPathname = `/${this.apiVersion}/${accountId2}/insights`;
429
1241
  const params = {
430
1242
  fields: apiRequest.fields.join(","),
431
1243
  level: apiRequest.level,
432
- limit: String(apiRequest.limit || DEFAULT_LIMIT)
1244
+ limit: String(Math.min(Math.max(apiRequest.limit || DEFAULT_LIMIT, 1), MAX_PAGE_ROWS))
433
1245
  };
434
1246
  if (apiRequest.timeRange) {
435
1247
  params.time_range = JSON.stringify({
@@ -448,35 +1260,35 @@ var MetaClient = class {
448
1260
  if (apiRequest.actionBreakdowns && apiRequest.actionBreakdowns.length > 0) {
449
1261
  params.action_breakdowns = apiRequest.actionBreakdowns.join(",");
450
1262
  }
451
- const windows = apiRequest.attributionWindows;
452
- if (windows === void 0) {
453
- params.action_attribution_windows = "1d_click,7d_click,28d_click,1d_view,1d_ev";
454
- } else if (windows.length > 0) {
455
- params.action_attribution_windows = windows.join(",");
456
- }
1263
+ Object.assign(params, measurementParams(apiRequest));
457
1264
  if (apiRequest.filtering && apiRequest.filtering.length > 0) {
458
1265
  params.filtering = JSON.stringify(apiRequest.filtering);
459
1266
  }
460
1267
  logger.info("meta", "Fetching insights", {
461
1268
  type: apiRequest.type,
462
- accountId,
1269
+ accountId: accountId2,
463
1270
  breakdowns: params.breakdowns || "(none)",
464
1271
  action_breakdowns: params.action_breakdowns || "(none)",
465
1272
  fieldCount: apiRequest.fields.length
466
1273
  });
467
1274
  const firstResponse = await this.request(
468
- `/${accountId}/insights`,
1275
+ `/${accountId2}/insights`,
469
1276
  params
470
1277
  );
471
- allRows.push(...firstResponse.data.map(normalizeInsightRow));
1278
+ allRows.push(...firstResponse.data.slice(0, MAX_PAGINATION_ROWS).map(normalizeInsightRow));
472
1279
  nextUrl = firstResponse.paging?.next;
473
- while (nextUrl) {
474
- const data = await this.fetchPaginatedUrl(nextUrl);
1280
+ while (nextUrl && pageCount < MAX_PAGINATION_PAGES && allRows.length < MAX_PAGINATION_ROWS) {
1281
+ const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
475
1282
  if (data.error) {
476
1283
  throw new MetaApiException(data.error);
477
1284
  }
478
- allRows.push(...data.data.map(normalizeInsightRow));
1285
+ const remainingRows = MAX_PAGINATION_ROWS - allRows.length;
1286
+ allRows.push(...data.data.slice(0, remainingRows).map(normalizeInsightRow));
479
1287
  nextUrl = data.paging?.next;
1288
+ pageCount += 1;
1289
+ }
1290
+ if (nextUrl) {
1291
+ throw new Error("Meta insights pagination exceeded its page or row safety cap.");
480
1292
  }
481
1293
  return allRows;
482
1294
  }
@@ -490,8 +1302,8 @@ var MetaClient = class {
490
1302
  }
491
1303
  const results = [];
492
1304
  for (const request2 of plan.requests) {
493
- const rows = await this.fetchInsights(adAccountId, request2);
494
- results.push(rows);
1305
+ const rows2 = await this.fetchInsights(adAccountId, request2);
1306
+ results.push(rows2);
495
1307
  }
496
1308
  const mergedData = mergeResults2(
497
1309
  results,
@@ -552,19 +1364,26 @@ var MetaClient = class {
552
1364
  async getAsyncReportResults(reportRunId) {
553
1365
  const allRows = [];
554
1366
  let nextUrl;
1367
+ let pageCount = 1;
1368
+ const expectedPathname = `/${this.apiVersion}/${reportRunId}/insights`;
555
1369
  const firstResponse = await this.request(
556
1370
  `/${reportRunId}/insights`,
557
1371
  { limit: "500" }
558
1372
  );
559
- allRows.push(...firstResponse.data);
1373
+ allRows.push(...firstResponse.data.slice(0, MAX_PAGINATION_ROWS));
560
1374
  nextUrl = firstResponse.paging?.next;
561
- while (nextUrl) {
562
- const data = await this.fetchPaginatedUrl(nextUrl);
1375
+ while (nextUrl && pageCount < MAX_PAGINATION_PAGES && allRows.length < MAX_PAGINATION_ROWS) {
1376
+ const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
563
1377
  if (data.error) {
564
1378
  throw new MetaApiException(data.error);
565
1379
  }
566
- allRows.push(...data.data);
1380
+ const remainingRows = MAX_PAGINATION_ROWS - allRows.length;
1381
+ allRows.push(...data.data.slice(0, remainingRows));
567
1382
  nextUrl = data.paging?.next;
1383
+ pageCount += 1;
1384
+ }
1385
+ if (nextUrl) {
1386
+ throw new Error("Meta async-report pagination exceeded its page or row safety cap.");
568
1387
  }
569
1388
  return allRows;
570
1389
  }
@@ -600,7 +1419,7 @@ var MetaClient = class {
600
1419
  * Results are deduplicated by study ID.
601
1420
  */
602
1421
  async getAdStudies(adAccountId) {
603
- const accountId = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
1422
+ const accountId2 = adAccountId.startsWith("act_") ? adAccountId : `act_${adAccountId}`;
604
1423
  const fields = [
605
1424
  "id",
606
1425
  "name",
@@ -616,23 +1435,28 @@ var MetaClient = class {
616
1435
  ].join(",");
617
1436
  const seenIds = /* @__PURE__ */ new Set();
618
1437
  const allStudies = [];
1438
+ let remainingPageBudget = MAX_PAGINATION_PAGES;
619
1439
  const fetchFromEdge = async (endpoint) => {
620
1440
  try {
1441
+ if (remainingPageBudget < 1 || allStudies.length >= MAX_PAGINATION_ROWS) return;
1442
+ remainingPageBudget -= 1;
621
1443
  const firstResponse = await this.request(
622
1444
  endpoint,
623
1445
  { fields, limit: "100" }
624
1446
  );
625
- for (const study of firstResponse.data) {
1447
+ for (const study of firstResponse.data.slice(0, MAX_PAGINATION_ROWS - allStudies.length)) {
626
1448
  if (!seenIds.has(study.id)) {
627
1449
  seenIds.add(study.id);
628
1450
  allStudies.push(study);
629
1451
  }
630
1452
  }
631
1453
  let nextUrl = firstResponse.paging?.next;
632
- while (nextUrl) {
633
- const data = await this.fetchPaginatedUrl(nextUrl);
1454
+ const expectedPathname = `/${this.apiVersion}${endpoint}`;
1455
+ while (nextUrl && remainingPageBudget > 0 && allStudies.length < MAX_PAGINATION_ROWS) {
1456
+ remainingPageBudget -= 1;
1457
+ const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
634
1458
  if (data.error) break;
635
- for (const study of data.data) {
1459
+ for (const study of data.data.slice(0, MAX_PAGINATION_ROWS - allStudies.length)) {
636
1460
  if (!seenIds.has(study.id)) {
637
1461
  seenIds.add(study.id);
638
1462
  allStudies.push(study);
@@ -648,9 +1472,9 @@ var MetaClient = class {
648
1472
  );
649
1473
  }
650
1474
  };
651
- await fetchFromEdge(`/${accountId}/ad_studies`);
1475
+ await fetchFromEdge(`/${accountId2}/ad_studies`);
652
1476
  try {
653
- const accountInfo = await this.request(`/${accountId}`, { fields: "business{id}" });
1477
+ const accountInfo = await this.request(`/${accountId2}`, { fields: "business{id}" });
654
1478
  if (accountInfo.business?.id) {
655
1479
  await fetchFromEdge(`/${accountInfo.business.id}/ad_studies`);
656
1480
  }
@@ -732,11 +1556,13 @@ var MetaClient = class {
732
1556
  async getPages() {
733
1557
  const pages = [];
734
1558
  let nextUrl = null;
1559
+ let pageCount = 1;
1560
+ const expectedPathname = `/${this.apiVersion}/me/accounts`;
735
1561
  const firstPage = await this.request("/me/accounts", {
736
1562
  fields: "id,name,picture{url}",
737
1563
  limit: "200"
738
1564
  });
739
- for (const p of firstPage.data || []) {
1565
+ for (const p of (firstPage.data || []).slice(0, MAX_PAGINATION_ROWS)) {
740
1566
  pages.push({
741
1567
  id: p.id,
742
1568
  name: p.name,
@@ -744,10 +1570,10 @@ var MetaClient = class {
744
1570
  });
745
1571
  }
746
1572
  nextUrl = firstPage.paging?.next || null;
747
- while (nextUrl) {
748
- const data = await this.fetchPaginatedUrl(nextUrl);
1573
+ while (nextUrl && pageCount < MAX_PAGINATION_PAGES && pages.length < MAX_PAGINATION_ROWS) {
1574
+ const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
749
1575
  if (data.error) break;
750
- for (const p of data.data || []) {
1576
+ for (const p of (data.data || []).slice(0, MAX_PAGINATION_ROWS - pages.length)) {
751
1577
  pages.push({
752
1578
  id: p.id,
753
1579
  name: p.name,
@@ -755,15 +1581,18 @@ var MetaClient = class {
755
1581
  });
756
1582
  }
757
1583
  nextUrl = data.paging?.next || null;
758
- if (pages.length > 1e3) break;
1584
+ pageCount += 1;
1585
+ }
1586
+ if (nextUrl) {
1587
+ throw new Error("Meta managed-page pagination exceeded its page or row safety cap.");
759
1588
  }
760
1589
  return pages;
761
1590
  }
762
1591
  /**
763
1592
  * Get Instagram accounts associated with an ad account.
764
1593
  */
765
- async getInstagramAccounts(accountId) {
766
- const formattedId = accountId.startsWith("act_") ? accountId : `act_${accountId}`;
1594
+ async getInstagramAccounts(accountId2) {
1595
+ const formattedId = accountId2.startsWith("act_") ? accountId2 : `act_${accountId2}`;
767
1596
  const accounts = [];
768
1597
  try {
769
1598
  const result = await this.request(`/${formattedId}/instagram_accounts`, {
@@ -784,8 +1613,8 @@ var MetaClient = class {
784
1613
  * Search for campaigns, ad sets, or ads by name using the Management API.
785
1614
  * Returns ALL entities regardless of spend/activity (unlike Insights API).
786
1615
  */
787
- async searchEntities(accountId, entityType, options = {}) {
788
- const formattedId = accountId.startsWith("act_") ? accountId : `act_${accountId}`;
1616
+ async searchEntities(accountId2, entityType, options = {}) {
1617
+ const formattedId = accountId2.startsWith("act_") ? accountId2 : `act_${accountId2}`;
789
1618
  const edge = entityType === "campaign" ? "campaigns" : entityType === "adset" ? "adsets" : "ads";
790
1619
  const defaultFields = {
791
1620
  campaign: [
@@ -821,7 +1650,7 @@ var MetaClient = class {
821
1650
  ]
822
1651
  };
823
1652
  const fields = options.fields?.length ? options.fields : defaultFields[entityType];
824
- const limit = options.limit || 50;
1653
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), MAX_SEARCH_ENTITY_ROWS);
825
1654
  const params = {
826
1655
  fields: fields.join(","),
827
1656
  limit: String(limit)
@@ -846,14 +1675,20 @@ var MetaClient = class {
846
1675
  }
847
1676
  const results = [];
848
1677
  let nextUrl = null;
1678
+ let pageCount = 1;
1679
+ const expectedPathname = `/${this.apiVersion}/${formattedId}/${edge}`;
849
1680
  const firstPage = await this.request(`/${formattedId}/${edge}`, params);
850
1681
  results.push(...firstPage.data || []);
851
1682
  nextUrl = firstPage.paging?.next || null;
852
- while (nextUrl && results.length < limit) {
853
- const data = await this.fetchPaginatedUrl(nextUrl);
1683
+ while (nextUrl && results.length < limit && pageCount < MAX_PAGINATION_PAGES) {
1684
+ const data = await this.fetchPaginatedUrl(nextUrl, expectedPathname);
854
1685
  if (data.error) break;
855
1686
  results.push(...data.data || []);
856
1687
  nextUrl = data.paging?.next || null;
1688
+ pageCount += 1;
1689
+ }
1690
+ if (nextUrl && results.length < limit && pageCount >= MAX_PAGINATION_PAGES) {
1691
+ throw new Error("Meta pagination exceeded the 20-page safety cap.");
857
1692
  }
858
1693
  return results.slice(0, limit);
859
1694
  }
@@ -903,20 +1738,399 @@ var MetaClient = class {
903
1738
  * Fetch a full paginated URL (used for "next" page cursors).
904
1739
  * Rate-limited via the shared limiter.
905
1740
  */
906
- async fetchPaginatedUrl(url) {
1741
+ async fetchPaginatedUrl(url, expectedPathname) {
1742
+ const trustedUrl = trustedMetaGraphUrl(url, this.apiVersion, expectedPathname);
907
1743
  return this.rateLimiter.execute(async () => {
908
- const safeUrl = assertSafeMetaGraphUrl(url, this.apiVersion);
909
- const response = await fetch(safeUrl, {
910
- redirect: "error",
1744
+ const response = await fetch(trustedUrl, {
911
1745
  headers: {
912
1746
  Authorization: `Bearer ${this.accessToken}`
913
- }
1747
+ },
1748
+ signal: AbortSignal.timeout(META_REQUEST_TIMEOUT_MS),
1749
+ redirect: "error"
914
1750
  });
915
1751
  return await response.json();
916
1752
  });
917
1753
  }
918
1754
  };
919
1755
 
1756
+ // src/platforms/meta/business-assets.ts
1757
+ var record = (x) => !!x && typeof x === "object" && !Array.isArray(x);
1758
+ async function getBusinessAssets(client, args) {
1759
+ const limit = args.limit ?? 100, maxPages = args.maxPages ?? 3;
1760
+ if (args.cursor && (args.businessId || args.adAccountId))
1761
+ throw Error(
1762
+ "Use per-edge cursors for scoped Business discovery; cursor is only for /me/businesses."
1763
+ );
1764
+ const warnings = [], coverage = {}, paging = {};
1765
+ const stores = {
1766
+ businesses: /* @__PURE__ */ new Map(),
1767
+ pages: /* @__PURE__ */ new Map(),
1768
+ instagram_accounts: /* @__PURE__ */ new Map(),
1769
+ pixels: /* @__PURE__ */ new Map(),
1770
+ datasets: /* @__PURE__ */ new Map()
1771
+ };
1772
+ let requests = 0;
1773
+ let account = null;
1774
+ const url = (path, params) => {
1775
+ const u = new URL(
1776
+ `https://graph.facebook.com/${client.apiVersion}/${path}`
1777
+ );
1778
+ for (const [k, v] of Object.entries(params))
1779
+ if (v !== void 0) u.searchParams.set(k, String(v));
1780
+ return u.toString();
1781
+ };
1782
+ const errorInfo = (e) => ({
1783
+ message: e instanceof Error ? e.message : String(e),
1784
+ ...e instanceof MetaApiException ? { code: e.code, subcode: e.subcode } : {}
1785
+ });
1786
+ const get = async (path, params) => {
1787
+ requests++;
1788
+ const r = await client.fetchUrl(url(path, params));
1789
+ if (record(r.error)) throw new MetaApiException(r.error);
1790
+ return r;
1791
+ };
1792
+ const add = (kind, items, source, context = {}) => {
1793
+ for (const item of items) {
1794
+ if (!item.id) continue;
1795
+ const id2 = String(item.id), old = stores[kind].get(id2);
1796
+ stores[kind].set(id2, {
1797
+ ...old,
1798
+ ...item,
1799
+ ...context,
1800
+ source,
1801
+ relationships: [
1802
+ ...new Map(
1803
+ [...old?.relationships ?? [], { source, ...context }].map((r) => [
1804
+ JSON.stringify(r),
1805
+ r
1806
+ ])
1807
+ ).values()
1808
+ ],
1809
+ sources: [.../* @__PURE__ */ new Set([...old?.sources ?? [], source])]
1810
+ });
1811
+ }
1812
+ };
1813
+ const node = async (path, fields) => {
1814
+ try {
1815
+ const r = await get(path, { fields });
1816
+ coverage[path] = { status: "complete", returnedCount: 1 };
1817
+ return r;
1818
+ } catch (e) {
1819
+ const error = errorInfo(e);
1820
+ coverage[path] = { status: "unavailable", returnedCount: null, error };
1821
+ warnings.push({
1822
+ area: path,
1823
+ ...error,
1824
+ suggestion: "Verify access to this exact asset; an unavailable read is not an empty result."
1825
+ });
1826
+ return null;
1827
+ }
1828
+ };
1829
+ const edge = async (path, fields, fallbackFields) => {
1830
+ const rows2 = [];
1831
+ let after = args.cursors?.[path] ?? (path === "me/businesses" ? args.cursor : void 0);
1832
+ let pages = 0;
1833
+ try {
1834
+ for (; pages < maxPages; ) {
1835
+ let r;
1836
+ try {
1837
+ r = await get(path, { fields, limit, after });
1838
+ } catch (e) {
1839
+ if (!fallbackFields || pages !== 0 || !(e instanceof MetaApiException) || ![10, 100, 200].includes(e.code))
1840
+ throw e;
1841
+ const area = `${path}:instagram_links`;
1842
+ const error = errorInfo(e);
1843
+ coverage[area] = {
1844
+ status: "unavailable",
1845
+ returnedCount: null,
1846
+ error
1847
+ };
1848
+ warnings.push({
1849
+ area,
1850
+ ...error,
1851
+ suggestion: "Page identity is retried without Instagram expansions. Missing Instagram links are unavailable, not proof that no account is linked."
1852
+ });
1853
+ fields = fallbackFields;
1854
+ fallbackFields = void 0;
1855
+ r = await get(path, { fields, limit, after });
1856
+ }
1857
+ if (!Array.isArray(r.data))
1858
+ throw Error("Meta did not return a list for this edge.");
1859
+ rows2.push(...r.data.filter(record));
1860
+ pages++;
1861
+ const pg = record(r.paging) ? r.paging : {};
1862
+ const hasMore = typeof pg.next === "string" && pg.next.length > 0;
1863
+ after = record(pg.cursors) && typeof pg.cursors.after === "string" ? pg.cursors.after : void 0;
1864
+ if (hasMore && !after) {
1865
+ const next = new URL(String(pg.next));
1866
+ if (next.origin !== new URL(`https://graph.facebook.com/${client.apiVersion}`).origin || next.pathname !== new URL(url(path, {})).pathname)
1867
+ throw Error("Untrusted pagination route.");
1868
+ after = next.searchParams.get("after") ?? void 0;
1869
+ }
1870
+ if (!hasMore) {
1871
+ coverage[path] = {
1872
+ status: rows2.length ? "complete" : "empty",
1873
+ returnedCount: rows2.length,
1874
+ pagesFetched: pages,
1875
+ hasMore: false
1876
+ };
1877
+ return rows2;
1878
+ }
1879
+ if (!after)
1880
+ throw Error(
1881
+ "Meta indicated more results without a usable edge cursor."
1882
+ );
1883
+ }
1884
+ coverage[path] = {
1885
+ status: "partial",
1886
+ returnedCount: rows2.length,
1887
+ pagesFetched: pages,
1888
+ hasMore: true
1889
+ };
1890
+ paging[path] = {
1891
+ after,
1892
+ resume: {
1893
+ ...args,
1894
+ cursor: void 0,
1895
+ cursors: { ...args.cursors, [path]: after }
1896
+ }
1897
+ };
1898
+ } catch (e) {
1899
+ const error = errorInfo(e);
1900
+ coverage[path] = {
1901
+ status: rows2.length ? "partial" : "unavailable",
1902
+ returnedCount: rows2.length || null,
1903
+ pagesFetched: pages,
1904
+ hasMore: null,
1905
+ error
1906
+ };
1907
+ warnings.push({
1908
+ area: path,
1909
+ ...error,
1910
+ suggestion: "Check this edge and its asset permissions. Missing data is not zero; inspect status and returnedCount for this edge."
1911
+ });
1912
+ }
1913
+ return rows2;
1914
+ };
1915
+ if (args.adAccountId) {
1916
+ const act2 = args.adAccountId.replace(/^act_/, "");
1917
+ if (!/^\d+$/.test(act2)) throw Error("Invalid ad account ID");
1918
+ account = await node(
1919
+ `act_${act2}`,
1920
+ "id,name,business{id,name,verification_status}"
1921
+ );
1922
+ }
1923
+ const owner = record(account?.business) ? account.business : null;
1924
+ if (args.businessId && owner && String(owner.id) !== args.businessId)
1925
+ throw Error(
1926
+ "Provided Business does not own the selected ad account. No unrelated Business was queried."
1927
+ );
1928
+ const businessId = args.businessId ?? (owner?.id ? String(owner.id) : void 0);
1929
+ if (businessId) {
1930
+ if (!/^\d+$/.test(businessId)) throw Error("Invalid Business ID");
1931
+ const b = await node(businessId, "id,name,verification_status");
1932
+ if (b) add("businesses", [b], businessId);
1933
+ else if (owner) add("businesses", [owner], "ad_account.business");
1934
+ } else if (!args.adAccountId)
1935
+ add(
1936
+ "businesses",
1937
+ await edge("me/businesses", "id,name,verification_status"),
1938
+ "me/businesses"
1939
+ );
1940
+ else
1941
+ warnings.push({
1942
+ area: "ad_account.business",
1943
+ message: "The selected account did not return its owning Business. Discovery was not broadened to unrelated Businesses.",
1944
+ suggestion: "Verify access or supply the intended businessId explicitly."
1945
+ });
1946
+ if (args.adAccountId && account)
1947
+ add(
1948
+ "pixels",
1949
+ await edge(`${account.id}/adspixels`, "id,name,last_fired_time"),
1950
+ `${account.id}/adspixels`,
1951
+ { ad_account_id: account.id }
1952
+ );
1953
+ const bs = [...stores.businesses.values()];
1954
+ if (bs.length > 5)
1955
+ warnings.push({
1956
+ area: "business_expansion",
1957
+ message: "Assets are expanded for the first five Businesses only.",
1958
+ suggestion: "Repeat with businessId for any other listed Business."
1959
+ });
1960
+ for (const b of bs.slice(0, 5)) {
1961
+ const id2 = String(b.id);
1962
+ for (const relation of ["owned_pages", "client_pages"]) {
1963
+ const path = `${id2}/${relation}`;
1964
+ const pages = await edge(
1965
+ path,
1966
+ "id,name,category,tasks,instagram_business_account{id,username,name},connected_instagram_account{id,username,name}",
1967
+ "id,name,category,tasks"
1968
+ );
1969
+ add("pages", pages, path, { business_id: id2 });
1970
+ for (const page of pages)
1971
+ for (const field of [
1972
+ "instagram_business_account",
1973
+ "connected_instagram_account"
1974
+ ])
1975
+ if (record(page[field]))
1976
+ add("instagram_accounts", [page[field]], `${path}:${field}`, {
1977
+ business_id: id2,
1978
+ page_id: page.id
1979
+ });
1980
+ }
1981
+ add(
1982
+ "instagram_accounts",
1983
+ await edge(`${id2}/owned_instagram_accounts`, "id,username,name"),
1984
+ `${id2}/owned_instagram_accounts`,
1985
+ { business_id: id2 }
1986
+ );
1987
+ const igAssets = await edge(
1988
+ `${id2}/client_instagram_assets`,
1989
+ "id,ig_user_id,ig_username"
1990
+ );
1991
+ for (const a of igAssets)
1992
+ if (a.ig_user_id)
1993
+ add(
1994
+ "instagram_accounts",
1995
+ [
1996
+ {
1997
+ id: String(a.ig_user_id),
1998
+ username: a.ig_username,
1999
+ business_asset_id: a.id
2000
+ }
2001
+ ],
2002
+ `${id2}/client_instagram_assets`,
2003
+ { business_id: id2 }
2004
+ );
2005
+ for (const relation of ["owned_pixels", "client_pixels"])
2006
+ add(
2007
+ "pixels",
2008
+ await edge(`${id2}/${relation}`, "id,name,last_fired_time"),
2009
+ `${id2}/${relation}`,
2010
+ { business_id: id2 }
2011
+ );
2012
+ add(
2013
+ "datasets",
2014
+ await edge(`${id2}/ads_dataset`, "id,name"),
2015
+ `${id2}/ads_dataset`,
2016
+ { business_id: id2 }
2017
+ );
2018
+ }
2019
+ const unavailable = Object.entries(coverage).filter(([, c]) => c.status === "unavailable" || c.status === "partial").map(([edge2]) => edge2);
2020
+ const datasetsQueried = Object.entries(coverage).filter(
2021
+ ([p]) => p.endsWith("/ads_dataset")
2022
+ );
2023
+ return {
2024
+ scope: {
2025
+ mode: args.adAccountId ? "ad_account" : args.businessId ? "business" : "accessible_businesses",
2026
+ requested_ad_account_id: args.adAccountId ?? null,
2027
+ requested_business_id: args.businessId ?? null
2028
+ },
2029
+ ad_account: account,
2030
+ account_business: owner,
2031
+ ...Object.fromEntries(
2032
+ Object.entries(stores).map(([k, v]) => [k, [...v.values()]])
2033
+ ),
2034
+ counts: {
2035
+ ...Object.fromEntries(
2036
+ Object.entries(stores).map(([k, v]) => [k, v.size])
2037
+ ),
2038
+ datasets: !datasetsQueried.length || datasetsQueried.every(([, c]) => c.status === "unavailable") ? null : stores.datasets.size
2039
+ },
2040
+ coverage,
2041
+ paging,
2042
+ warnings,
2043
+ limitations: [
2044
+ ...unavailable.map((edge2) => ({ edge: edge2, status: coverage[edge2].status })),
2045
+ ...bs.length > 5 ? [{ area: "business_expansion", status: "partial" }] : []
2046
+ ],
2047
+ notes: [
2048
+ "Counts describe returned records, not the size of inaccessible inventories. Inspect per-edge coverage.",
2049
+ "Source edges show owned/client/linked relationships; presence in a Business inventory does not prove assignment to the selected ad account.",
2050
+ "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.",
2051
+ "Dataset discovery uses Business ads_dataset; it is not the removed offline_conversion_data_sets edge."
2052
+ ],
2053
+ nextActions: Object.entries(paging).map(([edge2, p]) => ({
2054
+ edge: edge2,
2055
+ tool: "meta_get_business_assets",
2056
+ arguments: p.resume
2057
+ })),
2058
+ debug: { requestCount: requests }
2059
+ };
2060
+ }
2061
+
2062
+ // src/platforms/meta/organic-insights.ts
2063
+ function instagramInsightMetrics(media) {
2064
+ const type = media.media_type;
2065
+ const product = media.media_product_type;
2066
+ if (product === "STORY") return ["views", "reach"];
2067
+ const feed = ["views", "reach", "likes", "comments", "saved", "shares", "total_interactions"];
2068
+ if (product === "REELS" && type === "VIDEO") {
2069
+ return [...feed, "ig_reels_video_view_total_time", "ig_reels_avg_watch_time"];
2070
+ }
2071
+ if (type === "IMAGE" || type === "CAROUSEL_ALBUM") return feed;
2072
+ return ["reach"];
2073
+ }
2074
+
2075
+ // src/platforms/meta/creative-media.ts
2076
+ var obj2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
2077
+ var rows = (v) => Array.isArray(v) ? v.map(obj2) : [];
2078
+ var str = (...vs) => vs.find((v) => typeof v === "string" && v.length > 0 && !v.includes("{{"));
2079
+ function metaImageFileUrl(...values) {
2080
+ for (const value of values) {
2081
+ if (typeof value !== "string" || !value || value.includes("{{")) continue;
2082
+ try {
2083
+ const url = new URL(value);
2084
+ if (!/^https?:$/.test(url.protocol)) continue;
2085
+ if (/(^|\.)(facebook\.com|fb\.com)$/.test(url.hostname)) continue;
2086
+ return value;
2087
+ } catch {
2088
+ }
2089
+ }
2090
+ }
2091
+ function metaCreativeMedia(creative) {
2092
+ const story = obj2(creative.object_story_spec), feed = obj2(creative.asset_feed_spec);
2093
+ const link = obj2(story.link_data), video = obj2(story.video_data), photo = obj2(story.photo_data), template = obj2(story.template_data);
2094
+ const catalog = Boolean(creative.product_set_id || link.product_set_id || template.product_set_id || feed.product_set_id || Object.keys(template).length);
2095
+ const media = [];
2096
+ const add = (entry, fallback) => {
2097
+ const video_id = str(entry.video_id);
2098
+ const image_hash = str(entry.image_hash, entry.hash);
2099
+ const image_url = metaImageFileUrl(entry.image_url, entry.picture, entry.url);
2100
+ if (!video_id && !image_hash && !image_url) return;
2101
+ media.push({
2102
+ media_id: `${video_id ? "video" : "image"}:${video_id ?? image_hash ?? image_url}`,
2103
+ video_id,
2104
+ image_hash,
2105
+ image_url,
2106
+ thumbnail_url: metaImageFileUrl(entry.thumbnail_url, image_url, fallback?.thumbnail_url),
2107
+ source: str(entry.source)
2108
+ });
2109
+ };
2110
+ const children = rows(link.child_attachments);
2111
+ if (catalog) {
2112
+ add(video);
2113
+ add(link);
2114
+ add(template);
2115
+ } else if (children.length) {
2116
+ children.forEach((child) => add(child));
2117
+ } else {
2118
+ add(video, creative);
2119
+ add(photo, creative);
2120
+ add(link, creative);
2121
+ }
2122
+ if (!catalog) {
2123
+ rows(feed.images).forEach((image) => add(image));
2124
+ rows(feed.videos).forEach((item) => add(item));
2125
+ if (!media.length) {
2126
+ add(creative);
2127
+ if (!media.length && metaImageFileUrl(creative.thumbnail_url)) media.push({ media_id: String(creative.id ?? "preview"), thumbnail_url: metaImageFileUrl(creative.thumbnail_url) });
2128
+ }
2129
+ }
2130
+ const unique = [...new Map(media.map((item) => [item.media_id, item])).values()];
2131
+ return { format: catalog ? unique.length ? "collection" : "catalog_dpa" : children.length ? "carousel" : unique.length > 1 ? "flexible" : "single", media: unique };
2132
+ }
2133
+
920
2134
  // src/platforms/meta/metric-catalog.ts
921
2135
  var ALL_BREAKDOWNS = [
922
2136
  "age",
@@ -5762,11 +6976,11 @@ function calculateDerivedMetrics(row) {
5762
6976
  }
5763
6977
 
5764
6978
  // src/platforms/meta/broad-read.ts
5765
- import { z } from "zod";
5766
- var graphIdSchema = z.string().trim().min(1).max(200).regex(/^[A-Za-z0-9_.:-]+$/, "Use a Graph node ID without slashes or query parameters");
5767
- var graphFieldSchema = z.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Graph field name");
5768
- var graphDimensionSchema = z.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Insights field or breakdown name");
5769
- var dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
6979
+ import { z as z3 } from "zod";
6980
+ var graphIdSchema = z3.string().trim().min(1).max(200).regex(/^[A-Za-z0-9_.:-]+$/, "Use a Graph node ID without slashes or query parameters");
6981
+ var graphFieldSchema = z3.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Graph field name");
6982
+ var graphDimensionSchema = z3.string().trim().min(1).max(100).regex(/^[A-Za-z][A-Za-z0-9_]*$/, "Use a plain Insights field or breakdown name");
6983
+ var dateSchema = z3.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
5770
6984
  var META_BROAD_READ_EDGES = [
5771
6985
  "accounts",
5772
6986
  "activities",
@@ -5816,16 +7030,16 @@ var META_BROAD_READ_EDGES = [
5816
7030
  "cells",
5817
7031
  "objectives"
5818
7032
  ];
5819
- var readEdgeSchema = z.enum(META_BROAD_READ_EDGES);
5820
- var filterValueSchema = z.union([
5821
- z.string().max(2e3),
5822
- z.number().finite(),
5823
- z.boolean(),
5824
- z.array(z.union([z.string().max(500), z.number().finite(), z.boolean()])).max(500)
7033
+ var readEdgeSchema = z3.enum(META_BROAD_READ_EDGES);
7034
+ var filterValueSchema = z3.union([
7035
+ z3.string().max(2e3),
7036
+ z3.number().finite(),
7037
+ z3.boolean(),
7038
+ z3.array(z3.union([z3.string().max(500), z3.number().finite(), z3.boolean()])).max(500)
5825
7039
  ]);
5826
- var graphFilterSchema = z.object({
5827
- field: z.string().trim().min(1).max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*$/, "Invalid filtering field"),
5828
- operator: z.enum([
7040
+ var graphFilterSchema = z3.object({
7041
+ field: z3.string().trim().min(1).max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*$/, "Invalid filtering field"),
7042
+ operator: z3.enum([
5829
7043
  "EQUAL",
5830
7044
  "NOT_EQUAL",
5831
7045
  "GREATER_THAN",
@@ -5846,7 +7060,7 @@ var graphFilterSchema = z.object({
5846
7060
  ]),
5847
7061
  value: filterValueSchema
5848
7062
  });
5849
- var datePresetSchema = z.enum([
7063
+ var datePresetSchema = z3.enum([
5850
7064
  "today",
5851
7065
  "yesterday",
5852
7066
  "this_month",
@@ -5868,7 +7082,7 @@ var datePresetSchema = z.enum([
5868
7082
  "this_week_sun_today",
5869
7083
  "this_year"
5870
7084
  ]);
5871
- var targetingSearchTypeSchema = z.enum([
7085
+ var targetingSearchTypeSchema = z3.enum([
5872
7086
  "adinterest",
5873
7087
  "adbehaviors",
5874
7088
  "adinterestsuggestion",
@@ -5946,8 +7160,8 @@ function registerMetaBroadReadTools(server, client, ok3) {
5946
7160
  "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.",
5947
7161
  {
5948
7162
  nodeId: graphIdSchema.describe("Graph node ID, for example a campaign, ad set, ad, creative, Page, IG account, pixel, audience, or catalog ID"),
5949
- fields: z.array(graphFieldSchema).min(1).max(100).describe("Flat Graph field names; nested field expansion is intentionally disabled"),
5950
- includeMetadata: z.boolean().optional().default(false).describe("Ask Graph for field metadata when supported")
7163
+ fields: z3.array(graphFieldSchema).min(1).max(100).describe("Flat Graph field names; nested field expansion is intentionally disabled"),
7164
+ includeMetadata: z3.boolean().optional().default(false).describe("Ask Graph for field metadata when supported")
5951
7165
  },
5952
7166
  async ({ nodeId, fields, includeMetadata }) => {
5953
7167
  try {
@@ -5968,15 +7182,15 @@ function registerMetaBroadReadTools(server, client, ok3) {
5968
7182
  {
5969
7183
  parentId: graphIdSchema.describe("Parent Graph node ID, such as act_123, a Business, Page, catalog, campaign, or ad set ID"),
5970
7184
  edge: readEdgeSchema,
5971
- fields: z.array(graphFieldSchema).min(1).max(100).optional(),
5972
- filtering: z.array(graphFilterSchema).max(20).optional(),
5973
- parameters: z.record(z.unknown()).optional().describe("Additional documented GET parameters, for example targeting_spec or optimization_goal; auth and HTTP method override parameters are blocked"),
7185
+ fields: z3.array(graphFieldSchema).min(1).max(100).optional(),
7186
+ filtering: z3.array(graphFilterSchema).max(20).optional(),
7187
+ parameters: z3.record(z3.unknown()).optional().describe("Additional documented GET parameters, for example targeting_spec or optimization_goal; auth and HTTP method override parameters are blocked"),
5974
7188
  since: dateSchema.optional(),
5975
7189
  until: dateSchema.optional(),
5976
- limit: z.number().int().min(1).max(500).optional().default(100),
5977
- after: z.string().max(2e3).optional(),
5978
- before: z.string().max(2e3).optional(),
5979
- includeSummary: z.boolean().optional().default(false)
7190
+ limit: z3.number().int().min(1).max(500).optional().default(100),
7191
+ after: z3.string().max(2e3).optional(),
7192
+ before: z3.string().max(2e3).optional(),
7193
+ includeSummary: z3.boolean().optional().default(false)
5980
7194
  },
5981
7195
  async ({ parentId, edge, fields, filtering, parameters, since, until, limit, after, before, includeSummary }) => {
5982
7196
  try {
@@ -6005,19 +7219,19 @@ function registerMetaBroadReadTools(server, client, ok3) {
6005
7219
  "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.",
6006
7220
  {
6007
7221
  objectId: graphIdSchema.describe("Ad account (act_...), campaign, ad set, or ad ID whose /insights edge should be queried"),
6008
- fields: z.array(graphDimensionSchema).min(1).max(100).describe("Native Meta Insights API fields; calculated aliases are not accepted here"),
6009
- level: z.enum(["account", "campaign", "adset", "ad"]).optional(),
6010
- breakdowns: z.array(graphDimensionSchema).max(20).optional(),
6011
- actionBreakdowns: z.array(graphDimensionSchema).max(20).optional(),
6012
- actionAttributionWindows: z.array(z.enum(["1d_click", "7d_click", "28d_click", "1d_view", "1d_ev", "dda", "skan_click", "skan_view"])).max(8).optional(),
7222
+ fields: z3.array(graphDimensionSchema).min(1).max(100).describe("Native Meta Insights API fields; calculated aliases are not accepted here"),
7223
+ level: z3.enum(["account", "campaign", "adset", "ad"]).optional(),
7224
+ breakdowns: z3.array(graphDimensionSchema).max(20).optional(),
7225
+ actionBreakdowns: z3.array(graphDimensionSchema).max(20).optional(),
7226
+ actionAttributionWindows: z3.array(z3.enum(["1d_click", "7d_click", "28d_click", "1d_view", "1d_ev", "dda", "skan_click", "skan_view"])).max(8).optional(),
6013
7227
  datePreset: datePresetSchema.optional(),
6014
- timeRange: z.object({ since: dateSchema, until: dateSchema }).optional(),
6015
- timeIncrement: z.union([z.number().int().min(1).max(90), z.enum(["monthly", "all_days"])]).optional(),
6016
- filtering: z.array(graphFilterSchema).max(20).optional(),
6017
- sort: z.string().trim().max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*_(ascending|descending)$/).optional(),
6018
- limit: z.number().int().min(1).max(500).optional().default(100),
6019
- after: z.string().max(2e3).optional(),
6020
- includeSummary: z.boolean().optional().default(false)
7228
+ timeRange: z3.object({ since: dateSchema, until: dateSchema }).optional(),
7229
+ timeIncrement: z3.union([z3.number().int().min(1).max(90), z3.enum(["monthly", "all_days"])]).optional(),
7230
+ filtering: z3.array(graphFilterSchema).max(20).optional(),
7231
+ sort: z3.string().trim().max(150).regex(/^[A-Za-z][A-Za-z0-9_.]*_(ascending|descending)$/).optional(),
7232
+ limit: z3.number().int().min(1).max(500).optional().default(100),
7233
+ after: z3.string().max(2e3).optional(),
7234
+ includeSummary: z3.boolean().optional().default(false)
6021
7235
  },
6022
7236
  async ({ objectId, fields, level, breakdowns, actionBreakdowns, actionAttributionWindows, datePreset, timeRange, timeIncrement, filtering, sort, limit, after, includeSummary }) => {
6023
7237
  try {
@@ -6054,10 +7268,10 @@ function registerMetaBroadReadTools(server, client, ok3) {
6054
7268
  "Search Meta's read-only targeting metadata for interests, validated interests, geographies, locales, countries, cities, regions, markets, or postal codes.",
6055
7269
  {
6056
7270
  type: targetingSearchTypeSchema,
6057
- query: z.string().trim().min(1).max(200).optional().describe("Search text; required by most interest and geography searches"),
6058
- countryCode: z.string().trim().length(2).transform((value) => value.toUpperCase()).optional(),
6059
- locationTypes: z.array(z.enum(["country", "country_group", "region", "city", "zip", "geo_market", "electoral_district"])).max(7).optional(),
6060
- limit: z.number().int().min(1).max(1e3).optional().default(100)
7271
+ query: z3.string().trim().min(1).max(200).optional().describe("Search text; required by most interest and geography searches"),
7272
+ countryCode: z3.string().trim().length(2).transform((value) => value.toUpperCase()).optional(),
7273
+ locationTypes: z3.array(z3.enum(["country", "country_group", "region", "city", "zip", "geo_market", "electoral_district"])).max(7).optional(),
7274
+ limit: z3.number().int().min(1).max(1e3).optional().default(100)
6061
7275
  },
6062
7276
  async ({ type, query, countryCode, locationTypes, limit }) => {
6063
7277
  try {
@@ -6083,7 +7297,7 @@ function registerMetaBroadReadTools(server, client, ok3) {
6083
7297
  "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.",
6084
7298
  {
6085
7299
  adId: graphIdSchema,
6086
- adFormat: z.string().trim().min(1).max(100).regex(/^[A-Z][A-Z0-9_]*$/).optional().default("DESKTOP_FEED_STANDARD")
7300
+ adFormat: z3.string().trim().min(1).max(100).regex(/^[A-Z][A-Z0-9_]*$/).optional().default("DESKTOP_FEED_STANDARD")
6087
7301
  },
6088
7302
  async ({ adId, adFormat }) => {
6089
7303
  try {
@@ -6097,12 +7311,12 @@ function registerMetaBroadReadTools(server, client, ok3) {
6097
7311
  }
6098
7312
 
6099
7313
  // src/platforms/meta/tools.ts
6100
- var adAccountIdSchema = z2.string().describe("Ad account ID (e.g., act_123456789)");
6101
- var levelSchema = z2.enum(["account", "campaign", "adset", "ad"]);
6102
- var statusFilterSchema = z2.enum(["ACTIVE", "PAUSED", "DELETED", "ARCHIVED"]).optional().describe("Filter by entity status");
6103
- var limitSchema = z2.number().int().min(1).max(500).optional().default(100);
6104
- var cursorSchema = z2.string().optional().describe("Pagination cursor from previous response");
6105
- var productBreakdownSchema = z2.enum([
7314
+ var adAccountIdSchema = z4.string().describe("Ad account ID (e.g., act_123456789)");
7315
+ var levelSchema = z4.enum(["account", "campaign", "adset", "ad"]);
7316
+ var statusFilterSchema = z4.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.");
7317
+ var limitSchema = z4.number().int().min(1).max(500).optional().default(100);
7318
+ var cursorSchema = z4.string().optional().describe("Pagination cursor from previous response");
7319
+ var productBreakdownSchema = z4.enum([
6106
7320
  "product_id",
6107
7321
  "product_brand_breakdown",
6108
7322
  "product_category_breakdown",
@@ -6114,7 +7328,7 @@ var productBreakdownSchema = z2.enum([
6114
7328
  "product_custom_label_4_breakdown",
6115
7329
  "product_group_content_id_breakdown"
6116
7330
  ]);
6117
- var datePresetSchema2 = z2.enum([
7331
+ var datePresetSchema2 = z4.enum([
6118
7332
  "today",
6119
7333
  "yesterday",
6120
7334
  "this_month",
@@ -6136,9 +7350,9 @@ var datePresetSchema2 = z2.enum([
6136
7350
  "this_week_sun_today",
6137
7351
  "this_year"
6138
7352
  ]).optional();
6139
- var timeRangeSchema = z2.object({
6140
- since: z2.string().describe("Start date YYYY-MM-DD"),
6141
- until: z2.string().describe("End date YYYY-MM-DD")
7353
+ var timeRangeSchema = z4.object({
7354
+ since: z4.string().describe("Start date YYYY-MM-DD"),
7355
+ until: z4.string().describe("End date YYYY-MM-DD")
6142
7356
  }).optional();
6143
7357
  var META_GRAPH_BASE = "https://graph.facebook.com";
6144
7358
  var REQUIRED_READ_SCOPES = ["ads_read"];
@@ -6272,10 +7486,46 @@ async function fetchGraphWithFallback(client, area, primaryUrl, fallbackUrl, war
6272
7486
  }
6273
7487
  }
6274
7488
  }
7489
+ function pageClientResolver(client, warnings) {
7490
+ const pageClients = /* @__PURE__ */ new Map();
7491
+ const pageClientFor = async (id2) => {
7492
+ if (!pageClients.has(id2)) {
7493
+ const result = await fetchGraph(
7494
+ client,
7495
+ "organic_page_token",
7496
+ graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: "access_token" }),
7497
+ warnings,
7498
+ "Grant pages_show_list/pages_read_engagement and access to this Page, then reconnect Meta if necessary."
7499
+ );
7500
+ const token = typeof result?.access_token === "string" ? result.access_token : void 0;
7501
+ pageClients.set(id2, token ? new MetaClient(token, client.apiVersion) : null);
7502
+ 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." });
7503
+ }
7504
+ return pageClients.get(id2) ?? null;
7505
+ };
7506
+ return pageClientFor;
7507
+ }
6275
7508
  function dataArray(result) {
6276
7509
  const data = result?.data;
6277
7510
  return Array.isArray(data) ? data.filter(isRecord) : [];
6278
7511
  }
7512
+ function entityListResult(result, statusFilter) {
7513
+ return {
7514
+ ...result,
7515
+ coverage: {
7516
+ scope: "returned_page",
7517
+ returned_count: dataArray(result).length,
7518
+ has_more: !!(isRecord(result.paging) && result.paging.next),
7519
+ status_filter_field: statusFilter ? "effective_status" : null,
7520
+ status_filter: statusFilter ?? null
7521
+ },
7522
+ notes: [
7523
+ "Follow paging.cursors.after while paging.next is present. A limited or filtered listing does not prove that an object does not exist.",
7524
+ "status is configured status; effective_status can differ during processing or due to a paused parent. Omit statusFilter when verifying a newly created object.",
7525
+ "An empty listing alone does not establish indexing lag or its cause. Read known campaign IDs with meta_get_campaign_structure."
7526
+ ]
7527
+ };
7528
+ }
6279
7529
  function pagingInfo(result) {
6280
7530
  return isRecord(result?.paging) ? result.paging : void 0;
6281
7531
  }
@@ -6288,11 +7538,41 @@ function pictureUrl(value) {
6288
7538
  }
6289
7539
  return void 0;
6290
7540
  }
7541
+ function thumbnailSummary(value) {
7542
+ if (!value || typeof value.uri !== "string") return void 0;
7543
+ return { uri: value.uri, width: value.width, height: value.height };
7544
+ }
7545
+ function normalizeAdVideo(video, includeSource) {
7546
+ const status = isRecord(video.status) ? video.status : void 0;
7547
+ const thumbnailEdge = isRecord(video.thumbnails) ? video.thumbnails.data : void 0;
7548
+ const thumbnails = Array.isArray(thumbnailEdge) ? thumbnailEdge.filter(isRecord) : [];
7549
+ const preferred = thumbnails.find((thumb) => thumb.is_preferred === true) ?? thumbnails[0];
7550
+ let largest;
7551
+ for (const thumb of thumbnails) {
7552
+ if (!largest || (Number(thumb.width) || 0) > (Number(largest.width) || 0)) largest = thumb;
7553
+ }
7554
+ const from = isRecord(video.from) ? video.from : void 0;
7555
+ return {
7556
+ id: video.id,
7557
+ title: video.title,
7558
+ video_status: typeof status?.video_status === "string" ? status.video_status : void 0,
7559
+ duration_seconds: typeof video.length === "number" ? video.length : void 0,
7560
+ owner: from ? { id: from.id, name: from.name } : void 0,
7561
+ created_time: video.created_time,
7562
+ updated_time: video.updated_time,
7563
+ picture: video.picture,
7564
+ permalink_url: video.permalink_url,
7565
+ thumbnail_preferred: thumbnailSummary(preferred),
7566
+ thumbnail_largest: thumbnailSummary(largest),
7567
+ thumbnail_count: thumbnails.length,
7568
+ ...includeSource ? { source: video.source } : {}
7569
+ };
7570
+ }
6291
7571
  function addById(target, items) {
6292
7572
  for (const item of items) {
6293
- const id = typeof item.id === "string" ? item.id : void 0;
6294
- if (!id) continue;
6295
- target.set(id, { ...target.get(id) ?? {}, ...item });
7573
+ const id2 = typeof item.id === "string" ? item.id : void 0;
7574
+ if (!id2) continue;
7575
+ target.set(id2, { ...target.get(id2) ?? {}, ...item });
6296
7576
  }
6297
7577
  }
6298
7578
  function normalizePage(page, source) {
@@ -6368,6 +7648,10 @@ function normalizeCreativeAsset(source, sourceType) {
6368
7648
  const photoData = isRecord(spec.photo_data) ? spec.photo_data : void 0;
6369
7649
  const templateData = isRecord(spec.template_data) ? spec.template_data : void 0;
6370
7650
  return {
7651
+ ...(() => {
7652
+ const media = metaCreativeMedia(creative);
7653
+ return { creative_format: media.format, media: media.media };
7654
+ })(),
6371
7655
  source_type: sourceType,
6372
7656
  ad_id: sourceType === "ad" ? source.id : void 0,
6373
7657
  ad_name: sourceType === "ad" ? source.name : void 0,
@@ -6702,6 +7986,7 @@ function normalizeInstagramMedia(media) {
6702
7986
  id: media.id,
6703
7987
  caption: media.caption,
6704
7988
  media_type: media.media_type,
7989
+ media_product_type: media.media_product_type,
6705
7990
  media_url: media.media_url,
6706
7991
  thumbnail_url: media.thumbnail_url,
6707
7992
  permalink: media.permalink,
@@ -6841,256 +8126,51 @@ function registerMetaTools(server, config) {
6841
8126
  "meta_get_business_assets",
6842
8127
  "Discover accessible Meta Business assets read-only: businesses, pages, Instagram accounts, pixels, and datasets when permissions allow.",
6843
8128
  {
6844
- businessId: z2.string().optional().describe("Business Manager ID. If omitted, the tool lists /me/businesses and uses ad account business metadata when available."),
6845
- adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID to discover account-level pixels/datasets and related business."),
8129
+ businessId: z4.string().optional().describe("Business Manager ID. With adAccountId, discover only its owning Business; otherwise list /me/businesses when this is omitted."),
8130
+ adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID to discover its owner Business and assigned pixels; datasets are read from that Business when permitted."),
6846
8131
  limit: limitSchema,
8132
+ cursor: cursorSchema.describe("Legacy cursor for /me/businesses only. Use cursors for asset edges."),
8133
+ cursors: z4.record(z4.string(), z4.string()).optional().describe("Per-edge cursors from paging/nextActions. Never reuse a cursor for another edge."),
8134
+ maxPages: z4.number().int().min(1).max(5).default(3).describe("Maximum pages per edge; remaining pages are reported explicitly.")
8135
+ },
8136
+ async (args) => {
8137
+ try {
8138
+ return ok(await getBusinessAssets(client, args));
8139
+ } catch (e) {
8140
+ return formatMcpToolError(e);
8141
+ }
8142
+ }
8143
+ );
8144
+ server.tool(
8145
+ "meta_get_pages",
8146
+ "List accessible Facebook Pages with id, name, category, tasks, picture, and linked Instagram account references when available.",
8147
+ {
8148
+ businessId: z4.string().optional().describe("Optional Business Manager ID to list owned/client pages. Omit to use /me/accounts."),
8149
+ limit: z4.number().int().min(1).max(200).optional().default(100),
6847
8150
  cursor: cursorSchema
6848
8151
  },
6849
- async ({ businessId, adAccountId, limit, cursor }) => {
8152
+ async ({ businessId, limit, cursor }) => {
6850
8153
  const warnings = [];
6851
- const businessIds = /* @__PURE__ */ new Set();
6852
- const businessesById = /* @__PURE__ */ new Map();
6853
8154
  const pagesById = /* @__PURE__ */ new Map();
6854
- const instagramById = /* @__PURE__ */ new Map();
6855
- const pixelsById = /* @__PURE__ */ new Map();
6856
- const datasetsById = /* @__PURE__ */ new Map();
6857
- const businessFields = "id,name,verification_status,created_time,updated_time";
6858
- 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}";
6859
- const pageFallbackFields = "id,name,category,tasks,picture{url}";
6860
- const instagramFields = "id,username,name,ig_id,profile_picture_url";
6861
- const pixelFields = "id,name,last_fired_time,creation_time,owner_ad_account,business";
6862
- const pixelFallbackFields = "id,name,last_fired_time,creation_time";
6863
- const datasetFields = "id,name,description,creation_time,updated_time";
6864
- const datasetFallbackFields = "id,name";
8155
+ 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}";
8156
+ const fallbackFields = "id,name,category,tasks,picture{url}";
8157
+ const paging = {};
6865
8158
  if (businessId) {
6866
- businessIds.add(businessId);
6867
- const business = await fetchGraph(
8159
+ const ownedPages = await fetchGraphWithFallback(
6868
8160
  client,
6869
- "business",
6870
- graphUrl(client, `/${businessId}`, { fields: businessFields }),
8161
+ "owned_pages",
8162
+ graphUrl(client, `/${businessId}/owned_pages`, { fields, limit, after: cursor }),
8163
+ graphUrl(client, `/${businessId}/owned_pages`, { fields: fallbackFields, limit, after: cursor }),
6871
8164
  warnings,
6872
- "Grant business_management or provide a Business ID the token can read."
8165
+ "Grant business_management plus pages_show_list/pages_read_engagement to read Business-owned Pages."
6873
8166
  );
6874
- if (business) businessesById.set(String(business.id ?? businessId), business);
6875
- } else {
6876
- const businesses = await fetchGraph(
8167
+ const clientPages = await fetchGraphWithFallback(
6877
8168
  client,
6878
- "businesses",
6879
- graphUrl(client, "/me/businesses", { fields: businessFields, limit, after: cursor }),
8169
+ "client_pages",
8170
+ graphUrl(client, `/${businessId}/client_pages`, { fields, limit, after: cursor }),
8171
+ graphUrl(client, `/${businessId}/client_pages`, { fields: fallbackFields, limit, after: cursor }),
6880
8172
  warnings,
6881
- "Grant business_management to list Business Manager assets. You can still provide businessId or adAccountId directly."
6882
- );
6883
- for (const business of dataArray(businesses)) {
6884
- const id = typeof business.id === "string" ? business.id : void 0;
6885
- if (!id) continue;
6886
- businessIds.add(id);
6887
- businessesById.set(id, business);
6888
- }
6889
- }
6890
- if (adAccountId) {
6891
- const accountBusiness = await fetchGraph(
6892
- client,
6893
- "ad_account_business",
6894
- graphUrl(client, `/${formatAdAccountId(adAccountId)}`, {
6895
- fields: "id,name,business{id,name,verification_status}"
6896
- }),
6897
- warnings,
6898
- "Grant ads_read and business access for the selected ad account."
6899
- );
6900
- const business = isRecord(accountBusiness?.business) ? accountBusiness.business : void 0;
6901
- if (business && typeof business.id === "string") {
6902
- businessIds.add(business.id);
6903
- businessesById.set(business.id, business);
6904
- }
6905
- const adAccountPixels = await fetchGraphWithFallback(
6906
- client,
6907
- "ad_account_pixels",
6908
- graphUrl(client, `/${formatAdAccountId(adAccountId)}/adspixels`, {
6909
- fields: pixelFields,
6910
- limit,
6911
- after: cursor
6912
- }),
6913
- graphUrl(client, `/${formatAdAccountId(adAccountId)}/adspixels`, {
6914
- fields: pixelFallbackFields,
6915
- limit,
6916
- after: cursor
6917
- }),
6918
- warnings,
6919
- "Grant ads_read and pixel access on the ad account to read pixel metadata."
6920
- );
6921
- addById(pixelsById, dataArray(adAccountPixels));
6922
- const offlineDatasets = await fetchGraphWithFallback(
6923
- client,
6924
- "ad_account_offline_datasets",
6925
- graphUrl(client, `/${formatAdAccountId(adAccountId)}/offline_conversion_data_sets`, {
6926
- fields: datasetFields,
6927
- limit,
6928
- after: cursor
6929
- }),
6930
- graphUrl(client, `/${formatAdAccountId(adAccountId)}/offline_conversion_data_sets`, {
6931
- fields: datasetFallbackFields,
6932
- limit,
6933
- after: cursor
6934
- }),
6935
- warnings,
6936
- "Offline dataset access may require business-level permissions; provide businessId if available."
6937
- );
6938
- addById(datasetsById, dataArray(offlineDatasets));
6939
- }
6940
- for (const id of businessIds) {
6941
- const [
6942
- ownedPages,
6943
- clientPages,
6944
- ownedInstagram,
6945
- clientInstagram,
6946
- ownedPixels,
6947
- clientPixels,
6948
- ownedDatasets,
6949
- clientDatasets
6950
- ] = await Promise.all([
6951
- fetchGraphWithFallback(
6952
- client,
6953
- `business:${id}:owned_pages`,
6954
- graphUrl(client, `/${id}/owned_pages`, { fields: pageFields, limit, after: cursor }),
6955
- graphUrl(client, `/${id}/owned_pages`, { fields: pageFallbackFields, limit, after: cursor }),
6956
- warnings,
6957
- "Grant business_management plus pages_show_list/pages_read_engagement to read owned Pages."
6958
- ),
6959
- fetchGraphWithFallback(
6960
- client,
6961
- `business:${id}:client_pages`,
6962
- graphUrl(client, `/${id}/client_pages`, { fields: pageFields, limit, after: cursor }),
6963
- graphUrl(client, `/${id}/client_pages`, { fields: pageFallbackFields, limit, after: cursor }),
6964
- warnings,
6965
- "Client Page access may require Business Manager partner permissions."
6966
- ),
6967
- fetchGraph(
6968
- client,
6969
- `business:${id}:owned_instagram_accounts`,
6970
- graphUrl(client, `/${id}/owned_instagram_accounts`, { fields: instagramFields, limit, after: cursor }),
6971
- warnings,
6972
- "Grant business_management and instagram_basic to read owned Instagram accounts."
6973
- ),
6974
- fetchGraph(
6975
- client,
6976
- `business:${id}:client_instagram_accounts`,
6977
- graphUrl(client, `/${id}/client_instagram_accounts`, { fields: instagramFields, limit, after: cursor }),
6978
- warnings,
6979
- "Client Instagram access may require Business Manager partner permissions and instagram_basic."
6980
- ),
6981
- fetchGraphWithFallback(
6982
- client,
6983
- `business:${id}:owned_pixels`,
6984
- graphUrl(client, `/${id}/owned_pixels`, { fields: pixelFields, limit, after: cursor }),
6985
- graphUrl(client, `/${id}/owned_pixels`, { fields: pixelFallbackFields, limit, after: cursor }),
6986
- warnings,
6987
- "Grant business_management and asset access to read owned pixels."
6988
- ),
6989
- fetchGraphWithFallback(
6990
- client,
6991
- `business:${id}:client_pixels`,
6992
- graphUrl(client, `/${id}/client_pixels`, { fields: pixelFields, limit, after: cursor }),
6993
- graphUrl(client, `/${id}/client_pixels`, { fields: pixelFallbackFields, limit, after: cursor }),
6994
- warnings,
6995
- "Client pixel access may require Business Manager partner permissions."
6996
- ),
6997
- fetchGraphWithFallback(
6998
- client,
6999
- `business:${id}:owned_data_sets`,
7000
- graphUrl(client, `/${id}/owned_data_sets`, { fields: datasetFields, limit, after: cursor }),
7001
- graphUrl(client, `/${id}/owned_data_sets`, { fields: datasetFallbackFields, limit, after: cursor }),
7002
- warnings,
7003
- "Dataset edges vary by Meta account setup; try adAccountId fallback or verify business asset permissions."
7004
- ),
7005
- fetchGraphWithFallback(
7006
- client,
7007
- `business:${id}:client_data_sets`,
7008
- graphUrl(client, `/${id}/client_data_sets`, { fields: datasetFields, limit, after: cursor }),
7009
- graphUrl(client, `/${id}/client_data_sets`, { fields: datasetFallbackFields, limit, after: cursor }),
7010
- warnings,
7011
- "Client dataset access may require partner permissions or may not be available for this business."
7012
- )
7013
- ]);
7014
- addById(pagesById, dataArray(ownedPages).map((page) => normalizePage(page, `business:${id}:owned_pages`)));
7015
- addById(pagesById, dataArray(clientPages).map((page) => normalizePage(page, `business:${id}:client_pages`)));
7016
- for (const page of [...dataArray(ownedPages), ...dataArray(clientPages)]) {
7017
- const pageContext = {
7018
- page_id: page.id,
7019
- page_name: page.name,
7020
- business_id: id
7021
- };
7022
- if (isRecord(page.instagram_business_account)) {
7023
- addById(instagramById, [
7024
- normalizeInstagramAccount(page.instagram_business_account, "page.instagram_business_account", pageContext)
7025
- ]);
7026
- }
7027
- if (isRecord(page.connected_instagram_account)) {
7028
- addById(instagramById, [
7029
- normalizeInstagramAccount(page.connected_instagram_account, "page.connected_instagram_account", pageContext)
7030
- ]);
7031
- }
7032
- }
7033
- addById(instagramById, dataArray(ownedInstagram).map((account) => normalizeInstagramAccount(account, `business:${id}:owned_instagram_accounts`, { business_id: id })));
7034
- addById(instagramById, dataArray(clientInstagram).map((account) => normalizeInstagramAccount(account, `business:${id}:client_instagram_accounts`, { business_id: id })));
7035
- addById(pixelsById, dataArray(ownedPixels));
7036
- addById(pixelsById, dataArray(clientPixels));
7037
- addById(datasetsById, dataArray(ownedDatasets));
7038
- addById(datasetsById, dataArray(clientDatasets));
7039
- }
7040
- if (businessIds.size === 0) {
7041
- warnings.push({
7042
- area: "business_assets",
7043
- message: "No readable Business Manager was discovered from /me/businesses or the provided ad account.",
7044
- suggestion: "Provide businessId directly, grant business_management, or use adAccountId for account-level pixels/datasets."
7045
- });
7046
- }
7047
- return ok({
7048
- businesses: [...businessesById.values()],
7049
- pages: [...pagesById.values()],
7050
- instagram_accounts: [...instagramById.values()],
7051
- pixels: [...pixelsById.values()],
7052
- datasets: [...datasetsById.values()],
7053
- counts: {
7054
- businesses: businessesById.size,
7055
- pages: pagesById.size,
7056
- instagram_accounts: instagramById.size,
7057
- pixels: pixelsById.size,
7058
- datasets: datasetsById.size
7059
- },
7060
- warnings
7061
- });
7062
- }
7063
- );
7064
- server.tool(
7065
- "meta_get_pages",
7066
- "List accessible Facebook Pages with id, name, category, tasks, picture, and linked Instagram account references when available.",
7067
- {
7068
- businessId: z2.string().optional().describe("Optional Business Manager ID to list owned/client pages. Omit to use /me/accounts."),
7069
- limit: z2.number().int().min(1).max(200).optional().default(100),
7070
- cursor: cursorSchema
7071
- },
7072
- async ({ businessId, limit, cursor }) => {
7073
- const warnings = [];
7074
- const pagesById = /* @__PURE__ */ new Map();
7075
- 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}";
7076
- const fallbackFields = "id,name,category,tasks,picture{url}";
7077
- const paging = {};
7078
- if (businessId) {
7079
- const ownedPages = await fetchGraphWithFallback(
7080
- client,
7081
- "owned_pages",
7082
- graphUrl(client, `/${businessId}/owned_pages`, { fields, limit, after: cursor }),
7083
- graphUrl(client, `/${businessId}/owned_pages`, { fields: fallbackFields, limit, after: cursor }),
7084
- warnings,
7085
- "Grant business_management plus pages_show_list/pages_read_engagement to read Business-owned Pages."
7086
- );
7087
- const clientPages = await fetchGraphWithFallback(
7088
- client,
7089
- "client_pages",
7090
- graphUrl(client, `/${businessId}/client_pages`, { fields, limit, after: cursor }),
7091
- graphUrl(client, `/${businessId}/client_pages`, { fields: fallbackFields, limit, after: cursor }),
7092
- warnings,
7093
- "Client Page access may require Business Manager partner permissions."
8173
+ "Client Page access may require Business Manager partner permissions."
7094
8174
  );
7095
8175
  addById(pagesById, dataArray(ownedPages).map((page) => normalizePage(page, "owned_pages")));
7096
8176
  addById(pagesById, dataArray(clientPages).map((page) => normalizePage(page, "client_pages")));
@@ -7121,9 +8201,9 @@ function registerMetaTools(server, config) {
7121
8201
  "List Instagram accounts linked to accessible Pages, Business Manager assets, or an ad account when permissions allow.",
7122
8202
  {
7123
8203
  adAccountId: adAccountIdSchema.optional().describe("Optional ad account ID for /instagram_accounts."),
7124
- businessId: z2.string().optional().describe("Optional Business Manager ID for owned/client Instagram account edges."),
7125
- pageId: z2.string().optional().describe("Optional Page ID to read linked instagram_business_account/connected_instagram_account."),
7126
- limit: z2.number().int().min(1).max(200).optional().default(100),
8204
+ businessId: z4.string().optional().describe("Optional Business Manager ID for owned/client Instagram account edges."),
8205
+ pageId: z4.string().optional().describe("Optional Page ID to read linked instagram_business_account/connected_instagram_account."),
8206
+ limit: z4.number().int().min(1).max(200).optional().default(100),
7127
8207
  cursor: cursorSchema
7128
8208
  },
7129
8209
  async ({ adAccountId, businessId, pageId, limit, cursor }) => {
@@ -7227,9 +8307,9 @@ function registerMetaTools(server, config) {
7227
8307
  "List pixels and datasets from an ad account or Business Manager when accessible, returning actionable warnings for permission-limited edges.",
7228
8308
  {
7229
8309
  adAccountId: adAccountIdSchema.optional().describe("Ad account ID for /adspixels and offline conversion datasets."),
7230
- businessId: z2.string().optional().describe("Business Manager ID for owned/client pixels and datasets."),
7231
- includeDatasets: z2.boolean().optional().default(true).describe("Also attempt dataset/offline conversion dataset edges."),
7232
- limit: z2.number().int().min(1).max(200).optional().default(100),
8310
+ businessId: z4.string().optional().describe("Business Manager ID for owned/client pixels and datasets."),
8311
+ includeDatasets: z4.boolean().optional().default(true).describe("Also attempt dataset/offline conversion dataset edges."),
8312
+ limit: z4.number().int().min(1).max(200).optional().default(100),
7233
8313
  cursor: cursorSchema
7234
8314
  },
7235
8315
  async ({ adAccountId, businessId, includeDatasets, limit, cursor }) => {
@@ -7353,9 +8433,9 @@ function registerMetaTools(server, config) {
7353
8433
  "Read ad account activity logs from /{ad_account_id}/activities with object, event, actor, timestamp, and extra_data fields.",
7354
8434
  {
7355
8435
  adAccountId: adAccountIdSchema,
7356
- since: z2.string().optional().describe("Optional start time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
7357
- until: z2.string().optional().describe("Optional end time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
7358
- limit: z2.number().int().min(1).max(500).optional().default(100),
8436
+ since: z4.string().optional().describe("Optional start time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
8437
+ until: z4.string().optional().describe("Optional end time accepted by Graph API, usually YYYY-MM-DD or Unix seconds."),
8438
+ limit: z4.number().int().min(1).max(500).optional().default(100),
7359
8439
  cursor: cursorSchema
7360
8440
  },
7361
8441
  async ({ adAccountId, since, until, limit, cursor }) => {
@@ -7394,11 +8474,11 @@ function registerMetaTools(server, config) {
7394
8474
  "Aggregate read-only delivery diagnostics across campaigns, ad sets, and ads using status/effective_status/issues_info where available plus simple delivery insights.",
7395
8475
  {
7396
8476
  adAccountId: adAccountIdSchema,
7397
- level: z2.enum(["campaign", "adset", "ad", "all"]).optional().default("all"),
7398
- effectiveStatusFilter: z2.array(z2.string()).optional().describe("Optional effective_status filter values such as ACTIVE, PAUSED, WITH_ISSUES, DISAPPROVED."),
8477
+ level: z4.enum(["campaign", "adset", "ad", "all"]).optional().default("all"),
8478
+ effectiveStatusFilter: z4.array(z4.string()).optional().describe("Optional effective_status filter values such as ACTIVE, PAUSED, WITH_ISSUES, DISAPPROVED."),
7399
8479
  datePreset: datePresetSchema2.describe("Insights date preset for simple delivery metrics. Defaults to last_7d."),
7400
8480
  timeRange: timeRangeSchema.describe("Optional custom insights date range."),
7401
- limit: z2.number().int().min(1).max(200).optional().default(100),
8481
+ limit: z4.number().int().min(1).max(200).optional().default(100),
7402
8482
  cursor: cursorSchema
7403
8483
  },
7404
8484
  async ({ adAccountId, level, effectiveStatusFilter, datePreset, timeRange, limit, cursor }) => {
@@ -7492,43 +8572,38 @@ function registerMetaTools(server, config) {
7492
8572
  );
7493
8573
  server.tool(
7494
8574
  "meta_get_creative_assets",
7495
- "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.",
8575
+ "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.",
7496
8576
  {
7497
8577
  adAccountId: adAccountIdSchema.optional().describe("Ad account ID. Used when adIds/creativeIds are omitted."),
7498
- adIds: z2.array(z2.string()).optional().describe("Specific ad IDs to enrich."),
7499
- creativeIds: z2.array(z2.string()).optional().describe("Specific creative IDs to enrich."),
8578
+ adIds: z4.array(z4.string()).optional().describe("Specific ad IDs to enrich."),
8579
+ creativeIds: z4.array(z4.string()).optional().describe("Specific creative IDs to enrich."),
7500
8580
  limit: limitSchema,
7501
8581
  cursor: cursorSchema
7502
8582
  },
7503
8583
  async ({ adAccountId, adIds, creativeIds, limit, cursor }) => {
7504
8584
  const warnings = [];
7505
- 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";
7506
- const adFields = `id,name,status,effective_status,campaign_id,adset_id,creative{${creativeFields}}`;
8585
+ 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";
8586
+ const adFields = `id,name,account_id,status,effective_status,campaign_id,adset_id,creative{${creativeFields}}`;
7507
8587
  let sourceType = "ad";
7508
8588
  let result = null;
7509
8589
  const idChunkSize = 50;
7510
8590
  if (creativeIds && creativeIds.length > 0) {
7511
8591
  sourceType = "creative";
7512
8592
  const merged = {};
7513
- for (let index = 0; index < creativeIds.length; index += idChunkSize) {
7514
- const chunk = creativeIds.slice(index, index + idChunkSize);
7515
- const chunkResult = await fetchGraph(
7516
- client,
7517
- `creative_ids_${Math.floor(index / idChunkSize) + 1}`,
7518
- graphUrl(client, "", { ids: chunk.join(","), fields: creativeFields }),
7519
- warnings,
7520
- "Verify the creative IDs are readable by this token and include only read-only creative fields."
7521
- );
7522
- Object.assign(merged, chunkResult ?? {});
8593
+ for (const id2 of creativeIds.slice(0, 20)) {
8594
+ 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.");
8595
+ if (creative) merged[id2] = creative;
7523
8596
  }
7524
8597
  result = merged;
7525
- if (creativeIds.length > idChunkSize) {
7526
- warnings.push({
7527
- area: "creative_assets",
7528
- message: `Creative ID enrichment was chunked into batches of ${idChunkSize} to avoid oversized Graph API responses.`,
7529
- suggestion: "Prefer passing targeted creativeIds/adIds from insights for complete creative-to-site coverage."
7530
- });
8598
+ 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." });
8599
+ } else if (adIds && adIds.length > 0 && !adAccountId) {
8600
+ const merged = {};
8601
+ for (const id2 of adIds.slice(0, 20)) {
8602
+ const ad = await fetchGraph(client, "ad_creative", graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: adFields }), warnings, "Provide the owning adAccountId for efficient targeted ad lookup.");
8603
+ if (ad) merged[id2] = ad;
7531
8604
  }
8605
+ 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." });
8606
+ result = merged;
7532
8607
  } else if (adIds && adIds.length > 0) {
7533
8608
  sourceType = "ad";
7534
8609
  const merged = {};
@@ -7537,12 +8612,19 @@ function registerMetaTools(server, config) {
7537
8612
  const chunkResult = await fetchGraph(
7538
8613
  client,
7539
8614
  `ad_ids_creatives_${Math.floor(index / idChunkSize) + 1}`,
7540
- graphUrl(client, "", { ids: chunk.join(","), fields: adFields }),
8615
+ graphUrl(client, `/${formatAdAccountId(adAccountId)}/ads`, { filtering: JSON.stringify([{ field: "id", operator: "IN", value: chunk }]), fields: adFields, limit: 500 }),
7541
8616
  warnings,
7542
8617
  "Verify the ad IDs are readable by this token and belong to accessible ad accounts."
7543
8618
  );
7544
- Object.assign(merged, chunkResult ?? {});
8619
+ for (const ad of dataArray(chunkResult)) if (ad.id) merged[String(ad.id)] = ad;
8620
+ }
8621
+ const missingIds = adIds.filter((id2) => !merged[id2]);
8622
+ for (const id2 of missingIds.slice(0, 10)) {
8623
+ const ad = await fetchGraph(client, "historical_ad_creative", graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: adFields }), warnings, "Historical ad metadata may no longer be accessible.");
8624
+ if (ad && typeof ad.account_id === "string" && formatAdAccountId(ad.account_id) === formatAdAccountId(adAccountId)) merged[id2] = ad;
8625
+ 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." });
7545
8626
  }
8627
+ 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." });
7546
8628
  result = merged;
7547
8629
  if (adIds.length > idChunkSize) {
7548
8630
  warnings.push({
@@ -7572,7 +8654,49 @@ function registerMetaTools(server, config) {
7572
8654
  });
7573
8655
  }
7574
8656
  const records = result?.data ? dataArray(result) : Object.values(result ?? {}).filter(isRecord);
7575
- const assets = records.map((record) => normalizeCreativeAsset(record, sourceType));
8657
+ const assets = records.map((record2) => normalizeCreativeAsset(record2, sourceType));
8658
+ const allMedia = assets.flatMap((asset) => Array.isArray(asset.media) ? asset.media : []);
8659
+ const hashes = [...new Set(allMedia.filter((m) => m.image_hash).map((m) => m.image_hash))];
8660
+ if (hashes.length && adAccountId) {
8661
+ for (let offset = 0; offset < hashes.length; offset += 50) {
8662
+ const images = await fetchGraph(client, "creative_image_hashes", graphUrl(client, `/${formatAdAccountId(adAccountId)}/adimages`, {
8663
+ hashes: JSON.stringify(hashes.slice(offset, offset + 50)),
8664
+ fields: "hash,permalink_url,url",
8665
+ limit: 50
8666
+ }), warnings, "Verify ads_read access to the ad account image library.");
8667
+ 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);
8668
+ }
8669
+ }
8670
+ const videoIds = [...new Set(allMedia.map((m) => m.video_id).filter((id2) => Boolean(id2)))];
8671
+ const pageClientFor = pageClientResolver(client, warnings);
8672
+ for (const id2 of videoIds.slice(0, 10)) {
8673
+ const video = await fetchGraph(
8674
+ client,
8675
+ "creative_video",
8676
+ graphUrl(client, `/${encodeURIComponent(id2)}`, { fields: "id,picture,source,from" }),
8677
+ warnings,
8678
+ "Verify access to the ad video and its owning Page."
8679
+ ) ?? { id: id2 };
8680
+ const owner = assets.find((asset) => asset.media.some((m) => m.video_id === id2));
8681
+ const pageId = firstString(
8682
+ isRecord(video.from) ? video.from.id : void 0,
8683
+ owner?.page_id,
8684
+ typeof owner?.effective_object_story_id === "string" ? owner.effective_object_story_id.split("_")[0] : void 0
8685
+ );
8686
+ if (!video.source && pageId) {
8687
+ const pageClient = await pageClientFor(pageId);
8688
+ if (pageClient) {
8689
+ const resolved = await fetchGraph(pageClient, "creative_page_video", graphUrl(pageClient, `/${encodeURIComponent(id2)}`, { fields: "id,picture,source" }), warnings, "Verify Page content access.");
8690
+ if (resolved) Object.assign(video, resolved);
8691
+ }
8692
+ }
8693
+ for (const item of allMedia) if (item.video_id === id2) {
8694
+ item.thumbnail_url = firstString(video?.picture, item.thumbnail_url);
8695
+ item.source = firstString(video?.source, item.source);
8696
+ }
8697
+ 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." });
8698
+ }
8699
+ 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." });
7576
8700
  const paging = pagingInfo(result);
7577
8701
  if (paging) {
7578
8702
  warnings.push({
@@ -7594,8 +8718,8 @@ function registerMetaTools(server, config) {
7594
8718
  "Read detailed custom, saved, and lookalike audiences with pagination and rich fields where permissions allow.",
7595
8719
  {
7596
8720
  adAccountId: adAccountIdSchema.optional().describe("Ad account ID used when listing audiences."),
7597
- audienceIds: z2.array(z2.string()).optional().describe("Specific audience IDs to fetch via batch IDs lookup."),
7598
- type: z2.enum(["all", "custom", "saved", "lookalike"]).optional().default("all"),
8721
+ audienceIds: z4.array(z4.string()).optional().describe("Specific audience IDs to fetch via batch IDs lookup."),
8722
+ type: z4.enum(["all", "custom", "saved", "lookalike"]).optional().default("all"),
7599
8723
  limit: limitSchema,
7600
8724
  cursor: cursorSchema
7601
8725
  },
@@ -7730,13 +8854,13 @@ function registerMetaTools(server, config) {
7730
8854
  "meta_get_catalog_products",
7731
8855
  "Read Product Catalogs and Product Items when catalog access is available. Returns join-ready product metadata without creating or updating catalog assets.",
7732
8856
  {
7733
- businessId: z2.string().optional().describe("Business Manager ID used to discover owned/client product catalogs."),
7734
- catalogId: z2.string().optional().describe("Product Catalog ID to read directly."),
7735
- productIds: z2.array(z2.string()).max(100).optional().describe("Specific Product Item IDs to fetch via the batch IDs endpoint."),
7736
- search: z2.string().optional().describe("Client-side substring filter across product id, retailer_id, name, brand, category, type, and custom labels."),
7737
- includeProducts: z2.boolean().optional().default(true).describe("Fetch product items for discovered/provided catalogs."),
7738
- includeProductSets: z2.boolean().optional().default(false).describe("Also attempt product_sets edges for catalog context."),
7739
- limit: z2.number().int().min(1).max(500).optional().default(100),
8857
+ businessId: z4.string().optional().describe("Business Manager ID used to discover owned/client product catalogs."),
8858
+ catalogId: z4.string().optional().describe("Product Catalog ID to read directly."),
8859
+ productIds: z4.array(z4.string()).max(100).optional().describe("Specific Product Item IDs to fetch via the batch IDs endpoint."),
8860
+ search: z4.string().optional().describe("Client-side substring filter across product id, retailer_id, name, brand, category, type, and custom labels."),
8861
+ includeProducts: z4.boolean().optional().default(true).describe("Fetch product items for discovered/provided catalogs."),
8862
+ includeProductSets: z4.boolean().optional().default(false).describe("Also attempt product_sets edges for catalog context."),
8863
+ limit: z4.number().int().min(1).max(500).optional().default(100),
7740
8864
  cursor: cursorSchema
7741
8865
  },
7742
8866
  async ({ businessId, catalogId, productIds, search, includeProducts, includeProductSets, limit, cursor }) => {
@@ -7844,31 +8968,31 @@ function registerMetaTools(server, config) {
7844
8968
  suggestion: "Use catalogId with the returned catalog-specific cursor for precise product pagination."
7845
8969
  });
7846
8970
  }
7847
- for (const id of catalogIds) {
8971
+ for (const id2 of catalogIds) {
7848
8972
  const products = await fetchGraphWithFallback(
7849
8973
  client,
7850
- `catalog:${id}:products`,
7851
- graphUrl(client, `/${id}/products`, { fields: productFields, limit, after: cursor }),
7852
- graphUrl(client, `/${id}/products`, { fields: productFallbackFields, limit, after: cursor }),
8974
+ `catalog:${id2}:products`,
8975
+ graphUrl(client, `/${id2}/products`, { fields: productFields, limit, after: cursor }),
8976
+ graphUrl(client, `/${id2}/products`, { fields: productFallbackFields, limit, after: cursor }),
7853
8977
  warnings,
7854
8978
  "Product Item fields may require catalog permissions. Minimal join fields are returned when rich product fields fail."
7855
8979
  );
7856
- const normalizedProducts = dataArray(products).map((product) => normalizeProductItem(product, `catalog:${id}:products`)).filter((product) => productSearchMatches(product, search));
8980
+ const normalizedProducts = dataArray(products).map((product) => normalizeProductItem(product, `catalog:${id2}:products`)).filter((product) => productSearchMatches(product, search));
7857
8981
  addById(productsById, normalizedProducts);
7858
- paging[`catalog:${id}:products`] = pagingInfo(products);
8982
+ paging[`catalog:${id2}:products`] = pagingInfo(products);
7859
8983
  }
7860
8984
  }
7861
8985
  if (includeProductSets) {
7862
- for (const id of catalogIds) {
8986
+ for (const id2 of catalogIds) {
7863
8987
  const productSets = await fetchGraph(
7864
8988
  client,
7865
- `catalog:${id}:product_sets`,
7866
- graphUrl(client, `/${id}/product_sets`, { fields: productSetFields, limit, after: cursor }),
8989
+ `catalog:${id2}:product_sets`,
8990
+ graphUrl(client, `/${id2}/product_sets`, { fields: productSetFields, limit, after: cursor }),
7867
8991
  warnings,
7868
8992
  "Product set reads may require catalog access, and some catalog verticals do not expose product_sets."
7869
8993
  );
7870
8994
  addById(productSetsById, dataArray(productSets));
7871
- paging[`catalog:${id}:product_sets`] = pagingInfo(productSets);
8995
+ paging[`catalog:${id2}:product_sets`] = pagingInfo(productSets);
7872
8996
  }
7873
8997
  }
7874
8998
  return ok({
@@ -7899,14 +9023,14 @@ function registerMetaTools(server, config) {
7899
9023
  "Query product-breakdown insights and enrich rows with Product Catalog metadata when catalog access is available.",
7900
9024
  {
7901
9025
  adAccountId: adAccountIdSchema,
7902
- catalogId: z2.string().optional().describe("Product Catalog ID used to load product metadata for joins."),
9026
+ catalogId: z4.string().optional().describe("Product Catalog ID used to load product metadata for joins."),
7903
9027
  productBreakdown: productBreakdownSchema.optional().default("product_id"),
7904
- metrics: z2.array(z2.string()).min(1).optional().default(["impressions", "clicks", "spend", "actions", "action_values"]),
9028
+ metrics: z4.array(z4.string()).min(1).optional().default(["impressions", "clicks", "spend", "actions", "action_values"]),
7905
9029
  level: levelSchema.optional().default("ad"),
7906
9030
  datePreset: datePresetSchema2.describe("Insights date preset. Defaults to last_30d."),
7907
9031
  timeRange: timeRangeSchema.describe("Optional custom insights date range."),
7908
- limit: z2.number().int().min(1).max(1e3).optional().default(500),
7909
- catalogProductLimit: z2.number().int().min(1).max(1e3).optional().default(500)
9032
+ limit: z4.number().int().min(1).max(1e3).optional().default(500),
9033
+ catalogProductLimit: z4.number().int().min(1).max(1e3).optional().default(500)
7910
9034
  },
7911
9035
  async ({ adAccountId, catalogId, productBreakdown, metrics, level, datePreset, timeRange, limit, catalogProductLimit }) => {
7912
9036
  const warnings = [];
@@ -8019,12 +9143,12 @@ function registerMetaTools(server, config) {
8019
9143
  "Read brand safety, suitability, placement, and context-control signals from ad account/ad set targeting and optional block-list edges.",
8020
9144
  {
8021
9145
  adAccountId: adAccountIdSchema,
8022
- businessId: z2.string().optional().describe("Optional Business Manager ID for block-list discovery."),
8023
- adsetIds: z2.array(z2.string()).max(100).optional().describe("Specific ad set IDs to inspect. If omitted, reads ad sets from the ad account."),
8024
- includeAdsets: z2.boolean().optional().default(true),
8025
- includeBlockLists: z2.boolean().optional().default(true),
8026
- includeRawTargeting: z2.boolean().optional().default(false),
8027
- limit: z2.number().int().min(1).max(500).optional().default(100),
9146
+ businessId: z4.string().optional().describe("Optional Business Manager ID for block-list discovery."),
9147
+ adsetIds: z4.array(z4.string()).max(100).optional().describe("Specific ad set IDs to inspect. If omitted, reads ad sets from the ad account."),
9148
+ includeAdsets: z4.boolean().optional().default(true),
9149
+ includeBlockLists: z4.boolean().optional().default(true),
9150
+ includeRawTargeting: z4.boolean().optional().default(false),
9151
+ limit: z4.number().int().min(1).max(500).optional().default(100),
8028
9152
  cursor: cursorSchema
8029
9153
  },
8030
9154
  async ({ adAccountId, businessId, adsetIds, includeAdsets, includeBlockLists, includeRawTargeting, limit, cursor }) => {
@@ -8144,11 +9268,11 @@ function registerMetaTools(server, config) {
8144
9268
  "meta_interpret_experiment_results",
8145
9269
  "Read and interpret A/B test or conversion lift study results with confidence guardrails, cells, objectives, and optional cell entities.",
8146
9270
  {
8147
- studyId: z2.string().optional().describe("Specific Ad Study ID to interpret."),
9271
+ studyId: z4.string().optional().describe("Specific Ad Study ID to interpret."),
8148
9272
  adAccountId: adAccountIdSchema.optional().describe("Ad account ID used to discover ad_studies when studyId is omitted."),
8149
- includeCellEntities: z2.boolean().optional().default(false).describe("Also read campaigns/adsets/adaccounts attached to each study cell."),
8150
- cellEntityType: z2.enum(["campaigns", "adsets", "adaccounts"]).optional().default("campaigns"),
8151
- limit: z2.number().int().min(1).max(100).optional().default(50)
9273
+ includeCellEntities: z4.boolean().optional().default(false).describe("Also read campaigns/adsets/adaccounts attached to each study cell."),
9274
+ cellEntityType: z4.enum(["campaigns", "adsets", "adaccounts"]).optional().default("campaigns"),
9275
+ limit: z4.number().int().min(1).max(100).optional().default(50)
8152
9276
  },
8153
9277
  async ({ studyId, adAccountId, includeCellEntities, cellEntityType, limit }) => {
8154
9278
  const warnings = [];
@@ -8233,21 +9357,21 @@ function registerMetaTools(server, config) {
8233
9357
  }
8234
9358
  const interpretedStudies = [];
8235
9359
  for (const study of studiesById.values()) {
8236
- const id = typeof study.id === "string" ? study.id : void 0;
8237
- if (!id) continue;
9360
+ const id2 = typeof study.id === "string" ? study.id : void 0;
9361
+ if (!id2) continue;
8238
9362
  const [cellsResult, objectivesResult] = await Promise.all([
8239
9363
  fetchGraph(
8240
9364
  client,
8241
- `ad_study:${id}:cells`,
8242
- graphUrl(client, `/${id}/cells`, { fields: cellFields, limit }),
9365
+ `ad_study:${id2}:cells`,
9366
+ graphUrl(client, `/${id2}/cells`, { fields: cellFields, limit }),
8243
9367
  warnings,
8244
9368
  "Study cells may require experiment access. Without cells, interpretation is limited to objective payloads."
8245
9369
  ),
8246
9370
  fetchGraphWithFallback(
8247
9371
  client,
8248
- `ad_study:${id}:objectives`,
8249
- graphUrl(client, `/${id}/objectives`, { fields: objectiveFields, limit }),
8250
- graphUrl(client, `/${id}/objectives`, { fields: objectiveFallbackFields, limit }),
9372
+ `ad_study:${id2}:objectives`,
9373
+ graphUrl(client, `/${id2}/objectives`, { fields: objectiveFields, limit }),
9374
+ graphUrl(client, `/${id2}/objectives`, { fields: objectiveFallbackFields, limit }),
8251
9375
  warnings,
8252
9376
  "Study result payloads can be permission/whitelist dependent; basic objective fields are returned when full results are unavailable."
8253
9377
  )
@@ -8295,26 +9419,27 @@ function registerMetaTools(server, config) {
8295
9419
  );
8296
9420
  server.tool(
8297
9421
  "meta_get_organic_content_enrichment",
8298
- "Enrich Facebook Page posts and Instagram organic media with media URLs, permalinks, counts, attachments, and optional read-only insights.",
9422
+ "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.",
8299
9423
  {
8300
- pageId: z2.string().optional().describe("Facebook Page ID for /posts and linked IG discovery."),
8301
- instagramAccountId: z2.string().optional().describe("Instagram professional account ID for /media."),
8302
- postIds: z2.array(z2.string()).max(50).optional().describe("Specific Facebook post IDs to enrich."),
8303
- mediaIds: z2.array(z2.string()).max(50).optional().describe("Specific Instagram media IDs to enrich."),
8304
- includePagePosts: z2.boolean().optional().default(true),
8305
- includeInstagramMedia: z2.boolean().optional().default(true),
8306
- includeInsights: z2.boolean().optional().default(false),
8307
- limit: z2.number().int().min(1).max(100).optional().default(25),
9424
+ pageId: z4.string().optional().describe("Facebook Page ID for /posts and linked IG discovery."),
9425
+ instagramAccountId: z4.string().optional().describe("Instagram professional account ID for /media."),
9426
+ postIds: z4.array(z4.string()).max(50).optional().describe("Specific Facebook post IDs to enrich."),
9427
+ mediaIds: z4.array(z4.string()).max(50).optional().describe("Specific Instagram media IDs to enrich."),
9428
+ includePagePosts: z4.boolean().optional().default(true),
9429
+ includeInstagramMedia: z4.boolean().optional().default(true),
9430
+ includeInsights: z4.boolean().optional().default(false),
9431
+ limit: z4.number().int().min(1).max(100).optional().default(25),
8308
9432
  cursor: cursorSchema,
8309
- since: z2.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text."),
8310
- until: z2.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text.")
9433
+ since: z4.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text."),
9434
+ until: z4.string().optional().describe("Optional Graph time filter. For IG media this should be Unix seconds or strtotime-compatible text.")
8311
9435
  },
8312
9436
  async ({ pageId, instagramAccountId, postIds, mediaIds, includePagePosts, includeInstagramMedia, includeInsights, limit, cursor, since, until }) => {
8313
9437
  const warnings = [];
8314
9438
  const paging = {};
8315
- 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)";
8316
- const pagePostFallbackFields = "id,message,created_time,type,permalink_url,full_picture,shares,likes.summary(true),comments.summary(true)";
8317
- 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}";
9439
+ const pagePostFields = "id,message,created_time,permalink_url,full_picture,attachments{media,type,url,target,title,description},shares";
9440
+ const pagePostFallbackFields = "id,message,created_time,permalink_url,full_picture,shares";
9441
+ 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}";
9442
+ const pageClientFor = pageClientResolver(client, warnings);
8318
9443
  const pagePosts = [];
8319
9444
  const instagramMedia = [];
8320
9445
  let resolvedInstagramAccountId = instagramAccountId;
@@ -8326,38 +9451,54 @@ function registerMetaTools(server, config) {
8326
9451
  });
8327
9452
  }
8328
9453
  if (includePagePosts && postIds && postIds.length > 0) {
8329
- const result = await fetchGraphWithFallback(
8330
- client,
8331
- "page_post_ids",
8332
- graphUrl(client, "", { ids: postIds.join(","), fields: pagePostFields }),
8333
- graphUrl(client, "", { ids: postIds.join(","), fields: pagePostFallbackFields }),
8334
- warnings,
8335
- "Post insights or attachments may require pages_read_engagement or a Page token; minimal post metadata is returned when rich fields fail."
8336
- );
8337
- pagePosts.push(...Object.values(result ?? {}).filter(isRecord).map(normalizeOrganicPost));
9454
+ for (const postId of [...new Set(postIds)]) {
9455
+ const ownerId = /^(\d+)_\d+$/.exec(postId)?.[1] ?? pageId;
9456
+ if (!ownerId) {
9457
+ 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." });
9458
+ continue;
9459
+ }
9460
+ const pageClient = await pageClientFor(ownerId);
9461
+ if (!pageClient) continue;
9462
+ const post = await fetchGraphWithFallback(
9463
+ pageClient,
9464
+ "page_post_ids",
9465
+ graphUrl(pageClient, `/${encodeURIComponent(postId)}`, { fields: pagePostFields }),
9466
+ graphUrl(pageClient, `/${encodeURIComponent(postId)}`, { fields: pagePostFallbackFields }),
9467
+ warnings,
9468
+ "Post attachments require Page content access."
9469
+ );
9470
+ if (post) pagePosts.push(normalizeOrganicPost(post));
9471
+ }
8338
9472
  } else if (includePagePosts && pageId) {
8339
- const result = await fetchGraphWithFallback(
8340
- client,
8341
- "page_posts_enrichment",
8342
- graphUrl(client, `/${pageId}/posts`, {
8343
- fields: pagePostFields,
8344
- limit,
8345
- after: cursor,
8346
- since,
8347
- until
8348
- }),
8349
- graphUrl(client, `/${pageId}/posts`, {
8350
- fields: pagePostFallbackFields,
8351
- limit,
8352
- after: cursor,
8353
- since,
8354
- until
8355
- }),
8356
- warnings,
8357
- "Post insights or attachment fields may require pages_read_engagement and Page access. Minimal post fields are returned when rich fields fail."
8358
- );
8359
- pagePosts.push(...dataArray(result).map(normalizeOrganicPost));
8360
- paging.page_posts = pagingInfo(result);
9473
+ const pageClient = await pageClientFor(pageId);
9474
+ if (pageClient) {
9475
+ const result = await fetchGraphWithFallback(
9476
+ pageClient,
9477
+ "page_posts_enrichment",
9478
+ graphUrl(pageClient, `/${encodeURIComponent(pageId)}/posts`, { fields: pagePostFields, limit, after: cursor, since, until }),
9479
+ graphUrl(pageClient, `/${encodeURIComponent(pageId)}/posts`, { fields: pagePostFallbackFields, limit, after: cursor, since, until }),
9480
+ warnings,
9481
+ "Post attachments require pages_read_engagement and Page access."
9482
+ );
9483
+ pagePosts.push(...dataArray(result).map(normalizeOrganicPost));
9484
+ paging.page_posts = pagingInfo(result);
9485
+ }
9486
+ }
9487
+ if (includeInsights) {
9488
+ for (const post of pagePosts) {
9489
+ const id2 = typeof post.id === "string" ? post.id : void 0;
9490
+ const ownerId = id2 ? /^(\d+)_\d+$/.exec(id2)?.[1] ?? pageId : void 0;
9491
+ if (!id2 || !ownerId) continue;
9492
+ const pageClient = await pageClientFor(ownerId);
9493
+ if (!pageClient) continue;
9494
+ post.insights = await fetchGraph(
9495
+ pageClient,
9496
+ `page_post:${id2}:insights`,
9497
+ graphUrl(pageClient, `/${encodeURIComponent(id2)}/insights`, { metric: "post_media_view,post_clicks" }),
9498
+ warnings,
9499
+ "Page post insights require read_insights and a Page token. Unavailable metrics are not zero; post metadata is retained."
9500
+ );
9501
+ }
8361
9502
  }
8362
9503
  if (includeInstagramMedia && !resolvedInstagramAccountId && pageId) {
8363
9504
  const page = await fetchGraph(
@@ -8403,18 +9544,38 @@ function registerMetaTools(server, config) {
8403
9544
  for (const media of instagramMedia) {
8404
9545
  const mediaId = typeof media.id === "string" ? media.id : void 0;
8405
9546
  if (!mediaId) continue;
8406
- const insights = await fetchGraph(
8407
- client,
8408
- `instagram_media:${mediaId}:insights`,
8409
- graphUrl(client, `/${mediaId}/insights`, {
8410
- metric: "impressions,reach,engagement,saved,video_views,plays,total_interactions"
8411
- }),
8412
- warnings,
8413
- "IG media insight metrics vary by media type and API version. Basic media metadata remains available when insights fail."
8414
- );
8415
- media.insights = insights;
9547
+ const metrics = instagramInsightMetrics(media);
9548
+ media.insights_requested_metrics = metrics;
9549
+ try {
9550
+ media.insights = await client.fetchUrl(graphUrl(client, `/${encodeURIComponent(mediaId)}/insights`, { metric: metrics.join(",") }));
9551
+ } catch (error) {
9552
+ warnings.push(warningFromError(
9553
+ `instagram_media:${mediaId}:insights`,
9554
+ error,
9555
+ "Insight availability depends on media format, age and permissions. Unavailable metrics are not zero; media metadata is retained."
9556
+ ));
9557
+ const incompatible = error instanceof MetaApiException && error.code === 100 && /metric.*(incompat|valid|supported|must be)|invalid.*metric/i.test(error.message);
9558
+ media.insights = incompatible && metrics.length > 1 ? await fetchGraph(
9559
+ client,
9560
+ `instagram_media:${mediaId}:reach`,
9561
+ graphUrl(client, `/${encodeURIComponent(mediaId)}/insights`, { metric: "reach" }),
9562
+ warnings,
9563
+ "Only reach was retried after Meta rejected the format-specific metric selection; other metrics remain unavailable."
9564
+ ) : null;
9565
+ }
9566
+ media.insights_returned_metrics = dataArray(isRecord(media.insights) ? media.insights : null).map((row) => row.name);
8416
9567
  }
8417
9568
  }
9569
+ const insightState = (item, requested) => {
9570
+ const result = isRecord(item.insights) ? item.insights : null;
9571
+ const returned = dataArray(result);
9572
+ return { requested: includeInsights, source: "content_insights", delivery_breakdown: "not_established", metrics: Object.fromEntries(requested.map((name2) => {
9573
+ const metric = returned.find((m) => m.name === name2);
9574
+ return [name2, { status: !includeInsights ? "not_requested" : !result ? "read_failed" : !metric ? "not_returned" : "returned", period: metric?.period ?? null }];
9575
+ })) };
9576
+ };
9577
+ for (const post of pagePosts) post.insight_coverage = insightState(post, ["post_media_view", "post_clicks"]);
9578
+ for (const media of instagramMedia) media.insight_coverage = insightState(media, Array.isArray(media.insights_requested_metrics) ? media.insights_requested_metrics : instagramInsightMetrics(media));
8418
9579
  return ok({
8419
9580
  page_id: pageId,
8420
9581
  instagram_account_id: resolvedInstagramAccountId,
@@ -8425,8 +9586,14 @@ function registerMetaTools(server, config) {
8425
9586
  instagram_media: instagramMedia.length
8426
9587
  },
8427
9588
  notes: [
9589
+ "Facebook likes/comments edges are not requested. Missing engagement counts are unavailable, not zero; no commenter profiles are collected.",
9590
+ "Media insights retain Meta\u2019s native periods (usually lifetime). since/until filter the media list, not the measurement period of each media insight.",
8428
9591
  "Instagram thumbnail_url is media-type dependent and may only appear for video/Reels media.",
8429
- "This tool only reads existing organic content; it never publishes, edits, hides, or deletes posts."
9592
+ "This tool reads existing Page/Instagram content; it never publishes, edits, hides, or deletes posts.",
9593
+ "Content insights are not Ads Insights. Do not label a metric organic-only unless Meta supplies an explicit organic breakdown or definition.",
9594
+ "Preserve native metric names, titles, descriptions and periods. Unusual titles alone do not establish audience restrictions.",
9595
+ "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.",
9596
+ "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."
8430
9597
  ],
8431
9598
  paging,
8432
9599
  warnings
@@ -8468,14 +9635,14 @@ function registerMetaTools(server, config) {
8468
9635
  limit: limitSchema,
8469
9636
  cursor: cursorSchema
8470
9637
  },
8471
- async ({ adAccountId, statusFilter, limit, cursor }) => {
9638
+ async ({ adAccountId, statusFilter, limit = 100, cursor }) => {
8472
9639
  try {
8473
9640
  const fields = "id,name,status,effective_status,objective,daily_budget,lifetime_budget,budget_remaining,bid_strategy,buying_type,start_time,stop_time";
8474
9641
  let url = `https://graph.facebook.com/${client.apiVersion}/${adAccountId}/campaigns?fields=${fields}&limit=${limit}`;
8475
9642
  if (statusFilter) url += `&filtering=[{"field":"effective_status","operator":"IN","value":["${statusFilter}"]}]`;
8476
9643
  if (cursor) url += `&after=${cursor}`;
8477
9644
  const result = await client.fetchUrl(url);
8478
- return ok(result);
9645
+ return ok(entityListResult(result, statusFilter));
8479
9646
  } catch (e) {
8480
9647
  return formatMcpToolError(e);
8481
9648
  }
@@ -8486,12 +9653,12 @@ function registerMetaTools(server, config) {
8486
9653
  "List ad sets for a Meta ad account, optionally filtered by campaign. Returns targeting, budget, optimization, and schedule info.",
8487
9654
  {
8488
9655
  adAccountId: adAccountIdSchema,
8489
- campaignId: z2.string().optional().describe("Filter by campaign ID"),
9656
+ campaignId: z4.string().optional().describe("Filter by campaign ID"),
8490
9657
  statusFilter: statusFilterSchema,
8491
9658
  limit: limitSchema,
8492
9659
  cursor: cursorSchema
8493
9660
  },
8494
- async ({ adAccountId, campaignId, statusFilter, limit, cursor }) => {
9661
+ async ({ adAccountId, campaignId, statusFilter, limit = 100, cursor }) => {
8495
9662
  try {
8496
9663
  const fields = "id,name,status,effective_status,campaign_id,daily_budget,lifetime_budget,optimization_goal,billing_event,bid_amount,targeting,start_time,end_time";
8497
9664
  const parent = campaignId ?? adAccountId;
@@ -8499,7 +9666,7 @@ function registerMetaTools(server, config) {
8499
9666
  if (statusFilter) url += `&filtering=[{"field":"effective_status","operator":"IN","value":["${statusFilter}"]}]`;
8500
9667
  if (cursor) url += `&after=${cursor}`;
8501
9668
  const result = await client.fetchUrl(url);
8502
- return ok(result);
9669
+ return ok(entityListResult(result, statusFilter));
8503
9670
  } catch (e) {
8504
9671
  return formatMcpToolError(e);
8505
9672
  }
@@ -8510,20 +9677,22 @@ function registerMetaTools(server, config) {
8510
9677
  "List ads for a Meta ad account, optionally filtered by ad set. Returns ad ID, name, status, and creative reference.",
8511
9678
  {
8512
9679
  adAccountId: adAccountIdSchema,
8513
- adsetId: z2.string().optional().describe("Filter by ad set ID"),
9680
+ adsetId: z4.string().optional().describe("Filter by ad set ID; adSetId is also accepted."),
9681
+ adSetId: z4.string().optional().describe("Alias for adsetId. If both are supplied, they must match."),
8514
9682
  statusFilter: statusFilterSchema,
8515
9683
  limit: limitSchema,
8516
9684
  cursor: cursorSchema
8517
9685
  },
8518
- async ({ adAccountId, adsetId, statusFilter, limit, cursor }) => {
9686
+ async ({ adAccountId, adsetId, adSetId, statusFilter, limit = 100, cursor }) => {
8519
9687
  try {
9688
+ if (adsetId && adSetId && adsetId !== adSetId) throw new Error("adsetId and adSetId must match when both are supplied.");
8520
9689
  const fields = "id,name,status,effective_status,adset_id,campaign_id,creative{id,name,thumbnail_url,object_story_spec,asset_feed_spec}";
8521
- const parent = adsetId ?? adAccountId;
9690
+ const parent = adsetId ?? adSetId ?? adAccountId;
8522
9691
  let url = `https://graph.facebook.com/${client.apiVersion}/${parent}/ads?fields=${fields}&limit=${limit}`;
8523
9692
  if (statusFilter) url += `&filtering=[{"field":"effective_status","operator":"IN","value":["${statusFilter}"]}]`;
8524
9693
  if (cursor) url += `&after=${cursor}`;
8525
9694
  const result = await client.fetchUrl(url);
8526
- return ok(result);
9695
+ return ok(entityListResult(result, statusFilter));
8527
9696
  } catch (e) {
8528
9697
  return formatMcpToolError(e);
8529
9698
  }
@@ -8537,16 +9706,22 @@ The query planner automatically splits incompatible metric/breakdown combination
8537
9706
  {
8538
9707
  adAccountId: adAccountIdSchema,
8539
9708
  level: levelSchema.describe("Aggregation level: account, campaign, adset, or ad"),
8540
- metrics: z2.array(z2.string()).min(1).describe("Metric keys from meta://metrics (e.g., impressions, spend, ctr)"),
8541
- breakdowns: z2.array(z2.string()).optional().describe("Breakdown keys from meta://breakdowns (e.g., age, gender, country)"),
9709
+ metrics: z4.array(z4.string()).min(1).describe("Metric keys from meta://metrics (e.g., impressions, spend, ctr)"),
9710
+ breakdowns: z4.array(z4.string()).optional().describe("Breakdown keys from meta://breakdowns (e.g., age, gender, country)"),
8542
9711
  datePreset: datePresetSchema2.describe("Predefined date range (e.g., last_7d, last_30d)"),
8543
9712
  timeRange: timeRangeSchema.describe("Custom date range with since/until in YYYY-MM-DD format"),
8544
- timeIncrement: z2.union([z2.literal(1), z2.literal(7), z2.literal(14), z2.literal(28), z2.literal(30), z2.string()]).optional().describe("Time granularity: 1 (daily), 7 (weekly), 'monthly', or 'all_days'"),
8545
- limit: z2.number().int().min(1).max(5e3).optional().default(500)
9713
+ timeIncrement: z4.union([z4.literal(1), z4.literal(7), z4.literal(14), z4.literal(28), z4.literal(30), z4.string()]).optional().describe("Time granularity: 1 (daily), 7 (weekly), 'monthly', or 'all_days'"),
9714
+ adIds: z4.array(z4.string().regex(/^\d+$/)).min(1).max(100).optional().describe("Restrict ad-level insights to these ad IDs."),
9715
+ attributionMode: z4.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."),
9716
+ attributionWindows: z4.array(z4.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."),
9717
+ actionReportTime: z4.enum(["impression", "conversion", "mixed"]).default("impression").describe("Date basis for actions, sent explicitly to Meta."),
9718
+ includeAccountTotals: z4.boolean().default(false).describe("Also read native account-level totals with the same dates, metrics and attribution. Requires no adIds or breakdowns."),
9719
+ limit: z4.number().int().min(1).max(5e3).optional().default(500)
8546
9720
  },
8547
- async ({ adAccountId, level, metrics, breakdowns, datePreset, timeRange, timeIncrement, limit }) => {
9721
+ async ({ adAccountId, level, metrics, breakdowns, datePreset, timeRange, timeIncrement, adIds, limit, attributionMode = "account", attributionWindows, actionReportTime = "impression", includeAccountTotals = false }) => {
8548
9722
  try {
8549
9723
  const startTime = Date.now();
9724
+ if (adIds && level !== "ad") throw new Error("adIds requires level ad.");
8550
9725
  const plan = planQueries({
8551
9726
  adAccountId,
8552
9727
  level,
@@ -8565,9 +9740,13 @@ The query planner automatically splits incompatible metric/breakdown combination
8565
9740
  suggestion: "Check meta://compatibility for valid metric/breakdown combinations"
8566
9741
  });
8567
9742
  }
9743
+ 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.");
9744
+ const measurement = { attributionMode, attributionWindows: attributionWindows ? [...new Set(attributionWindows)] : void 0, actionReportTime };
9745
+ const sentMeasurement = measurementParams({ ...plan.requests[0], ...measurement });
9746
+ for (const request2 of plan.requests) Object.assign(request2, measurement);
8568
9747
  const allResults = [];
8569
9748
  for (const request2 of plan.requests) {
8570
- const result = await client.fetchInsights(adAccountId, request2);
9749
+ const result = await client.fetchInsights(adAccountId, { ...request2, ...adIds ? { filtering: [{ field: "ad.id", operator: "IN", value: adIds }] } : {} });
8571
9750
  allResults.push(result);
8572
9751
  }
8573
9752
  let data = mergeResults(allResults, plan.joinKeys);
@@ -8577,11 +9756,32 @@ The query planner automatically splits incompatible metric/breakdown combination
8577
9756
  ...calculateDerivedMetrics(row)
8578
9757
  }));
8579
9758
  }
9759
+ let accountTotals = null;
9760
+ let totalsRequestCount = 0;
9761
+ if (includeAccountTotals) {
9762
+ if (level === "account") accountTotals = data;
9763
+ else {
9764
+ const totalsPlan = planQueries({ adAccountId, level: "account", metrics, breakdowns: [], datePreset: datePreset ?? "last_30d", timeRange, timeIncrement, limit });
9765
+ if (totalsPlan.errors.length) throw new Error("Cannot produce comparable native account totals: " + totalsPlan.errors.join("; "));
9766
+ const results = [];
9767
+ for (const q of totalsPlan.requests) {
9768
+ results.push(await client.fetchInsights(adAccountId, { ...q, ...measurement }));
9769
+ totalsRequestCount++;
9770
+ }
9771
+ accountTotals = mergeResults(results, totalsPlan.joinKeys);
9772
+ if (totalsPlan.calculatedMetrics.length) accountTotals = accountTotals.map((row) => ({ ...row, ...calculateDerivedMetrics(row) }));
9773
+ }
9774
+ }
9775
+ const evidence = reportingEvidence(allResults, plan.requests, plan.joinKeys, data);
8580
9776
  return ok({
8581
9777
  data,
9778
+ 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 },
9779
+ account_totals: accountTotals,
9780
+ evidence,
9781
+ notes: evidence.notes,
8582
9782
  rowCount: data.length,
8583
9783
  debug: {
8584
- requestCount: plan.requests.length,
9784
+ requestCount: plan.requests.length + totalsRequestCount,
8585
9785
  executionTimeMs: Date.now() - startTime,
8586
9786
  warnings: plan.warnings,
8587
9787
  calculatedMetrics: plan.calculatedMetrics
@@ -8597,7 +9797,7 @@ The query planner automatically splits incompatible metric/breakdown combination
8597
9797
  "Get hierarchical campaign structure: campaigns -> ad sets -> ads. Useful for understanding account organization.",
8598
9798
  {
8599
9799
  adAccountId: adAccountIdSchema,
8600
- campaignId: z2.string().optional().describe("Get structure for a specific campaign only")
9800
+ campaignId: z4.string().optional().describe("Get structure for a specific campaign only")
8601
9801
  },
8602
9802
  async ({ adAccountId, campaignId }) => {
8603
9803
  try {
@@ -8628,20 +9828,56 @@ The query planner automatically splits incompatible metric/breakdown combination
8628
9828
  "Get ad creative content: text, images, videos, links, call-to-action. Returns creative details for specified ads.",
8629
9829
  {
8630
9830
  adAccountId: adAccountIdSchema,
8631
- adIds: z2.array(z2.string()).optional().describe("Specific ad IDs to get creatives for"),
9831
+ adIds: z4.array(z4.string()).optional().describe("Specific ad IDs to get creatives for"),
8632
9832
  limit: limitSchema
8633
9833
  },
8634
9834
  async ({ adAccountId, adIds, limit }) => {
8635
9835
  try {
8636
9836
  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}";
8637
- let url;
8638
9837
  if (adIds && adIds.length > 0) {
8639
- const ids = adIds.join(",");
8640
- url = `https://graph.facebook.com/${client.apiVersion}/?ids=${ids}&fields=${fields}`;
8641
- } else {
8642
- url = `https://graph.facebook.com/${client.apiVersion}/${adAccountId}/ads?fields=${fields}&limit=${limit}`;
9838
+ const ids = [...new Set(adIds)];
9839
+ if (ids.length > 500 || ids.some((id2) => !/^\d+$/.test(id2))) {
9840
+ throw new Error("Provide at most 500 numeric ad IDs.");
9841
+ }
9842
+ const account = formatAdAccountId(adAccountId);
9843
+ const merged = {};
9844
+ const warnings = [];
9845
+ let requestCount = 0;
9846
+ for (let index = 0; index < ids.length; index += 50) {
9847
+ const chunk = ids.slice(index, index + 50);
9848
+ const result2 = await client.fetchUrl(graphUrl(client, `/${account}/ads`, {
9849
+ fields: `account_id,${fields}`,
9850
+ filtering: JSON.stringify([{ field: "id", operator: "IN", value: chunk }]),
9851
+ limit: 500
9852
+ }));
9853
+ requestCount++;
9854
+ for (const ad of dataArray(result2)) {
9855
+ if (typeof ad.id === "string" && chunk.includes(ad.id) && typeof ad.account_id === "string" && formatAdAccountId(ad.account_id) === account) {
9856
+ merged[ad.id] = ad;
9857
+ }
9858
+ }
9859
+ }
9860
+ const missing = ids.filter((id2) => !merged[id2]);
9861
+ for (const id2 of missing.slice(0, 10)) {
9862
+ requestCount++;
9863
+ const ad = await fetchGraph(
9864
+ client,
9865
+ "historical_ad_creative",
9866
+ graphUrl(client, `/${id2}`, { fields: `account_id,${fields}` }),
9867
+ warnings,
9868
+ "Verify that this historical ad is still accessible in the selected account."
9869
+ );
9870
+ if (ad?.id === id2 && typeof ad.account_id === "string" && formatAdAccountId(ad.account_id) === account) merged[id2] = ad;
9871
+ }
9872
+ const unresolved = ids.filter((id2) => !merged[id2]);
9873
+ if (unresolved.length) warnings.push({
9874
+ area: "creative_lookup",
9875
+ message: `${unresolved.length} requested ads could not be verified in the selected account.`,
9876
+ suggestion: "Narrow the selection; historical fallback is limited to 10 ads. Missing ads are not empty creatives."
9877
+ });
9878
+ return ok({ ...merged, warnings, debug: { requestCount } });
8643
9879
  }
8644
- const result = await client.fetchUrl(url);
9880
+ const result = await client.fetchUrl(graphUrl(client, `/${formatAdAccountId(adAccountId)}/ads`, { fields, limit }));
8645
9881
  return ok(result);
8646
9882
  } catch (e) {
8647
9883
  return formatMcpToolError(e);
@@ -8653,7 +9889,7 @@ The query planner automatically splits incompatible metric/breakdown combination
8653
9889
  "List custom, saved, and lookalike audiences for a Meta ad account.",
8654
9890
  {
8655
9891
  adAccountId: adAccountIdSchema,
8656
- type: z2.enum(["custom", "saved", "lookalike"]).optional().describe("Filter by audience type"),
9892
+ type: z4.enum(["custom", "saved", "lookalike"]).optional().describe("Filter by audience type"),
8657
9893
  limit: limitSchema
8658
9894
  },
8659
9895
  async ({ adAccountId, type, limit }) => {
@@ -8687,7 +9923,7 @@ The query planner automatically splits incompatible metric/breakdown combination
8687
9923
  server.tool(
8688
9924
  "meta_get_study_results",
8689
9925
  "Get detailed results for a conversion lift or A/B test study. Returns objectives, cells, and lift results.",
8690
- { studyId: z2.string().describe("The Ad Study ID") },
9926
+ { studyId: z4.string().describe("The Ad Study ID") },
8691
9927
  async ({ studyId }) => {
8692
9928
  try {
8693
9929
  const [cells, objectives] = await Promise.all([
@@ -8704,8 +9940,8 @@ The query planner automatically splits incompatible metric/breakdown combination
8704
9940
  "meta_validate_query",
8705
9941
  "Validate a metric/breakdown combination BEFORE executing. Returns errors and warnings. Use this to check if your query will work.",
8706
9942
  {
8707
- metrics: z2.array(z2.string()).min(1).describe("Metric keys to validate"),
8708
- breakdowns: z2.array(z2.string()).optional().describe("Breakdown keys to validate"),
9943
+ metrics: z4.array(z4.string()).min(1).describe("Metric keys to validate"),
9944
+ breakdowns: z4.array(z4.string()).optional().describe("Breakdown keys to validate"),
8709
9945
  level: levelSchema.optional().describe("Aggregation level")
8710
9946
  },
8711
9947
  async ({ metrics, breakdowns, level }) => {
@@ -8719,20 +9955,38 @@ The query planner automatically splits incompatible metric/breakdown combination
8719
9955
  );
8720
9956
  server.tool(
8721
9957
  "meta_get_page_posts",
8722
- "Get recent posts from a connected Facebook/Instagram page. Requires page access.",
9958
+ "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.",
8723
9959
  {
8724
- pageId: z2.string().describe("Facebook Page ID"),
8725
- limit: z2.number().int().min(1).max(100).optional().default(25)
9960
+ pageId: z4.string().regex(/^\d+$/).describe("Facebook Page ID"),
9961
+ postId: z4.string().regex(/^\d+_\d+$/).optional().describe("Exact PageID_PostID to read instead of the recent-post list; must belong to pageId."),
9962
+ limit: z4.number().int().min(1).max(100).optional().default(25),
9963
+ cursor: cursorSchema
8726
9964
  },
8727
- async ({ pageId, limit }) => {
8728
- try {
8729
- const fields = "id,message,created_time,type,permalink_url,full_picture,shares,likes.summary(true),comments.summary(true)";
8730
- const url = `https://graph.facebook.com/${client.apiVersion}/${pageId}/posts?fields=${fields}&limit=${limit}`;
8731
- const result = await client.fetchUrl(url);
8732
- return ok(result);
8733
- } catch (e) {
8734
- return formatMcpToolError(e);
9965
+ async ({ pageId, postId, limit, cursor }) => {
9966
+ const warnings = [];
9967
+ if (postId && (!postId.startsWith(`${pageId}_`) || cursor)) {
9968
+ return { ...ok({ error: "postId must belong to pageId and cannot be combined with a list cursor." }), isError: true };
8735
9969
  }
9970
+ const pageClient = await pageClientResolver(client, warnings)(pageId);
9971
+ if (!pageClient) return { ...ok({ error: "Could not obtain access to the selected Page. No posts were read.", warnings }), isError: true };
9972
+ const path = postId ? `/${encodeURIComponent(postId)}` : `/${encodeURIComponent(pageId)}/posts`;
9973
+ const params = postId ? {} : { limit, after: cursor };
9974
+ const result = await fetchGraphWithFallback(
9975
+ pageClient,
9976
+ "page_posts",
9977
+ graphUrl(pageClient, path, { ...params, fields: "id,message,created_time,permalink_url,full_picture,attachments{media,type,url,target,title,description},shares" }),
9978
+ graphUrl(pageClient, path, { ...params, fields: "id,message,created_time,permalink_url,full_picture,shares" }),
9979
+ warnings,
9980
+ "Post attachments require pages_read_engagement and access to this Page."
9981
+ );
9982
+ if (!result) return { ...ok({ error: "Could not read the selected Page posts.", warnings }), isError: true };
9983
+ return ok({
9984
+ ...postId ? { data: [result] } : result,
9985
+ page_id: pageId,
9986
+ coverage: { scope: postId ? "exact_post" : "page_posts", has_more: !!(isRecord(result.paging) && result.paging.next) },
9987
+ warnings,
9988
+ 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."]
9989
+ });
8736
9990
  }
8737
9991
  );
8738
9992
  server.tool(
@@ -8753,12 +10007,13 @@ The query planner automatically splits incompatible metric/breakdown combination
8753
10007
  "Search campaigns, ad sets, or ads by name within an ad account. Useful for finding specific entities.",
8754
10008
  {
8755
10009
  adAccountId: adAccountIdSchema,
8756
- entityType: z2.enum(["campaigns", "adsets", "ads"]).describe("Type of entity to search"),
8757
- nameFilter: z2.string().describe("Name substring to search for"),
10010
+ entityType: z4.enum(["campaigns", "adsets", "ads"]).describe("Type of entity to search"),
10011
+ nameFilter: z4.string().describe("Name substring to search for"),
8758
10012
  statusFilter: statusFilterSchema,
8759
- limit: limitSchema
10013
+ limit: limitSchema,
10014
+ cursor: cursorSchema
8760
10015
  },
8761
- async ({ adAccountId, entityType, nameFilter, statusFilter, limit }) => {
10016
+ async ({ adAccountId, entityType, nameFilter, statusFilter, limit, cursor }) => {
8762
10017
  try {
8763
10018
  const fieldsMap = {
8764
10019
  campaigns: "id,name,status,effective_status,objective",
@@ -8767,22 +10022,248 @@ The query planner automatically splits incompatible metric/breakdown combination
8767
10022
  };
8768
10023
  const fields = fieldsMap[entityType];
8769
10024
  const filters = [{ field: "name", operator: "CONTAIN", value: nameFilter }];
8770
- if (statusFilter) {
8771
- filters.push({ field: "effective_status", operator: "IN", value: statusFilter });
8772
- }
8773
- const url = `https://graph.facebook.com/${client.apiVersion}/${adAccountId}/${entityType}?fields=${fields}&limit=${limit}&filtering=${encodeURIComponent(JSON.stringify(filters))}`;
10025
+ if (statusFilter) filters.push({ field: "effective_status", operator: "IN", value: [statusFilter] });
10026
+ const url = graphUrl(client, `/${formatAdAccountId(adAccountId)}/${entityType}`, {
10027
+ fields,
10028
+ limit,
10029
+ filtering: JSON.stringify(filters),
10030
+ after: cursor
10031
+ });
8774
10032
  const result = await client.fetchUrl(url);
8775
- return ok(result);
10033
+ return ok(entityListResult(result, statusFilter));
8776
10034
  } catch (e) {
8777
10035
  return formatMcpToolError(e);
8778
10036
  }
8779
10037
  }
8780
10038
  );
8781
10039
  registerMetaBroadReadTools(server, client, ok);
10040
+ server.tool(
10041
+ "meta_list_ad_images",
10042
+ "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.",
10043
+ {
10044
+ adAccountId: adAccountIdSchema,
10045
+ nameFilter: z4.string().optional().describe("Only images whose file name matches this value"),
10046
+ hashes: z4.array(z4.string()).optional().describe("Only these image hashes (exact match)"),
10047
+ minWidth: z4.number().int().min(1).optional().describe("Only images at least this wide, in pixels"),
10048
+ minHeight: z4.number().int().min(1).optional().describe("Only images at least this tall, in pixels"),
10049
+ includeUsage: z4.boolean().optional().default(false).describe("Also return the creative IDs using each image"),
10050
+ limit: limitSchema,
10051
+ cursor: cursorSchema
10052
+ },
10053
+ async ({ adAccountId, nameFilter, hashes, minWidth, minHeight, includeUsage, limit, cursor }) => {
10054
+ const warnings = [];
10055
+ const baseFields = [
10056
+ "id",
10057
+ "hash",
10058
+ "name",
10059
+ "status",
10060
+ "created_time",
10061
+ "updated_time",
10062
+ "original_width",
10063
+ "original_height",
10064
+ "width",
10065
+ "height",
10066
+ "permalink_url",
10067
+ "url",
10068
+ "url_128"
10069
+ ];
10070
+ if (includeUsage) baseFields.push("creatives");
10071
+ const result = await fetchGraph(
10072
+ client,
10073
+ "ad_images",
10074
+ graphUrl(client, `/${formatAdAccountId(adAccountId)}/adimages`, {
10075
+ fields: baseFields.join(","),
10076
+ limit: typeof limit === "number" ? limit : 100,
10077
+ after: cursor,
10078
+ summary: "total_count",
10079
+ name: nameFilter,
10080
+ hashes: Array.isArray(hashes) && hashes.length > 0 ? JSON.stringify(hashes) : void 0,
10081
+ minwidth: minWidth,
10082
+ minheight: minHeight
10083
+ }),
10084
+ warnings,
10085
+ "Verify the token has ads_read on this ad account."
10086
+ );
10087
+ const images = dataArray(result).map((image) => ({
10088
+ ...image,
10089
+ creatives: Array.isArray(image.creatives) ? image.creatives : void 0
10090
+ }));
10091
+ const summary = isRecord(result?.summary) ? result.summary : void 0;
10092
+ return ok({
10093
+ images,
10094
+ count: images.length,
10095
+ totalCount: typeof summary?.total_count === "number" ? summary.total_count : void 0,
10096
+ paging: pagingInfo(result),
10097
+ warnings,
10098
+ limitations: [
10099
+ "Use url (or url_128 for a small preview) for image bytes. permalink_url can return Facebook HTML and is not an image file.",
10100
+ "CDN URLs are signed and can expire. Re-list by hash to refresh; do not store the URLs as durable links."
10101
+ ],
10102
+ nextActions: pagingInfo(result) ? ["Pass the paging cursor as `cursor` to fetch the next page."] : []
10103
+ });
10104
+ }
10105
+ );
10106
+ server.tool(
10107
+ "meta_list_ad_videos",
10108
+ "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.",
10109
+ {
10110
+ adAccountId: adAccountIdSchema,
10111
+ titleFilter: z4.string().optional().describe("Only videos whose title contains this value"),
10112
+ minLengthSeconds: z4.number().min(0).optional().describe("Only videos at least this many seconds long"),
10113
+ maxLengthSeconds: z4.number().min(1).optional().describe("Only videos at most this many seconds long"),
10114
+ includeSource: z4.boolean().optional().default(false).describe("Also return the short-lived raw video file URL for each video"),
10115
+ limit: z4.number().int().min(1).max(200).optional().default(25).describe("Videos per page (thumbnail payloads are heavy, keep this modest)"),
10116
+ cursor: cursorSchema
10117
+ },
10118
+ async ({ adAccountId, titleFilter, minLengthSeconds, maxLengthSeconds, includeSource, limit, cursor }) => {
10119
+ const warnings = [];
10120
+ const baseFields = [
10121
+ "id",
10122
+ "title",
10123
+ "status",
10124
+ "created_time",
10125
+ "updated_time",
10126
+ "length",
10127
+ "picture",
10128
+ "permalink_url",
10129
+ "thumbnails{uri,width,height,scale,is_preferred}"
10130
+ ];
10131
+ if (includeSource) baseFields.push("source");
10132
+ const result = await fetchGraph(
10133
+ client,
10134
+ "ad_videos",
10135
+ graphUrl(client, `/${formatAdAccountId(adAccountId)}/advideos`, {
10136
+ fields: baseFields.join(","),
10137
+ limit: typeof limit === "number" ? limit : 25,
10138
+ after: cursor,
10139
+ summary: "total_count",
10140
+ title: titleFilter,
10141
+ // Vérifié en live (Graph v26.0) : minlength/maxlength s'expriment en
10142
+ // millisecondes malgré une doc muette; en secondes, maxlength=120
10143
+ // élimine silencieusement toutes les vidéos.
10144
+ minlength: typeof minLengthSeconds === "number" ? Math.floor(minLengthSeconds * 1e3) : void 0,
10145
+ maxlength: typeof maxLengthSeconds === "number" ? Math.ceil(maxLengthSeconds * 1e3) : void 0
10146
+ }),
10147
+ warnings,
10148
+ "Verify the token has ads_read on this ad account."
10149
+ );
10150
+ const videos = dataArray(result).map((video) => normalizeAdVideo(video, Boolean(includeSource)));
10151
+ const summary = isRecord(result?.summary) ? result.summary : void 0;
10152
+ return ok({
10153
+ videos,
10154
+ count: videos.length,
10155
+ totalCount: typeof summary?.total_count === "number" ? summary.total_count : void 0,
10156
+ paging: pagingInfo(result),
10157
+ warnings,
10158
+ limitations: [
10159
+ "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.",
10160
+ "permalink_url points at facebook.com/watch and requires a logged-in session: unsuitable for hotlinking.",
10161
+ 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."
10162
+ ],
10163
+ nextActions: pagingInfo(result) ? ["Pass the paging cursor as `cursor` to fetch the next page."] : []
10164
+ });
10165
+ }
10166
+ );
10167
+ server.tool(
10168
+ "meta_get_video_sources",
10169
+ "Resolve fresh download URLs (source) and thumbnails for specific ad videos. Call this at download time: the returned URLs are signed and expire quickly.",
10170
+ {
10171
+ adAccountId: adAccountIdSchema.describe("Ad account that owns the videos, used for scoping and rate limits"),
10172
+ videoIds: z4.array(z4.string()).min(1).max(10).describe("Video IDs to resolve (at most 10 per call, one Graph request each)"),
10173
+ pageId: z4.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.")
10174
+ },
10175
+ async ({ adAccountId, videoIds, pageId: owningPageId }) => {
10176
+ void adAccountId;
10177
+ const warnings = [];
10178
+ const maxIdsPerCall = 10;
10179
+ const requested = Array.isArray(videoIds) ? [...new Set(videoIds.map(String).filter(Boolean))] : [];
10180
+ const ids = requested.slice(0, maxIdsPerCall);
10181
+ if (ids.length === 0) {
10182
+ return ok({
10183
+ videos: [],
10184
+ count: 0,
10185
+ warnings: [{
10186
+ area: "video_sources",
10187
+ message: "No video IDs were provided.",
10188
+ suggestion: "Pass videoIds from meta_list_ad_videos."
10189
+ }]
10190
+ });
10191
+ }
10192
+ if (requested.length > maxIdsPerCall) {
10193
+ warnings.push({
10194
+ area: "video_sources",
10195
+ message: `Only the first ${maxIdsPerCall} of ${requested.length} video IDs were resolved.`,
10196
+ suggestion: "Call again with the remaining IDs: each video costs one Graph request."
10197
+ });
10198
+ }
10199
+ const videoFields = "id,title,status,length,source,picture,permalink_url,from,thumbnails{uri,width,height,is_preferred}";
10200
+ const fetched = [];
10201
+ for (const videoId of ids) {
10202
+ const result = await fetchGraph(
10203
+ client,
10204
+ `video_${videoId}`,
10205
+ graphUrl(client, `/${encodeURIComponent(videoId)}`, { fields: videoFields }),
10206
+ warnings,
10207
+ "Verify the video ID comes from meta_list_ad_videos and is readable by this token."
10208
+ );
10209
+ if (result) fetched.push(result);
10210
+ else if (owningPageId) fetched.push({ id: videoId });
10211
+ }
10212
+ const pageClients = /* @__PURE__ */ new Map();
10213
+ for (const video of fetched) {
10214
+ if (typeof video.source === "string") continue;
10215
+ const from = isRecord(video.from) ? video.from : void 0;
10216
+ const pageId = typeof from?.id === "string" ? from.id : owningPageId;
10217
+ if (!pageId) continue;
10218
+ let pageClient = pageClients.get(pageId);
10219
+ if (pageClient === void 0) {
10220
+ const tokenResult = await fetchGraph(
10221
+ client,
10222
+ `page_token_${pageId}`,
10223
+ graphUrl(client, `/${pageId}`, { fields: "access_token" }),
10224
+ warnings,
10225
+ "Resolving a Page-hosted video file requires an admin role on the owning Page and the pages_show_list scope."
10226
+ );
10227
+ const pageAccessToken = typeof tokenResult?.access_token === "string" ? tokenResult.access_token : void 0;
10228
+ pageClient = pageAccessToken ? new MetaClient(pageAccessToken, client.apiVersion) : null;
10229
+ pageClients.set(pageId, pageClient);
10230
+ }
10231
+ if (!pageClient) continue;
10232
+ const retried = await fetchGraph(
10233
+ pageClient,
10234
+ `video_${video.id}_via_page`,
10235
+ graphUrl(pageClient, `/${encodeURIComponent(String(video.id))}`, { fields: videoFields }),
10236
+ warnings,
10237
+ "The Page token could not read this video: verify the Page role covers content access."
10238
+ );
10239
+ if (retried && typeof retried.source === "string") Object.assign(video, retried);
10240
+ }
10241
+ const videos = fetched.map((video) => normalizeAdVideo(video, true));
10242
+ const withoutSource = videos.filter((video) => video.source === void 0).length;
10243
+ if (withoutSource > 0) {
10244
+ warnings.push({
10245
+ area: "video_sources",
10246
+ message: `${withoutSource} video(s) returned no source file even via their owning Page.`,
10247
+ suggestion: "Their thumbnails and metadata remain available; the file requires a token holding an admin role on the owning Page."
10248
+ });
10249
+ }
10250
+ return ok({
10251
+ videos,
10252
+ count: videos.length,
10253
+ warnings,
10254
+ limitations: [
10255
+ "source URLs are signed, publicly fetchable download links that expire quickly: use them immediately and re-call this tool for fresh ones.",
10256
+ "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."
10257
+ ]
10258
+ });
10259
+ }
10260
+ );
8782
10261
  }
8783
10262
 
8784
10263
  // src/platforms/meta/resources.ts
8785
10264
  var READ_ONLY_TOOLS = [
10265
+ "meta_get_adset_configuration",
10266
+ "meta_get_catalog_batch_status",
8786
10267
  "meta_health_check",
8787
10268
  "meta_list_ad_accounts",
8788
10269
  "meta_get_account_details",
@@ -8816,7 +10297,12 @@ var READ_ONLY_TOOLS = [
8816
10297
  "meta_list_edge_raw",
8817
10298
  "meta_get_insights_raw",
8818
10299
  "meta_search_targeting_options",
8819
- "meta_get_ad_preview"
10300
+ "meta_get_ad_preview",
10301
+ "meta_list_ad_images",
10302
+ "meta_list_ad_videos",
10303
+ "meta_get_video_sources",
10304
+ "meta_get_entity_configuration",
10305
+ "meta_get_uploaded_video"
8820
10306
  ];
8821
10307
  function jsonResource(uri, data) {
8822
10308
  return {
@@ -9108,11 +10594,11 @@ function registerMetaResources(server, enableWrites = false) {
9108
10594
  },
9109
10595
  {
9110
10596
  scope: "catalog_management",
9111
- 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."
10597
+ 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."
9112
10598
  }
9113
10599
  ],
9114
10600
  write_scopes_to_avoid_for_this_server: [
9115
- "ads_management",
10601
+ ...!enableWrites ? ["ads_management"] : [],
9116
10602
  "pages_manage_posts",
9117
10603
  "pages_manage_metadata",
9118
10604
  "instagram_content_publish"
@@ -9128,14 +10614,15 @@ function registerMetaResources(server, enableWrites = false) {
9128
10614
  data_handling_notes: [
9129
10615
  "Tool responses redact access_token fields and access_token query parameters.",
9130
10616
  "Permission failures are returned as warnings for discovery tools when a partial response is still useful.",
9131
- "No write or mutation endpoints are registered by this server."
10617
+ enableWrites ? "Write tools are enabled; each mutation requires confirm:true after preview." : "Write tools are disabled on this instance."
9132
10618
  ]
9133
10619
  }
9134
10620
  ));
9135
10621
  }
9136
10622
 
9137
10623
  // src/platforms/meta/writes.ts
9138
- import { z as z3 } from "zod";
10624
+ import { z as z5 } from "zod";
10625
+ var ADS_BASE = "https://googleads.googleapis.com/v25";
9139
10626
  function ok2(data) {
9140
10627
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
9141
10628
  }
@@ -9150,38 +10637,9 @@ function preview(action, details) {
9150
10637
  message: "Preview only, nothing was changed. Repeat the same call with confirm: true to apply this change to the live account."
9151
10638
  });
9152
10639
  }
9153
- var confirmSchema = z3.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
9154
- var ZERO_DECIMAL = /* @__PURE__ */ new Set([
9155
- "JPY",
9156
- "KRW",
9157
- "CLP",
9158
- "ISK",
9159
- "VND",
9160
- "UGX",
9161
- "PYG",
9162
- "RWF",
9163
- "XOF",
9164
- "XAF",
9165
- "XPF",
9166
- "BIF",
9167
- "DJF",
9168
- "GNF",
9169
- "KMF",
9170
- "MGA",
9171
- "VUV"
9172
- ]);
9173
- function toMinorUnits(amount, currency) {
9174
- const code = currency.trim().toUpperCase();
9175
- if (!/^[A-Z]{3}$/.test(code)) {
9176
- throw new Error(`Expected a three letter currency code, received "${currency}".`);
9177
- }
9178
- if (!Number.isFinite(amount) || amount <= 0) {
9179
- throw new Error(`Expected a positive amount, received "${amount}".`);
9180
- }
9181
- return Math.round(amount * (ZERO_DECIMAL.has(code) ? 1 : 100));
9182
- }
9183
- async function request(url, init, context) {
9184
- const response = await fetch(url, init);
10640
+ var confirmSchema = z5.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
10641
+ async function request(url, init, contexte) {
10642
+ const response = await fetch(url, { ...init, redirect: "error" });
9185
10643
  const body = await response.text();
9186
10644
  let parsed;
9187
10645
  try {
@@ -9190,223 +10648,111 @@ async function request(url, init, context) {
9190
10648
  parsed = body;
9191
10649
  }
9192
10650
  if (!response.ok) {
9193
- const detail = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
9194
- throw new Error(`${context}: ${detail.slice(0, 300)}`);
10651
+ if (url.startsWith(ADS_BASE + "/")) {
10652
+ const error = parsed?.error;
10653
+ const details = error?.details?.flatMap((d) => d.errors ?? []).map((e) => ({ code: e.errorCode, message: e.message, location: e.location }));
10654
+ throw new Error(`${contexte}: ${JSON.stringify(details?.length ? details : error?.message ?? parsed).slice(0, 3e3)}`);
10655
+ }
10656
+ throw new Error(`${contexte} : ${typeof parsed === "string" ? parsed.slice(0, 300) : JSON.stringify(parsed).slice(0, 300)}`);
9195
10657
  }
9196
10658
  return parsed;
9197
10659
  }
9198
- function registerMetaWrites(server, config) {
9199
- const version = config.apiVersion || DEFAULT_API_VERSION;
10660
+ function registerMetaWrites(c, config) {
10661
+ registerMetaExtendedWrites(c, config);
10662
+ const destination = c;
10663
+ c = { tool(n, d, s, h) {
10664
+ destination.tool(n, d, s, async (raw) => {
10665
+ try {
10666
+ const a = z5.object(s).strict().parse(raw);
10667
+ if (a.confirm) await verifyMetaWriteScope(config, a);
10668
+ return await h(a);
10669
+ } catch (e) {
10670
+ return ko(e.message);
10671
+ }
10672
+ });
10673
+ } };
10674
+ const version = config.apiVersion || "v26.0";
9200
10675
  const graph = (path) => `https://graph.facebook.com/${version}/${path}`;
9201
- const form = (fields) => ({
9202
- method: "POST",
9203
- headers: { "content-type": "application/x-www-form-urlencoded" },
9204
- body: new URLSearchParams({ ...fields, access_token: config.accessToken })
9205
- });
9206
- const statusTool = (name, target, param) => server.tool(
9207
- name,
10676
+ const statusTool = (nom, target, param) => c.tool(
10677
+ nom,
9208
10678
  `Pause or reactivate a Meta ${target}. Previews by default: without confirm: true, the tool describes the change without applying it.`,
9209
10679
  {
9210
- [param]: z3.string().describe(`${target} ID.`),
9211
- adAccountId: z3.string().describe("Owning ad account (act_\u2026 or bare ID)."),
9212
- status: z3.enum(["ACTIVE", "PAUSED"]).describe("ACTIVE reactivates, PAUSED stops delivery."),
10680
+ [param]: z5.string().describe(`${target} ID.`),
10681
+ adAccountId: z5.string().describe("Owning ad account (act_\u2026 or bare ID)."),
10682
+ status: z5.enum(["ACTIVE", "PAUSED"]).describe("ACTIVE reactivates, PAUSED stops delivery."),
9213
10683
  confirm: confirmSchema
9214
10684
  },
9215
10685
  async (a) => {
9216
- const id = String(a[param]);
10686
+ const id2 = String(a[param]);
9217
10687
  const { status, confirm } = a;
9218
- if (!confirm) return preview(name, { target: id, newStatus: status });
9219
- const result = await request(graph(id), form({ status: String(status) }), `Meta ${target} status`);
9220
- return ok2({ applied: true, action: name, result });
10688
+ if (!confirm) return preview(nom, { target: id2, newStatus: status });
10689
+ const result = await request(
10690
+ graph(id2),
10691
+ {
10692
+ method: "POST",
10693
+ headers: { "content-type": "application/x-www-form-urlencoded" },
10694
+ body: new URLSearchParams({ status: String(status), access_token: config.accessToken })
10695
+ },
10696
+ `Meta ${target} status`
10697
+ );
10698
+ return ok2({ applied: true, action: nom, result });
9221
10699
  }
9222
10700
  );
9223
10701
  statusTool("meta_update_campaign_status", "campaign", "campaignId");
9224
10702
  statusTool("meta_update_adset_status", "ad set", "adSetId");
9225
10703
  statusTool("meta_update_ad_status", "ad", "adId");
9226
- const renameTool = (name, target, param) => server.tool(
9227
- name,
10704
+ const renameTool = (nom, target, param) => c.tool(
10705
+ nom,
9228
10706
  `Rename a Meta ${target}. The name is the only thing that changes. Previews by default.`,
9229
10707
  {
9230
- [param]: z3.string().describe(`${target} ID.`),
9231
- adAccountId: z3.string().describe("Owning ad account (act_\u2026 or bare ID)."),
9232
- name: z3.string().min(1).max(400).describe("New name. This is the only thing the call changes."),
10708
+ [param]: z5.string().describe(`${target} ID.`),
10709
+ adAccountId: z5.string().describe("Owning ad account (act_\u2026 or bare ID)."),
10710
+ name: z5.string().min(1).max(400).describe("New name. This is the only thing the call changes."),
9233
10711
  confirm: confirmSchema
9234
10712
  },
9235
10713
  async (a) => {
9236
- const id = String(a[param]);
9237
- const { name: newName, confirm } = a;
9238
- if (!confirm) return preview(name, { target: id, newName });
9239
- const result = await request(graph(id), form({ name: String(newName) }), `Meta ${target} rename`);
9240
- return ok2({ applied: true, action: name, result });
10714
+ const id2 = String(a[param]);
10715
+ const { name: name2, confirm } = a;
10716
+ if (!confirm) return preview(nom, { target: id2, newName: name2 });
10717
+ const result = await request(
10718
+ graph(id2),
10719
+ {
10720
+ method: "POST",
10721
+ headers: { "content-type": "application/x-www-form-urlencoded" },
10722
+ body: new URLSearchParams({ name: String(name2), access_token: config.accessToken })
10723
+ },
10724
+ `Meta ${target} rename`
10725
+ );
10726
+ return ok2({ applied: true, action: nom, result });
9241
10727
  }
9242
10728
  );
9243
10729
  renameTool("meta_rename_campaign", "campaign", "campaignId");
9244
10730
  renameTool("meta_rename_adset", "ad set", "adSetId");
9245
10731
  renameTool("meta_rename_ad", "ad", "adId");
9246
- server.tool(
9247
- "meta_create_campaign",
9248
- "Create a Meta campaign. It is always created PAUSED and there is no option to create it active. Previews by default.",
9249
- {
9250
- adAccountId: z3.string().describe("Ad account, act_\u2026 or bare ID."),
9251
- name: z3.string().min(1).max(400).describe("Campaign name."),
9252
- objective: z3.enum([
9253
- "OUTCOME_TRAFFIC",
9254
- "OUTCOME_SALES",
9255
- "OUTCOME_LEADS",
9256
- "OUTCOME_AWARENESS",
9257
- "OUTCOME_ENGAGEMENT",
9258
- "OUTCOME_APP_PROMOTION"
9259
- ]).describe("Campaign objective."),
9260
- specialAdCategories: z3.array(z3.enum(["NONE", "HOUSING", "EMPLOYMENT", "CREDIT", "ISSUES_ELECTIONS_POLITICS"])).optional().describe("Required by Meta. Defaults to none."),
9261
- confirm: confirmSchema
9262
- },
9263
- async (a) => {
9264
- const { adAccountId, name, objective, specialAdCategories, confirm } = a;
9265
- const act = String(adAccountId).startsWith("act_") ? String(adAccountId) : `act_${adAccountId}`;
9266
- const categories = Array.isArray(specialAdCategories) ? specialAdCategories : [];
9267
- if (!confirm) {
9268
- return preview("meta_create_campaign", {
9269
- adAccount: act,
9270
- name,
9271
- objective,
9272
- specialAdCategories: categories,
9273
- status: "PAUSED"
9274
- });
9275
- }
9276
- const result = await request(
9277
- graph(`${act}/campaigns`),
9278
- form({
9279
- name: String(name),
9280
- objective: String(objective),
9281
- status: "PAUSED",
9282
- special_ad_categories: JSON.stringify(categories)
9283
- }),
9284
- "Meta campaign creation"
9285
- );
9286
- return ok2({ applied: true, action: "meta_create_campaign", status: "PAUSED", result });
9287
- }
9288
- );
9289
- server.tool(
9290
- "meta_update_adset_budget",
9291
- "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.",
9292
- {
9293
- adSetId: z3.string().describe("Ad set ID."),
9294
- adAccountId: z3.string().describe("Owning ad account, with or without the act_ prefix."),
9295
- currency: z3.string().length(3).describe("Account currency code, for example EUR. Read it with meta_list_ad_accounts."),
9296
- dailyBudget: z3.number().positive().optional().describe("Daily budget, in the account currency."),
9297
- lifetimeBudget: z3.number().positive().optional().describe("Lifetime budget, mutually exclusive with the daily budget."),
9298
- confirm: confirmSchema
9299
- },
9300
- async (a) => {
9301
- const { adSetId, currency, dailyBudget, lifetimeBudget, confirm } = a;
9302
- if (dailyBudget === void 0 && lifetimeBudget === void 0) {
9303
- return ko("Provide either dailyBudget or lifetimeBudget.");
9304
- }
9305
- if (dailyBudget !== void 0 && lifetimeBudget !== void 0) {
9306
- return ko("dailyBudget and lifetimeBudget are mutually exclusive; Meta rejects both together.");
9307
- }
9308
- const field = dailyBudget !== void 0 ? "daily_budget" : "lifetime_budget";
9309
- const amount = Number(dailyBudget ?? lifetimeBudget);
9310
- let minor;
9311
- try {
9312
- minor = toMinorUnits(amount, String(currency));
9313
- } catch (error) {
9314
- return ko(error instanceof Error ? error.message : String(error));
9315
- }
9316
- if (!confirm) {
9317
- return preview("meta_update_adset_budget", {
9318
- adSet: adSetId,
9319
- field,
9320
- amount,
9321
- currency,
9322
- inMinorUnits: minor
9323
- });
9324
- }
9325
- const result = await request(graph(String(adSetId)), form({ [field]: String(minor) }), "Meta ad set budget");
9326
- return ok2({ applied: true, action: "meta_update_adset_budget", result });
9327
- }
9328
- );
9329
- server.tool(
9330
- "meta_update_campaign_budget",
9331
- "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.",
9332
- {
9333
- campaignId: z3.string().describe("Campaign ID."),
9334
- adAccountId: z3.string().describe("Owning ad account (act_\u2026 or bare ID)."),
9335
- currency: z3.string().length(3).describe("Account currency code, for example EUR. Read it with meta_list_ad_accounts."),
9336
- dailyBudget: z3.number().positive().optional().describe("New daily budget, in the account currency."),
9337
- lifetimeBudget: z3.number().positive().optional().describe("New lifetime budget, in the account currency."),
9338
- confirm: confirmSchema
9339
- },
9340
- async (a) => {
9341
- const { campaignId, currency, dailyBudget, lifetimeBudget, confirm } = a;
9342
- if (dailyBudget === void 0 && lifetimeBudget === void 0) {
9343
- return ko("Provide either dailyBudget or lifetimeBudget.");
9344
- }
9345
- if (dailyBudget !== void 0 && lifetimeBudget !== void 0) {
9346
- return ko("Provide only one of dailyBudget or lifetimeBudget; Meta holds one or the other.");
9347
- }
9348
- const field = dailyBudget !== void 0 ? "daily_budget" : "lifetime_budget";
9349
- const amount = Number(dailyBudget ?? lifetimeBudget);
9350
- let minor;
9351
- try {
9352
- minor = toMinorUnits(amount, String(currency));
9353
- } catch (error) {
9354
- return ko(error instanceof Error ? error.message : String(error));
9355
- }
9356
- if (!confirm) {
9357
- return preview("meta_update_campaign_budget", {
9358
- campaign: campaignId,
9359
- field,
9360
- amount,
9361
- currency,
9362
- inMinorUnits: minor
9363
- });
9364
- }
9365
- const result = await request(graph(String(campaignId)), form({ [field]: String(minor) }), "Meta campaign budget");
9366
- return ok2({ applied: true, action: "meta_update_campaign_budget", result });
9367
- }
9368
- );
9369
- server.tool(
9370
- "meta_update_adset_schedule",
9371
- "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.",
9372
- {
9373
- adSetId: z3.string().describe("Ad set ID."),
9374
- adAccountId: z3.string().describe("Owning ad account (act_\u2026 or bare ID)."),
9375
- startTime: z3.string().optional().describe("Start time, ISO 8601 with an offset, for example 2026-09-01T00:00:00+0200."),
9376
- endTime: z3.string().optional().describe("End time, ISO 8601 with an offset, for example 2026-09-30T23:59:59+0200."),
9377
- confirm: confirmSchema
9378
- },
9379
- async (a) => {
9380
- const { adSetId, startTime, endTime, confirm } = a;
9381
- if (!startTime && !endTime) return ko("Provide startTime, endTime, or both.");
9382
- const fields = {};
9383
- if (startTime) fields.start_time = String(startTime);
9384
- if (endTime) fields.end_time = String(endTime);
9385
- if (!confirm) return preview("meta_update_adset_schedule", { adSet: adSetId, startTime, endTime });
9386
- const result = await request(graph(String(adSetId)), form(fields), "Meta ad set schedule");
9387
- return ok2({ applied: true, action: "meta_update_adset_schedule", result });
9388
- }
9389
- );
9390
10732
  }
9391
10733
 
9392
10734
  // src/platforms/meta/index.ts
9393
10735
  function registerMeta(server, config) {
9394
10736
  registerMetaTools(server, config);
10737
+ registerMetaExtendedWrites(server, config, true);
9395
10738
  registerMetaResources(server, config.enableWrites ?? false);
9396
- logger.info("meta", "Registered 34 read tools and 7 resources");
10739
+ logger.info("meta", "Registered 41 read tools and 7 resources");
9397
10740
  if (config.enableWrites) {
9398
10741
  registerMetaWrites(server, config);
9399
- logger.info("meta", "Registered 10 write tools (every one previews before it applies)");
10742
+ logger.info("meta", "Registered 23 write tools (every one previews before it applies)");
9400
10743
  }
9401
10744
  }
9402
10745
 
9403
10746
  // src/server.ts
9404
- var PACKAGE_VERSION = "1.0.0";
10747
+ var PACKAGE_VERSION = "2.0.0";
9405
10748
  function createServer(config) {
9406
10749
  const server = new McpServer(
9407
10750
  {
9408
10751
  name: "meta-ads-mcp",
9409
- version: PACKAGE_VERSION
10752
+ version: PACKAGE_VERSION,
10753
+ title: "Meta Ads",
10754
+ websiteUrl: "https://www.getmcpads.com/tools/meta-ads",
10755
+ icons: [{ src: "https://mcp.getmcpads.com/icon.svg", mimeType: "image/svg+xml" }]
9410
10756
  },
9411
10757
  {
9412
10758
  capabilities: {
@@ -9415,6 +10761,7 @@ function createServer(config) {
9415
10761
  }
9416
10762
  }
9417
10763
  );
10764
+ installToolQuality(server);
9418
10765
  if (config.meta) {
9419
10766
  registerMeta(server, config.meta);
9420
10767
  logger.system(
@@ -9427,15 +10774,15 @@ function createServer(config) {
9427
10774
  }
9428
10775
 
9429
10776
  // src/config/schema.ts
9430
- import { z as z4 } from "zod";
9431
- var metaSchema = z4.object({
9432
- accessToken: z4.string().min(1),
9433
- apiVersion: z4.string().optional(),
9434
- enableWrites: z4.boolean().optional()
10777
+ import { z as z6 } from "zod";
10778
+ var metaSchema = z6.object({
10779
+ accessToken: z6.string().min(1),
10780
+ apiVersion: z6.string().optional(),
10781
+ enableWrites: z6.boolean().optional()
9435
10782
  });
9436
- var serverConfigSchema = z4.object({
10783
+ var serverConfigSchema = z6.object({
9437
10784
  meta: metaSchema.optional(),
9438
- logLevel: z4.enum(["debug", "info", "warn", "error"]).default("info")
10785
+ logLevel: z6.enum(["debug", "info", "warn", "error"]).default("info")
9439
10786
  });
9440
10787
 
9441
10788
  // src/config/index.ts